Compare recent changes from module snapshots

This commit is contained in:
2026-06-09 21:20:53 +00:00
parent 479d144592
commit 816cfb24aa
17 changed files with 508 additions and 346 deletions

View File

@@ -171,7 +171,7 @@ midday, afternoon, and evening.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`. - `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`. - `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
Recent Changes are added to prompt input when a prior comparable briefing Recent Changes are added to prompt input when a prior comparable module
snapshot exists and a threshold is crossed. snapshot exists and a threshold is crossed.
### `reports` ### `reports`

View File

@@ -70,19 +70,20 @@ Single-report generation follows this order:
2. Create or use a filesystem store. 2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`. 3. Locate any prior compatible snapshot through `internal/state`.
4. Fetch a Weather API bundle. 4. Fetch a Weather API bundle.
5. Build a report-specific briefing package. 5. Build collected and derived facts once.
6. Save the briefing snapshot. 6. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current briefings. 7. Build and save the compatibility briefing snapshot.
8. Build and save the Scriptorium `data_package`. 8. Compute Recent Changes from structured prior and current module snapshots.
9. Run Scriptorium render preflight. 9. Build and save the YAML Scriptorium `data_package`.
10. Save preflight JSON when a render result is available. 10. Run Scriptorium render preflight.
11. Save metadata for inspection. 11. Save preflight JSON when a render result is available.
12. Run Scriptorium report generation to the managed report path. 12. Save metadata for inspection.
13. Copy the managed report to the requested `--out` path when provided. 13. Run Scriptorium report generation to the managed report path.
14. Save metadata with the managed report path. 14. Copy the managed report to the requested `--out` path when provided.
15. If distributor notification is enabled, notify using the managed report 15. Save metadata with the managed report path.
16. If distributor notification is enabled, notify using the managed report
path as the source file. path as the source file.
16. Save a distributor notification debug artifact and update metadata with its 17. Save a distributor notification debug artifact and update metadata with its
path. path.
If render preflight returns both a result and an error, preflight JSON and If render preflight returns both a result and an error, preflight JSON and
@@ -141,7 +142,7 @@ Inspect:
- Generated reports use the same app request and result types regardless of - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.
- Recent Changes are computed from structured briefing snapshots. - Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured - Distributor notification maps the managed Markdown report path to configured
bundle paths; extra output copies are not upload sources. bundle paths; extra output copies are not upload sources.

View File

@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
## Purpose ## Purpose
`internal/changes` compares current and prior briefing packages and emits `internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages. compact change records for prompt input data packages.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- prior briefing package - prior module snapshot
- current briefing package - current module snapshot
- comparison thresholds from configuration - comparison thresholds from configuration
Outputs: Outputs:
@@ -22,7 +22,7 @@ Outputs:
## Boundaries ## Boundaries
- This package compares structured briefing data only. - This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown, - It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text. invoke Scriptorium, or compare generated report text.
@@ -41,7 +41,7 @@ None.
## State Or Manifest Behavior ## State Or Manifest Behavior
None directly. The app loads prior briefing snapshots through `internal/state` None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions. before calling comparison functions.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -51,9 +51,11 @@ empty Recent Changes list without calling a comparison function.
## Failure Behavior ## Failure Behavior
- Daily comparison requires both inputs to contain Daily briefing content. - Daily comparison requires `derived_daily_summary` and
- 3-Day comparison requires both inputs to contain 3-Day briefing content. `derived_daypart_summaries` stanzas. It also uses `alert_digest` and
- Weekend comparison requires both inputs to contain Weekend briefing content. `precip_timing` when present.
- 3-Day comparison requires `derived_daypart_summaries`.
- Weekend comparison requires `derived_daypart_summaries`.
- Storm Report currently has no comparison implementation, so the app leaves - Storm Report currently has no comparison implementation, so the app leaves
Recent Changes empty for Storm reports. Recent Changes empty for Storm reports.

View File

