Add a new narrative forecast module and remove the unimplemented daily forecast stub
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ report:
|
|||||||
briefing:
|
briefing:
|
||||||
metadata: {}
|
metadata: {}
|
||||||
current_conditions: {}
|
current_conditions: {}
|
||||||
|
narrative_forecast: {}
|
||||||
recent_changes:
|
recent_changes:
|
||||||
items: []
|
items: []
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user