Add a new narrative forecast module and remove the unimplemented daily forecast stub

This commit is contained in:
2026-06-10 08:24:50 -05:00
parent 7b760a0823
commit c3da3af2f4
19 changed files with 255 additions and 44 deletions

View File

@@ -129,8 +129,8 @@ name the variable only; they should not contain the token value.
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`. - `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
- `sources`: optional map of source-specific overrides, using the same policy values. - `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources and Hourly forecast data is required for generated reports. Optional sources use
stub source slots use the missing-source policy. the missing-source policy.
### `scriptorium` ### `scriptorium`
@@ -196,6 +196,7 @@ reports:
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
- narrative_forecast
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
@@ -209,8 +210,8 @@ combinations, duplicate stanza names, and invalid options fail config loading.
includes all available AFD sections. includes all available AFD sections.
The module registry accepts all module IDs documented in The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Modules without builders are [Module Contract Internals](internal/module.md). Unknown or unimplemented
valid in composition but do not emit YAML stanzas. module IDs fail validation instead of being skipped.
## Secrets ## Secrets

View File

@@ -89,10 +89,6 @@ source-specific `missing_source.sources` policy:
- `discussion` for `/discussion` - `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest` - `weather_story` for `/weatherstories/latest`
The adapter also creates a missing stub source record for `daily` because that
source slot exists in the internal bundle but is not fetched from the Weather
API.
Policy behavior: Policy behavior:
- `error`: fail the fetch for that source - `error`: fail the fetch for that source

View File

@@ -27,15 +27,14 @@ Outputs:
- `ModuleDefinition` values with module ID, stanza name, option type, - `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas: - `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `alert_digest`, `metadata`, `current_conditions`, `narrative_forecast`, `alert_digest`,
`area_forecast_discussion`, and `weather_story` `area_forecast_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`
The registry also contains accepted composition entries for modules that do not Every registered composition entry has a builder. Unknown or unimplemented
emit stanzas until a builder exists. App orchestration skips those entries when module IDs fail validation instead of being skipped.
constructing snapshots.
## Boundaries ## Boundaries

View File

@@ -31,6 +31,7 @@ The registry recognizes these IDs:
- `metadata` - `metadata`
- `current_conditions` - `current_conditions`
- `narrative_forecast`
- `derived_daily_summary` - `derived_daily_summary`
- `derived_daypart_summaries` - `derived_daypart_summaries`
- `precip_timing` - `precip_timing`

View File

@@ -39,6 +39,7 @@ report:
briefing: briefing:
metadata: {} metadata: {}
current_conditions: {} current_conditions: {}
narrative_forecast: {}
recent_changes: recent_changes:
items: [] items: []
``` ```

View File

@@ -22,7 +22,6 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast, - `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, and source warnings
- stub source record for the daily forecast source slot
- optional saved bundle JSON through app fetch helpers - optional saved bundle JSON through app fetch helpers
## Boundaries ## Boundaries
@@ -70,7 +69,7 @@ data is required and cannot be skipped.
- HTTP errors, response read failures, and envelope decode failures include - HTTP errors, response read failures, and envelope decode failures include
endpoint context. endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch. - Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional and stub sources follow missing-source policy. - Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing - Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run. alert run.

View File

@@ -72,6 +72,7 @@ reports:
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
- narrative_forecast
- derived_daily_summary - derived_daily_summary
- derived_daypart_summaries - derived_daypart_summaries
- precip_timing - precip_timing

View File

@@ -112,9 +112,6 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil { if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err
}
return builder.bundle, nil return builder.bundle, nil
} }
@@ -267,15 +264,6 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil return nil
} }
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := weatherdata.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
}
return b.applyMissingPolicy(&source, "missing_source", message)
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true source.Missing = true
if required { if required {

View File

@@ -55,11 +55,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil { if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
} }
if len(bundle.Sources) != 8 { if len(bundle.Sources) != 7 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
} }
if len(bundle.Warnings) != 1 { if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings)) t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
} }
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
@@ -197,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
wantWarns int wantWarns int
wantSource bool wantSource bool
}{ }{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true}, {name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true}, {name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true}, {name: "error", policy: config.MissingSourceError, wantErr: true},
} }

View File

@@ -161,9 +161,19 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(data), "recent_changes:") || !strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "current_conditions:") || !strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "narrative_forecast:") ||
!strings.Contains(string(data), "area_forecast_discussion:") { !strings.Contains(string(data), "area_forecast_discussion:") {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
}
currentIndex := strings.Index(string(data), " current_conditions:")
narrativeIndex := strings.Index(string(data), " narrative_forecast:")
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
if currentIndex < 0 || narrativeIndex < 0 || summaryIndex < 0 || !(currentIndex < narrativeIndex && narrativeIndex < summaryIndex) {
t.Fatalf("data package stanza order is wrong, want current_conditions then narrative_forecast then derived_daily_summary:\n%s", string(data))
}
savedDataPackage, err := promptinput.LoadYAML(data) savedDataPackage, err := promptinput.LoadYAML(data)
if err != nil { if err != nil {
t.Fatalf("decode data package: %v", err) t.Fatalf("decode data package: %v", err)
@@ -178,6 +188,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || current["condition_text"] != "Clear" { if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"]) t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
} }
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
}
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any) story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
if !ok || story["title"] != "Several Chances for Rain Through Monday" { if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
@@ -954,8 +968,11 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("InspectSources() error = %v", err) t.Fatalf("InspectSources() error = %v", err)
} }
if len(sources.Sources) == 0 || len(sources.Warnings) == 0 { if len(sources.Sources) == 0 {
t.Fatalf("sources = %#v, want provenance and warnings", sources) t.Fatalf("sources = %#v, want provenance", sources)
}
if len(sources.Warnings) != 0 {
t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings)
} }
} }