@@ -4,26 +4,24 @@ This document describes prompt input data package construction.
## Purpose ## Purpose
`internal/promptinput` converts a structured briefing package and optional `internal/promptinput` converts report metadata, module snapshots, and optional
Recent Changes into the `data_package` JSON passed to Scriptorium prompts. Recent Changes into the YAML `data_package` passed to Scriptorium prompts.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- `briefing.Package` - report metadata
- `module.Snapshot`
- optional `[]changes.Change` - optional `[]changes.Change`
Outputs: Outputs:
- `promptinput.Package` containing schema version, RunID, report metadata, - `promptinput.Package` containing schema version, RunID, report metadata,
briefing content, Recent Changes, and source warnings. Briefing content named briefing stanzas, Recent Changes, and source warnings.
includes configured location context, current conditions when available,
structured weather story context when available, discussion key messages, and
short/long-term AFD narratives when the Weather API provides them.
- report metadata includes `currentLocalDate`, the generation date formatted as - report metadata includes `currentLocalDate`, the generation date formatted as
`YYYY-MM-DD` in the effective report timezone. `YYYY-MM-DD` in the effective report timezone.
- optional JSON file written by `promptinput.Save` - optional YAML file written by `promptinput.Save`
## Boundaries ## Boundaries
@@ -34,7 +32,8 @@ Outputs:
## Config Fields Used ## Config Fields Used
None directly. Config-derived values, including timezone and prompt location None directly. Config-derived values, including timezone and prompt location
context, are already present in briefing metadata before this package runs. context, are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used ## External Adapters Used
@@ -42,7 +41,7 @@ None.
## State Or Manifest Behavior ## State Or Manifest Behavior
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by `promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`. `internal/state`.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -51,9 +50,9 @@ None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior ## Failure Behavior
Validation fails before render preflight when required top-level or briefing Validation fails before render preflight when required top-level fields are
metadata fields are missing or inconsistent, or when no report content is missing or inconsistent, or when no named briefing stanzas are present. Save
present. Save failures include filesystem operation and path context. failures include filesystem operation and path context.
## Tests ## Tests
@@ -64,6 +63,6 @@ Inspect:
## Invariants ## Invariants
- Scriptorium receives structured `data_package` JSON. - Scriptorium receives structured `data_package` YAML.
- Briefing metadata and top-level report metadata must agree. - Module stanza order is deterministic for generated snapshots.
- Recent Changes are not inferred from rendered report text. - Recent Changes are not inferred from rendered report text.

View File

@@ -216,8 +216,8 @@ source provenance and warnings without dumping full weather payloads.
## Recent Changes ## Recent Changes
Recent Changes are computed from structured briefing snapshots, not rendered Recent Changes are computed from structured module snapshots, not rendered
Markdown text. Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day same valid local date. 3-Day Outlook compares with prior compatible 3-Day

View File

