Add a new narrative forecast module and remove the unimplemented daily forecast stub
This commit is contained in:
@@ -24,6 +24,7 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
}{
|
||||
{id: module.Metadata, stanza: "metadata"},
|
||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{id: module.WeatherStory, stanza: "weather_story"},
|
||||
@@ -44,6 +45,51 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[NarrativeForecastModule](t, output)
|
||||
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
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)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "probability_of_precipitation_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -130,10 +176,12 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Current = nil
|
||||
ctx.Collected.Narrative = nil
|
||||
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||
ctx.Collected.Discussion = nil
|
||||
ctx.Collected.WeatherStory = nil
|
||||
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
@@ -212,6 +260,9 @@ func testModuleContext() ModuleContext {
|
||||
humidity := 71.0
|
||||
windMph := 8.0
|
||||
windDirection := 190.0
|
||||
narrativeTempF := 81.0
|
||||
narrativePop := 60.0
|
||||
narrativeWind := 12.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
@@ -225,6 +276,25 @@ func testModuleContext() ModuleContext {
|
||||
WindSpeedMph: &windMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
},
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "narrative",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
@@ -257,6 +327,18 @@ func testModuleContext() ModuleContext {
|
||||
}},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{
|
||||
Event: "Flood Watch",
|
||||
Headline: "Flooding possible",
|
||||
|
||||
@@ -194,6 +194,15 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||
}
|
||||
narrative := []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(81),
|
||||
},
|
||||
}
|
||||
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
@@ -202,11 +211,19 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: summary.Period,
|
||||
},
|
||||
Collected: facts.CollectedFacts{
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
Product: "narrative",
|
||||
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
|
||||
},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
ValidPeriodNarrativePeriods: narrative,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
|
||||
@@ -152,6 +152,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
||||
switch requirement {
|
||||
case module.CollectedCurrentConditions:
|
||||
return ctx.Collected.Current != nil
|
||||
case module.CollectedNarrativeForecast:
|
||||
return ctx.Collected.Narrative != nil
|
||||
case module.CollectedAlerts:
|
||||
return ctx.Collected.Alerts != nil
|
||||
case module.CollectedDiscussion:
|
||||
@@ -262,6 +264,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
},
|
||||
{
|
||||
ID: module.NarrativeForecast,
|
||||
StanzaName: "narrative_forecast",
|
||||
DefaultOptions: module.NarrativeForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
|
||||
91
internal/briefing/narrative_forecast_module.go
Normal file
91
internal/briefing/narrative_forecast_module.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type NarrativeForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type NarrativeForecastPeriod struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
narrative := ctx.Collected.Narrative
|
||||
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := NarrativeForecastModule{
|
||||
Product: narrative.Product,
|
||||
IssuedAt: narrative.IssuedAt,
|
||||
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||
SourceLocation: narrative.LocationName,
|
||||
SourceLocationID: narrative.LocationID,
|
||||
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirectionDegrees: copyFloat(period.WindDirectionDegrees),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v NarrativeForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
Reference in New Issue
Block a user