Compare commits
3 Commits
ef044327c6
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f149563c68 | |||
| 276e4f1189 | |||
| cb42cad6a6 |
@@ -201,6 +201,7 @@ reports:
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- hourly_forecast
|
||||
```
|
||||
|
||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||
|
||||
@@ -27,8 +27,8 @@ Outputs:
|
||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `alert_digest`,
|
||||
`area_forecast_discussion`, and `weather_story`
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
@@ -36,6 +36,10 @@ Outputs:
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
Prompt-facing module values use local, human-readable date and time labels
|
||||
where the LLM is expected to reason about report content. Canonical timestamps
|
||||
remain in report metadata, source provenance, and integration artifacts.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package selects and shapes already-collected weather facts for prompts.
|
||||
|
||||
@@ -32,6 +32,7 @@ The registry recognizes these IDs:
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `narrative_forecast`
|
||||
- `hourly_forecast`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
|
||||
@@ -24,7 +24,8 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` with schema version, RunID, report metadata, named
|
||||
module stanzas, Recent Changes, and source warnings
|
||||
module stanzas grouped for prompt presentation, Recent Changes, and source
|
||||
warnings
|
||||
- YAML bytes from `promptinput.MarshalYAML`
|
||||
- YAML file written atomically by `promptinput.Save`
|
||||
|
||||
@@ -38,14 +39,37 @@ report:
|
||||
prompt_id: <prompt_id>
|
||||
briefing:
|
||||
metadata: {}
|
||||
current_conditions: {}
|
||||
narrative_forecast: {}
|
||||
applicable_risk_products:
|
||||
alert_digest: {}
|
||||
derived_summaries:
|
||||
derived_daily_summary: {}
|
||||
derived_daypart_summaries: {}
|
||||
precip_timing: {}
|
||||
outdoor_windows: {}
|
||||
narrative_products:
|
||||
narrative_forecast: {}
|
||||
area_forecast_discussion: {}
|
||||
weather_story: {}
|
||||
raw_data:
|
||||
current_conditions: {}
|
||||
hourly_forecast: {}
|
||||
recent_changes:
|
||||
items: []
|
||||
```
|
||||
|
||||
The `briefing` mapping contains named module stanzas. Stanza order follows the
|
||||
module snapshot output order.
|
||||
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
||||
weather module stanzas under prompt-facing categories. This grouping is a YAML
|
||||
presentation concern only: module snapshots remain flat, and loaded
|
||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||
Within each category, stanza order follows the module snapshot output order.
|
||||
|
||||
Current categories are:
|
||||
|
||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||
discussions, and similar risk products.
|
||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||
- `narrative_products`: official narrative text products and forecast stories.
|
||||
- `raw_data`: minimally transformed underlying weather data.
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -91,6 +115,7 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||
- Module stanza order is deterministic for generated snapshots.
|
||||
- Module stanza order is deterministic within each prompt-facing category.
|
||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||
- Recent Changes are provided by `internal/changes`; this package does not
|
||||
infer changes from rendered report text.
|
||||
|
||||
@@ -86,3 +86,4 @@ reports:
|
||||
- long_term
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
|
||||
@@ -160,19 +160,32 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
||||
!strings.Contains(string(data), "recent_changes:") ||
|
||||
!strings.Contains(string(data), "applicable_risk_products:") ||
|
||||
!strings.Contains(string(data), "derived_summaries:") ||
|
||||
!strings.Contains(string(data), "narrative_products:") ||
|
||||
!strings.Contains(string(data), "raw_data:") ||
|
||||
!strings.Contains(string(data), "current_conditions:") ||
|
||||
!strings.Contains(string(data), "narrative_forecast:") ||
|
||||
!strings.Contains(string(data), "hourly_forecast:") ||
|
||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||
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))
|
||||
riskIndex := strings.Index(string(data), " applicable_risk_products:")
|
||||
derivedIndex := strings.Index(string(data), " derived_summaries:")
|
||||
narrativeIndex := strings.Index(string(data), " narrative_products:")
|
||||
rawIndex := strings.Index(string(data), " raw_data:")
|
||||
alertIndex := strings.Index(string(data), " alert_digest:")
|
||||
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
|
||||
storyIndex := strings.Index(string(data), " weather_story:")
|
||||
currentIndex := strings.Index(string(data), " current_conditions:")
|
||||
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
||||
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
|
||||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
|
||||
}
|
||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
@@ -192,6 +205,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
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"])
|
||||
}
|
||||
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
|
||||
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
||||
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
||||
}
|
||||
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
||||
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"])
|
||||
@@ -679,7 +696,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("decode daily summary: %v", err)
|
||||
}
|
||||
if !ok || dailySummary["date"] != "2026-05-30" {
|
||||
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
|
||||
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||
@@ -1363,7 +1380,8 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
||||
}},
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
"morning": map[string]any{
|
||||
"period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)},
|
||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||
"temp_range_f": "50-58",
|
||||
},
|
||||
}},
|
||||
@@ -1385,7 +1403,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
date + "_morning": map[string]any{
|
||||
"period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")},
|
||||
"date": date,
|
||||
"period": date + " at 6:00 AM to " + date + " at 10:00 AM",
|
||||
"temp_range_f": "50-58",
|
||||
"max_pop_percent": precip,
|
||||
"max_pop_time": "6 AM",
|
||||
|
||||
@@ -25,6 +25,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.HourlyForecast, stanza: "hourly_forecast"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{id: module.WeatherStory, stanza: "weather_story"},
|
||||
@@ -45,6 +46,57 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
}
|
||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -61,6 +113,9 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
}
|
||||
if period.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)
|
||||
}
|
||||
@@ -80,6 +135,9 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
}
|
||||
@@ -189,11 +247,13 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Current = nil
|
||||
ctx.Collected.Narrative = nil
|
||||
ctx.Collected.Hourly = nil
|
||||
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||
ctx.Collected.Discussion = nil
|
||||
ctx.Collected.WeatherStory = nil
|
||||
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
@@ -276,6 +336,10 @@ func testModuleContext() ModuleContext {
|
||||
narrativePop := 60.0
|
||||
narrativeWind := 12.0
|
||||
narrativeWindDirection := 45.0
|
||||
hourlyTempF := 76.0
|
||||
hourlyPop := 70.0
|
||||
hourlyHumidity := 66.0
|
||||
hourlyWindMph := 14.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
@@ -309,6 +373,30 @@ func testModuleContext() ModuleContext {
|
||||
},
|
||||
},
|
||||
},
|
||||
Hourly: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "hourly",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||
TextDescription: "Tomorrow showers.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
@@ -341,6 +429,18 @@ func testModuleContext() ModuleContext {
|
||||
}},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
},
|
||||
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
|
||||
@@ -35,7 +35,7 @@ func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, e
|
||||
|
||||
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||
value := DerivedDailySummaryModule{
|
||||
Date: summary.Date,
|
||||
Date: friendlyDateLabel(summary.Date, timezone),
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
@@ -85,10 +85,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
||||
if maxGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||
}
|
||||
value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
|
||||
if len(value.DominantConditions) == 0 {
|
||||
value.DominantConditions = sortedSet(conditions)
|
||||
}
|
||||
value.DominantConditions = sortedSet(conditions)
|
||||
value.Hazards = sortedSet(hazards)
|
||||
return value, nil
|
||||
}
|
||||
@@ -144,22 +141,6 @@ func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.T
|
||||
return maxPop
|
||||
}
|
||||
|
||||
func narrativeConditions(periods []weatherdata.ForecastPeriod) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, period := range periods {
|
||||
if period.TextDescription == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[period.TextDescription]; ok {
|
||||
continue
|
||||
}
|
||||
seen[period.TextDescription] = struct{}{}
|
||||
out = append(out, period.TextDescription)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||
if maxPop == nil || maxPop.Value <= 0 {
|
||||
return ""
|
||||
|
||||
@@ -11,22 +11,23 @@ import (
|
||||
)
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -44,7 +45,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Period: daypart.Period,
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.Date != "Friday, May 29, 2026" {
|
||||
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||
}
|
||||
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
@@ -33,8 +36,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||
}
|
||||
if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." {
|
||||
t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions)
|
||||
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||
}
|
||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
@@ -147,6 +150,9 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||
@@ -160,11 +166,14 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
|
||||
123
internal/briefing/hourly_forecast_module.go
Normal file
123
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type HourlyForecastModule 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 []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPeriod struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,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"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,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"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
hourly := ctx.Collected.Hourly
|
||||
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := HourlyForecastModule{
|
||||
Product: hourly.Product,
|
||||
IssuedAt: hourly.IssuedAt,
|
||||
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||
SourceLocation: hourly.LocationName,
|
||||
SourceLocationID: hourly.LocationID,
|
||||
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
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),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v HourlyForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
@@ -74,6 +74,47 @@ func periodClockLabel(period timeutil.Period, timezone string) string {
|
||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||
}
|
||||
|
||||
func friendlyDateLabel(date string, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return parsed.Format("Monday, January 2, 2006")
|
||||
}
|
||||
|
||||
func localDateLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format(timeutil.DateLayout)
|
||||
}
|
||||
|
||||
func clockLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
|
||||
@@ -154,6 +154,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
||||
return ctx.Collected.Current != nil
|
||||
case module.CollectedNarrativeForecast:
|
||||
return ctx.Collected.Narrative != nil
|
||||
case module.CollectedHourlyForecast:
|
||||
return ctx.Collected.Hourly != nil
|
||||
case module.CollectedAlerts:
|
||||
return ctx.Collected.Alerts != nil
|
||||
case module.CollectedDiscussion:
|
||||
@@ -274,6 +276,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: "hourly_forecast",
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
|
||||
@@ -17,23 +17,23 @@ type NarrativeForecastModule struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
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"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -47,7 +47,7 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||
SourceLocation: narrative.LocationName,
|
||||
SourceLocationID: narrative.LocationID,
|
||||
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods),
|
||||
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
@@ -55,13 +55,13 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod) []NarrativeForecastPeriod {
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
StartTime: period.StartTime,
|
||||
EndTime: period.EndTime,
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
|
||||
@@ -96,6 +96,14 @@ func copyBool(value *bool) *bool {
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyFloat(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -84,13 +83,14 @@ type dailySummaryStanza struct {
|
||||
}
|
||||
|
||||
type daypartSummaryStanza struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
}
|
||||
|
||||
type precipTimingStanza struct {
|
||||
|
||||
@@ -3,10 +3,8 @@ package changes
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
@@ -98,7 +96,7 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
||||
DailyPrecipitationProbability: &precip,
|
||||
}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
"morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
|
||||
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
|
||||
}},
|
||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||
@@ -114,10 +112,6 @@ func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func period(start string, end string) timeutil.Period {
|
||||
return timeutil.Period{Start: at(start), End: at(end)}
|
||||
}
|
||||
|
||||
func testThresholds() Thresholds {
|
||||
return Thresholds{
|
||||
TemperatureDegrees: 5,
|
||||
@@ -136,11 +130,3 @@ func countType(changes []Change, changeType string) int {
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func at(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
@@ -113,10 +112,7 @@ func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[strin
|
||||
}
|
||||
|
||||
func daypartDate(daypart daypartSummaryStanza) string {
|
||||
if !daypart.Period.Start.IsZero() {
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout)
|
||||
}
|
||||
return ""
|
||||
return daypart.Date
|
||||
}
|
||||
|
||||
func minInt(a *int, b *int) *int {
|
||||
|
||||
@@ -31,7 +31,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
||||
t.Helper()
|
||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
date + "_morning": {
|
||||
Period: period(date+"T06:00:00Z", date+"T10:00:00Z"),
|
||||
Date: date,
|
||||
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
||||
TempRangeF: tempRange,
|
||||
MaxPopPercent: &precip,
|
||||
MaxPopTime: precipTime,
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
Metadata ID = "metadata"
|
||||
CurrentConditions ID = "current_conditions"
|
||||
NarrativeForecast ID = "narrative_forecast"
|
||||
HourlyForecast ID = "hourly_forecast"
|
||||
DerivedDailySummary ID = "derived_daily_summary"
|
||||
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
||||
PrecipTiming ID = "precip_timing"
|
||||
@@ -106,6 +107,7 @@ type FactRequirement string
|
||||
const (
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
@@ -130,6 +132,7 @@ const (
|
||||
type MetadataOptions struct{}
|
||||
type CurrentConditionsOptions struct{}
|
||||
type NarrativeForecastOptions struct{}
|
||||
type HourlyForecastOptions struct{}
|
||||
type DerivedDailySummaryOptions struct{}
|
||||
type DerivedDaypartSummariesOptions struct{}
|
||||
type PrecipTimingOptions struct{}
|
||||
|
||||
@@ -18,6 +18,35 @@ import (
|
||||
|
||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
||||
|
||||
const (
|
||||
metadataStanza = "metadata"
|
||||
categoryApplicableRiskProducts = "applicable_risk_products"
|
||||
categoryDerivedSummaries = "derived_summaries"
|
||||
categoryNarrativeProducts = "narrative_products"
|
||||
categoryRawData = "raw_data"
|
||||
)
|
||||
|
||||
var briefingCategoryOrder = []string{
|
||||
categoryApplicableRiskProducts,
|
||||
categoryDerivedSummaries,
|
||||
categoryNarrativeProducts,
|
||||
categoryRawData,
|
||||
}
|
||||
|
||||
var briefingStanzaCategories = map[string]string{
|
||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||
string(module.WeatherStory): categoryNarrativeProducts,
|
||||
string(module.CurrentConditions): categoryRawData,
|
||||
string(module.HourlyForecast): categoryRawData,
|
||||
}
|
||||
|
||||
type BuildRequest struct {
|
||||
Metadata Metadata
|
||||
Modules module.Snapshot
|
||||
@@ -144,13 +173,24 @@ func Validate(pkg Package) error {
|
||||
if len(pkg.Briefing.Order) == 0 {
|
||||
return fmt.Errorf("briefing stanzas are required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, name := range pkg.Briefing.Order {
|
||||
if name == "" {
|
||||
return fmt.Errorf("briefing stanza name is required")
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return fmt.Errorf("duplicate briefing stanza %q", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if _, ok := pkg.Briefing.Values[name]; !ok {
|
||||
return fmt.Errorf("briefing stanza %q is missing", name)
|
||||
}
|
||||
if name == metadataStanza {
|
||||
continue
|
||||
}
|
||||
if _, ok := briefingStanzaCategories[name]; !ok {
|
||||
return fmt.Errorf("briefing stanza %q has no prompt-input category", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -191,17 +231,40 @@ func LoadYAML(data []byte) (Package, error) {
|
||||
|
||||
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||
categoryNames := map[string][]string{}
|
||||
for _, name := range b.Order {
|
||||
value, ok := b.Values[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
||||
valueNode, err := yamlNode(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
||||
if name == metadataStanza {
|
||||
if err := appendYAMLMappingValue(node, name, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
node.Content = append(node.Content, keyNode, valueNode)
|
||||
category, ok := briefingStanzaCategories[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("briefing stanza %q has no prompt-input category", name)
|
||||
}
|
||||
categoryNames[category] = append(categoryNames[category], name)
|
||||
}
|
||||
for _, category := range briefingCategoryOrder {
|
||||
names := categoryNames[category]
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
categoryNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for _, name := range names {
|
||||
value := b.Values[name]
|
||||
if err := appendYAMLMappingValue(categoryNode, name, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
node.Content = append(node.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Value: category},
|
||||
categoryNode,
|
||||
)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
@@ -212,14 +275,46 @@ func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
|
||||
}
|
||||
values := map[string]any{}
|
||||
order := make([]string, 0, len(value.Content)/2)
|
||||
seen := map[string]struct{}{}
|
||||
seenCategories := map[string]struct{}{}
|
||||
categoryOrder := map[string][]string{}
|
||||
for i := 0; i < len(value.Content); i += 2 {
|
||||
name := value.Content[i].Value
|
||||
var stanza any
|
||||
if err := value.Content[i+1].Decode(&stanza); err != nil {
|
||||
return err
|
||||
if name == metadataStanza {
|
||||
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
order = append(order, name)
|
||||
values[name] = stanza
|
||||
if !knownBriefingCategory(name) {
|
||||
return fmt.Errorf("unknown briefing category %q", name)
|
||||
}
|
||||
if _, ok := seenCategories[name]; ok {
|
||||
return fmt.Errorf("duplicate briefing category %q", name)
|
||||
}
|
||||
seenCategories[name] = struct{}{}
|
||||
categoryNode := value.Content[i+1]
|
||||
if categoryNode.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("briefing category %q must be a mapping", name)
|
||||
}
|
||||
var names []string
|
||||
for j := 0; j < len(categoryNode.Content); j += 2 {
|
||||
stanzaName := categoryNode.Content[j].Value
|
||||
category, ok := briefingStanzaCategories[stanzaName]
|
||||
if !ok {
|
||||
return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName)
|
||||
}
|
||||
if category != name {
|
||||
return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name)
|
||||
}
|
||||
if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
categoryOrder[name] = names
|
||||
}
|
||||
for _, category := range briefingCategoryOrder {
|
||||
order = append(order, categoryOrder[category]...)
|
||||
}
|
||||
b.Order = order
|
||||
b.Values = values
|
||||
@@ -250,6 +345,39 @@ func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
||||
valueNode, err := yamlNode(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
||||
}
|
||||
node.Content = append(node.Content, keyNode, valueNode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error {
|
||||
if _, ok := seen[name]; ok {
|
||||
return fmt.Errorf("duplicate briefing stanza %q", name)
|
||||
}
|
||||
var stanza any
|
||||
if err := node.Decode(&stanza); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
*order = append(*order, name)
|
||||
values[name] = stanza
|
||||
return nil
|
||||
}
|
||||
|
||||
func knownBriefingCategory(name string) bool {
|
||||
for _, category := range briefingCategoryOrder {
|
||||
if name == category {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func yamlNode(value any) (*yaml.Node, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
req.Metadata.PromptID = "weather.three_day_outlook"
|
||||
req.Modules = snapshotWithOutputs(t,
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||
)
|
||||
|
||||
pkg, err := Build(req)
|
||||
@@ -117,12 +117,12 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
if pkg.Report.ID != report.ThreeDay {
|
||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
||||
}
|
||||
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
|
||||
t.Fatal("Briefing.Values[three_day] missing")
|
||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
@@ -141,9 +141,26 @@ func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
||||
}
|
||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
||||
!strings.Contains(string(first), "briefing:\n") ||
|
||||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
||||
!strings.Contains(string(first), " derived_summaries:\n") ||
|
||||
!strings.Contains(string(first), " narrative_products:\n") ||
|
||||
!strings.Contains(string(first), " raw_data:\n") ||
|
||||
!strings.Contains(string(first), " current_conditions:\n") ||
|
||||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
|
||||
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
|
||||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
|
||||
t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first))
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
before string
|
||||
after string
|
||||
}{
|
||||
{before: " metadata:\n", after: " applicable_risk_products:\n"},
|
||||
{before: " applicable_risk_products:\n", after: " derived_summaries:\n"},
|
||||
{before: " derived_summaries:\n", after: " narrative_products:\n"},
|
||||
{before: " narrative_products:\n", after: " raw_data:\n"},
|
||||
} {
|
||||
if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) {
|
||||
t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +182,51 @@ func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
|
||||
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
||||
}
|
||||
if loaded.Briefing.Order[1] != "current_conditions" {
|
||||
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
|
||||
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
|
||||
if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder)
|
||||
}
|
||||
if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" {
|
||||
t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
||||
req := validBuildRequest(t)
|
||||
req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}})
|
||||
|
||||
_, err := Build(req)
|
||||
if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) {
|
||||
t.Fatalf("Build() error = %v, want uncategorized stanza error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||
data := []byte(`
|
||||
schema_version: weatherreporter.data_package.v2
|
||||
run_id: 20260529T100000Z_daily_today
|
||||
report:
|
||||
id: daily_today
|
||||
prompt_id: weather.daily_report
|
||||
generated_at: 2026-05-29T10:00:00Z
|
||||
timezone: America/Chicago
|
||||
current_local_date: "2026-05-29"
|
||||
valid_period:
|
||||
start: 2026-05-29T05:00:00Z
|
||||
end: 2026-05-30T05:00:00Z
|
||||
briefing:
|
||||
metadata:
|
||||
run_id: 20260529T100000Z_daily_today
|
||||
raw_data:
|
||||
alert_digest:
|
||||
checked: true
|
||||
recent_changes:
|
||||
items: []
|
||||
`)
|
||||
|
||||
_, err := LoadYAML(data)
|
||||
if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) {
|
||||
t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +250,8 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
||||
module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +49,25 @@ func dailyTodayModules() []module.ConfigItem {
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
}
|
||||
|
||||
func dailyTomorrowModules() []module.ConfigItem {
|
||||
items := dailyTodayModules()
|
||||
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
return items
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||
|
||||
@@ -270,6 +270,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -286,6 +287,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user