@@ -3,7 +3,7 @@
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves. This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves.
## weatherreporter ## weatherreporter
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports. `weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical. The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.

View File

@@ -530,7 +530,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -988,11 +988,11 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace) return state.NewFilesystemStore(cfg.Workspace)
} }
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil { if priorSnapshot == nil {
return nil, nil return nil, nil
} }
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath) previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1002,7 +1002,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
WindGustMilesPerHour: cfg.WindGustMilesPerHour, WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch current.Metadata.ReportID { switch reportID {
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.DailyTomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:

View File

@@ -20,6 +20,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestFetchAndSaveBundle(t *testing.T) { func TestFetchAndSaveBundle(t *testing.T) {
@@ -655,25 +656,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorDailyBriefing(priorResolved) priorBriefing := priorDailyBriefing(priorResolved)
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
ModuleSnapshot: priorPaths.ModuleSnapshot,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -771,25 +754,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorDailyBriefing(priorResolved) priorBriefing := priorDailyBriefing(priorResolved)
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
ModuleSnapshot: priorPaths.ModuleSnapshot,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -839,25 +804,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorThreeDayBriefing(priorResolved) priorBriefing := priorThreeDayBriefing(priorResolved)
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
ModuleSnapshot: priorPaths.ModuleSnapshot,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportThreeDay, Report: ReportThreeDay,
@@ -916,25 +863,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorWeekendBriefing(priorResolved) priorBriefing := priorWeekendBriefing(priorResolved)
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
ModuleSnapshot: priorPaths.ModuleSnapshot,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportWeekend, Report: ReportWeekend,
@@ -1475,6 +1404,82 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package {
} }
} }
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, briefingPackage briefing.Package, snapshot module.Snapshot) {
t.Helper()
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
if err != nil {
t.Fatalf("SaveModuleSnapshot() error = %v", err)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
}
func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot {
t.Helper()
low := 50
high := 58
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"max_pop_percent": precip,
}},
{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)},
"temp_range_f": "50-58",
},
}},
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
"max_pop_percent": precip,
"max_pop_time": "6 AM",
}},
{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
t.Helper()
precip := 10
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")},
"temp_range_f": "50-58",
"max_pop_percent": precip,
"max_pop_time": "6 AM",
},
}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
high := 58.0 high := 58.0
precip := 10.0 precip := 10.0

View File

@@ -1,14 +1,16 @@
// Package changes compares structured briefing snapshots. // Package changes compares structured module snapshots.
package changes package changes
import ( import (
"fmt" "fmt"
"math" "math"
"sort" "sort"
"strconv"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type Thresholds struct { type Thresholds struct {
@@ -25,83 +27,168 @@ type Change struct {
Current string `json:"current,omitempty"` Current string `json:"current,omitempty"`
} }
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.Daily == nil { previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
return nil, fmt.Errorf("previous daily briefing is required") if err != nil {
return nil, fmt.Errorf("previous daily summary: %w", err)
} }
if current.Daily == nil { currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
return nil, fmt.Errorf("current daily briefing is required") if err != nil {
return nil, fmt.Errorf("current daily summary: %w", err)
} }
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
}
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
}
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
if err != nil {
return nil, err
}
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
if err != nil {
return nil, err
}
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
if err != nil {
return nil, err
}
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
if err != nil {
return nil, err
}
var changes []Change var changes []Change
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...) changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...) changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...) changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...) if previousHasTiming && currentHasTiming {
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...) changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
}
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
sortChanges(changes)
return changes, nil return changes, nil
} }
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change { type dailySummaryStanza struct {
var changes []Change Date string `json:"date,omitempty"`
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) { HighTempF *int `json:"high_temp_f,omitempty"`
changes = append(changes, Change{ LowTempF *int `json:"low_temp_f,omitempty"`
Type: "temperature_shift", MaxPopPercent *int `json:"max_pop_percent,omitempty"`
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min), MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Previous: fmt.Sprintf("%.0f", *previous.Min),
Current: fmt.Sprintf("%.0f", *current.Min),
})
}
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
changes = append(changes, Change{
Type: "temperature_shift",
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
Previous: fmt.Sprintf("%.0f", *previous.Max),
Current: fmt.Sprintf("%.0f", *current.Max),
})
}
return changes
} }
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change { 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"`
}
type precipTimingStanza struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
}
type alertDigestStanza struct {
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
}
type alertSummaryStanza struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
}
type indicators struct {
Snow bool
Ice bool
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
if err != nil {
return value, err
}
if !ok {
return value, fmt.Errorf("stanza %q is required", name)
}
return value, nil
}
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
if previous == nil || current == nil { if previous == nil || current == nil {
return nil return nil
} }
var changes []Change if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
previousCategory := precipitationCategory(previous.Value)
currentCategory := precipitationCategory(current.Value)
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
changes = append(changes, Change{
Type: "precip_probability_change",
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
})
}
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
changes = append(changes, Change{
Type: "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
Previous: clock(previous.Time),
Current: clock(current.Time),
})
}
return changes
}
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
return nil return nil
} }
return []Change{{ return []Change{{
Type: "wind_gust_change", Type: "temperature_shift",
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value), Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
Previous: fmt.Sprintf("%.0f mph", previous.Value), Previous: fmt.Sprintf("%d", *previous),
Current: fmt.Sprintf("%.0f mph", current.Value), Current: fmt.Sprintf("%d", *current),
}} }}
} }
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change { func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil {
return nil
}
previousCategory := precipitationCategory(float64(*previous))
currentCategory := precipitationCategory(float64(*current))
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
changeType := prefix + "precip_probability_change"
return []Change{{
Type: changeType,
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
}}
}
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
return nil
}
previousTime, previousOK := parseClock(previous)
currentTime, currentOK := parseClock(current)
if !previousOK || !currentOK {
return nil
}
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
return nil
}
return []Change{{
Type: prefix + "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
Previous: previous,
Current: current,
}}
}
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
return []Change{{
Type: prefix + "wind_gust_change",
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
Previous: fmt.Sprintf("%d mph", *previous),
Current: fmt.Sprintf("%d mph", *current),
}}
}
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
previousSet := alertSet(previous) previousSet := alertSet(previous)
currentSet := alertSet(current) currentSet := alertSet(current)
var changes []Change var changes []Change
@@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
return changes return changes
} }
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change { func compareIndicators(previous indicators, current indicators, prefix string) []Change {
var changes []Change var changes []Change
for _, item := range []struct { for _, item := range []struct {
name string name string
@@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
if item.previous == item.current { if item.previous == item.current {
continue continue
} }
changeType := item.name + "_risk_change" changeType := prefix + item.name + "_risk_change"
if item.current { if item.current {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"}) changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
} else { } else {
@@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
return changes return changes
} }
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators { func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
out := forecast.Indicators{} out := indicators{}
for _, daypart := range dayparts { for _, daypart := range dayparts {
out.Snow = out.Snow || daypart.Indicators.Snow out.Snow = out.Snow || daypart.Snow
out.Ice = out.Ice || daypart.Indicators.Ice out.Ice = out.Ice || daypart.Ice
} }
return out return out
} }
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} { func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
out := map[string]struct{}{} out := map[string]struct{}{}
for _, alert := range alerts { for _, alert := range alerts {
event := alert.Event event := alert.Event
@@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
return math.Abs(current-previous) >= threshold return math.Abs(current-previous) >= threshold
} }
func clock(t time.Time) string {
return t.Format("15:04")
}
func sortChanges(items []Change) { func sortChanges(items []Change) {
sort.SliceStable(items, func(i, j int) bool { sort.SliceStable(items, func(i, j int) bool {
if items[i].Type == items[j].Type { if items[i].Type == items[j].Type {
@@ -197,3 +280,33 @@ func sortChanges(items []Change) {
return items[i].Type < items[j].Type return items[i].Type < items[j].Type
}) })
} }
func parseClock(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func parseTempRange(value string) (*int, *int) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parts := strings.Split(value, "-")
if len(parts) == 1 {
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
return &parsed, &parsed
}
return nil, nil
}
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
if minErr != nil || maxErr != nil {
return nil, nil
}
return &minValue, &maxValue
}

View File

@@ -1,16 +1,17 @@
package changes package changes
import ( import (
"strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestCompareDailyNoMeaningfulChanges(t *testing.T) { func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -22,8 +23,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
} }
func TestCompareDailyTemperatureThreshold(t *testing.T) { func TestCompareDailyTemperatureThreshold(t *testing.T) {
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -35,8 +36,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
} }
func TestCompareDailyPrecipTimingShift(t *testing.T) { func TestCompareDailyPrecipTimingShift(t *testing.T) {
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -48,8 +49,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
} }
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) { func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{}) current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -61,8 +62,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
} }
func TestCompareDailyIndicatorChange(t *testing.T) { func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true}) current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -73,26 +74,48 @@ func TestCompareDailyIndicatorChange(t *testing.T) {
} }
} }
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package { func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts)) _, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
if err == nil {
t.Fatal("CompareDaily() error = nil, want missing stanza error")
}
if !strings.Contains(err.Error(), "derived_daily_summary") {
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
}
}
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
t.Helper()
relevant := make([]alertSummaryStanza, 0, len(alerts))
for _, alert := range alerts { for _, alert := range alerts {
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert}) relevant = append(relevant, alertSummaryStanza{Event: alert})
} }
return briefing.Package{ return snapshot(t,
Daily: &briefing.Daily{ module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
BottomLine: briefing.BottomLine{ Date: "2026-05-29",
Temperature: forecast.Range{Min: &low, Max: &high}, HighTempF: &high,
MaxPrecipProbability: &forecast.TimedValue{ LowTempF: &low,
Value: precip, MaxPopPercent: &precip,
Time: precipTime, }},
}, 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},
RelevantAlerts: alertOverlaps, }},
Dayparts: []forecast.DaypartSummary{ module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
{Name: "morning", Indicators: indicators}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
}, )
}, }
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
} }
return snapshot
}
func period(start string, end string) timeutil.Period {
return timeutil.Period{Start: at(start), End: at(end)}
} }
func testThresholds() Thresholds { func testThresholds() Thresholds {

View File

@@ -2,77 +2,147 @@ package changes
import ( import (
"fmt" "fmt"
"time" "sort"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.ThreeDay == nil { previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
return nil, fmt.Errorf("previous 3-day briefing is required") if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
} }
if current.ThreeDay == nil { currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
return nil, fmt.Errorf("current 3-day briefing is required") if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
} }
previousDays := outlookDaysByDate(previous.ThreeDay.Days) previousDays := outlookDaysFromDayparts(previousDayparts)
currentDays := outlookDaysByDate(current.ThreeDay.Days) currentDays := outlookDaysFromDayparts(currentDayparts)
return compareOutlookDays(previousDays, currentDays, thresholds, "")
}
type outlookDay struct {
Date string
LowTempF *int
HighTempF *int
MaxPopPercent *int
MaxPopTime string
MaxWindGustMph *int
Indicators indicators
}
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
var changes []Change var changes []Change
for date, currentDay := range currentDays { for date, currentDay := range currentDays {
previousDay, ok := previousDays[date] previousDay, ok := previousDays[date]
if !ok { if !ok {
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date}) changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
continue continue
} }
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...) changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
} }
for date := range previousDays { for date := range previousDays {
if _, ok := currentDays[date]; !ok { if _, ok := currentDays[date]; !ok {
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date}) changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
} }
} }
sortChanges(changes) sortChanges(changes)
return changes, nil return changes, nil
} }
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change { func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
var changes []Change var changes []Change
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) { for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
changes = append(changes, change) changes = append(changes, change)
} }
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) { for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
changes = append(changes, change) changes = append(changes, change)
changes[len(changes)-1].Type = "outlook_" + change.Type
} }
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) { for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change) changes = append(changes, change)
} }
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) { for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change) changes = append(changes, change)
} }
return changes return changes
} }
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay { func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
out := map[string]briefing.OutlookDay{} out := map[string]outlookDay{}
for _, day := range days { var keys []string
date := day.Date for key := range dayparts {
if date == "" && !day.Period.Start.IsZero() { keys = append(keys, key)
date = day.Period.Start.Format(time.DateOnly) }
sort.Strings(keys)
for _, key := range keys {
daypart := dayparts[key]
date := daypartDate(daypart)
if date == "" {
continue
} }
if date != "" { day := out[date]
out[date] = day day.Date = date
low, high := parseTempRange(daypart.TempRangeF)
day.LowTempF = minInt(day.LowTempF, low)
day.HighTempF = maxInt(day.HighTempF, high)
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
day.MaxPopTime = daypart.MaxPopTime
} }
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
out[date] = day
} }
return out return out
} }
func daypartDate(daypart daypartSummaryStanza) string {
if !daypart.Period.Start.IsZero() {
return daypart.Period.Start.Format(timeutil.DateLayout)
}
return ""
}
func minInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b < *a {
return copyInt(b)
}
return a
}
func maxInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b > *a {
return copyInt(b)
}
return a
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}