View File

@@ -24,6 +24,7 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
}{ }{
{id: module.Metadata, stanza: "metadata"}, {id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"}, {id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
{id: module.AlertDigest, stanza: "alert_digest"}, {id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"}, {id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"}, {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) { func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -130,10 +176,12 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
ctx.Collected.Current = nil ctx.Collected.Current = nil
ctx.Collected.Narrative = nil
ctx.Derived.ValidPeriodNarrativePeriods = nil
ctx.Collected.Discussion = nil ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = 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}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil { if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err) t.Fatalf("BuildModule(%s) error = %v", id, err)
@@ -212,6 +260,9 @@ func testModuleContext() ModuleContext {
humidity := 71.0 humidity := 71.0
windMph := 8.0 windMph := 8.0
windDirection := 190.0 windDirection := 190.0
narrativeTempF := 81.0
narrativePop := 60.0
narrativeWind := 12.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00") updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{ return ModuleContext{
Resolved: resolved, Resolved: resolved,
@@ -225,6 +276,25 @@ func testModuleContext() ModuleContext {
WindSpeedMph: &windMph, WindSpeedMph: &windMph,
WindDirectionDegrees: &windDirection, 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{ Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`), json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}}, }},
@@ -257,6 +327,18 @@ func testModuleContext() ModuleContext {
}}, }},
}, },
Derived: facts.DerivedFacts{ 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{{ AlertOverlaps: []forecast.AlertOverlap{{
Event: "Flood Watch", Event: "Flood Watch",
Headline: "Flooding possible", Headline: "Flooding possible",

View File

@@ -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-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), 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"}} summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
return ModuleContext{ return ModuleContext{
Resolved: report.Resolved{ Resolved: report.Resolved{
@@ -202,8 +211,16 @@ func derivedModuleContext(id report.ID) ModuleContext {
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: summary.Period, 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{ Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: hours, ValidPeriodHourlyPeriods: hours,
ValidPeriodNarrativePeriods: narrative,
DailySummaries: []forecast.DailySummary{summary}, DailySummaries: []forecast.DailySummary{summary},
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
PrecipTiming: forecast.BuildPrecipTiming(hours), PrecipTiming: forecast.BuildPrecipTiming(hours),

View File

@@ -152,6 +152,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
switch requirement { switch requirement {
case module.CollectedCurrentConditions: case module.CollectedCurrentConditions:
return ctx.Collected.Current != nil return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
return ctx.Collected.Narrative != nil
case module.CollectedAlerts: case module.CollectedAlerts:
return ctx.Collected.Alerts != nil return ctx.Collected.Alerts != nil
case module.CollectedDiscussion: case module.CollectedDiscussion:
@@ -262,6 +264,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule, 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, ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary", StanzaName: "derived_daily_summary",

View 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
}

View File

@@ -736,8 +736,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
sourcesOutput = stdout.String() sourcesOutput = stdout.String()
} }
} }
if !strings.Contains(sourcesOutput, `"warnings"`) { if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput) t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
} }
} }

View File

@@ -17,7 +17,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
Current: &weatherdata.Current{ConditionText: "Clear"}, Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}}, Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}}, Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
} }
collected := BuildCollected(bundle) collected := BuildCollected(bundle)
@@ -27,13 +27,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
} }
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" { if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings) t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings)
} }
bundle.Sources[0].Name = "changed" bundle.Sources[0].Name = "changed"
bundle.Warnings[0].Source = "changed" bundle.Warnings[0].Source = "changed"
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" { if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
} }
} }

View File

@@ -13,6 +13,7 @@ type ID string
const ( const (
Metadata ID = "metadata" Metadata ID = "metadata"
CurrentConditions ID = "current_conditions" CurrentConditions ID = "current_conditions"
NarrativeForecast ID = "narrative_forecast"
DerivedDailySummary ID = "derived_daily_summary" DerivedDailySummary ID = "derived_daily_summary"
DerivedDaypartSummaries ID = "derived_daypart_summaries" DerivedDaypartSummaries ID = "derived_daypart_summaries"
PrecipTiming ID = "precip_timing" PrecipTiming ID = "precip_timing"
@@ -104,6 +105,7 @@ type FactRequirement string
const ( const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions" CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedAlerts FactRequirement = "collected.alerts" CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion" CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story" CollectedWeatherStory FactRequirement = "collected.weather_story"
@@ -127,6 +129,7 @@ const (
type MetadataOptions struct{} type MetadataOptions struct{}
type CurrentConditionsOptions struct{} type CurrentConditionsOptions struct{}
type NarrativeForecastOptions struct{}
type DerivedDailySummaryOptions struct{} type DerivedDailySummaryOptions struct{}
type DerivedDaypartSummariesOptions struct{} type DerivedDaypartSummariesOptions struct{}
type PrecipTimingOptions struct{} type PrecipTimingOptions struct{}

View File

@@ -41,6 +41,7 @@ func dailyTodayModules() []module.ConfigItem {
return moduleItems( return moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary, module.DerivedDailySummary,
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,

View File

@@ -262,6 +262,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary, module.DerivedDailySummary,
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
@@ -276,6 +277,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary, module.DerivedDailySummary,
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,