View File

@@ -2,41 +2,13 @@ package changes
import ( import (
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
func TestCompareThreeDayDetectsDayChanges(t *testing.T) { func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
previousTemp := 70.0 previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
currentTemp := 78.0 current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
previousPrecip := 20.0
currentPrecip := 70.0
previous := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &previousTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: previousPrecip,
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
},
}}},
}
current := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &currentTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: currentPrecip,
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}},
}
changes, err := CompareThreeDay(previous, current, Thresholds{ changes, err := CompareThreeDay(previous, current, Thresholds{
TemperatureDegrees: 5, TemperatureDegrees: 5,
@@ -50,17 +22,20 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
if len(changes) == 0 { if len(changes) == 0 {
t.Fatal("changes length = 0, want detected 3-day changes") t.Fatal("changes length = 0, want detected 3-day changes")
} }
var foundPrecip bool if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
var foundSnow bool
for _, change := range changes {
if change.Type == "outlook_precip_probability_change" {
foundPrecip = true
}
if change.Type == "outlook_snow_risk_change" {
foundSnow = true
}
}
if !foundPrecip || !foundSnow {
t.Fatalf("changes = %#v, want precipitation and snow changes", changes) t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
} }
} }
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
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"),
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,
Snow: snow,
},
}})
}

View File

@@ -3,24 +3,17 @@ package changes
import ( import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
) )
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.Weekend == nil { previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
return nil, fmt.Errorf("previous weekend briefing is required")
}
if current.Weekend == nil {
return nil, fmt.Errorf("current weekend briefing is required")
}
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
} }
for i := range changes { currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
changes[i].Type = "weekend_" + changes[i].Type if err != nil {
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
} }
return changes, nil return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
} }

View File

@@ -1,31 +1,10 @@
package changes package changes
import ( import "testing"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) { func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
previousTemp := 70.0 previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
currentTemp := 78.0 current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
previous := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.Weekend},
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
Date: "2026-05-30",
Temperature: forecast.Range{Max: &previousTemp},
}}},
}
current := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.Weekend},
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
Date: "2026-05-30",
Temperature: forecast.Range{Max: &currentTemp},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}},
}
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5}) changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
if err != nil { if err != nil {
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
if len(changes) == 0 { if len(changes) == 0 {
t.Fatal("changes length = 0, want weekend changes") t.Fatal("changes length = 0, want weekend changes")
} }
for _, change := range changes { if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
if change.Type == "weekend_outlook_snow_risk_change" { t.Fatalf("changes = %#v, want snow risk change", changes)
return
}
} }
t.Fatalf("changes = %#v, want snow risk change", changes)
} }

View File

@@ -252,8 +252,9 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt) return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt)
}) })
return &PriorSnapshot{ return &PriorSnapshot{
Metadata: candidates[0], Metadata: candidates[0],
BriefingPath: candidates[0].BriefingPath, BriefingPath: candidates[0].BriefingPath,
ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
}, nil }, nil
} }

View File

@@ -268,6 +268,9 @@ func TestFindPriorSnapshot(t *testing.T) {
if prior.BriefingPath != briefingPath { if prior.BriefingPath != briefingPath {
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath) t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath)
} }
if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
}
} }
func TestFindPriorSnapshotUsesValidDate(t *testing.T) { func TestFindPriorSnapshotUsesValidDate(t *testing.T) {

View File

@@ -27,8 +27,9 @@ type Store interface {
} }
type PriorSnapshot struct { type PriorSnapshot struct {
Metadata Metadata Metadata Metadata
BriefingPath string BriefingPath string
ModuleSnapshotPath string
} }
type PreflightArtifact struct { type PreflightArtifact struct {