Compare recent changes from module snapshots
This commit is contained in:
@@ -171,7 +171,7 @@ midday, afternoon, and evening.
|
||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
||||
- `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.
|
||||
|
||||
### `reports`
|
||||
|
||||
@@ -70,19 +70,20 @@ Single-report generation follows this order:
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build a report-specific briefing package.
|
||||
6. Save the briefing snapshot.
|
||||
7. Compute Recent Changes from structured prior and current briefings.
|
||||
8. Build and save the Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
15. If distributor notification is enabled, notify using the managed report
|
||||
5. Build collected and derived facts once.
|
||||
6. Execute configured modules and save the module snapshot.
|
||||
7. Build and save the compatibility briefing snapshot.
|
||||
8. Compute Recent Changes from structured prior and current module snapshots.
|
||||
9. Build and save the YAML Scriptorium `data_package`.
|
||||
10. Run Scriptorium render preflight.
|
||||
11. Save preflight JSON when a render result is available.
|
||||
12. Save metadata for inspection.
|
||||
13. Run Scriptorium report generation to the managed report path.
|
||||
14. Copy the managed report to the requested `--out` path when provided.
|
||||
15. Save metadata with the managed report path.
|
||||
16. If distributor notification is enabled, notify using the managed report
|
||||
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.
|
||||
|
||||
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
|
||||
report ID.
|
||||
- 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.
|
||||
- Distributor notification maps the managed Markdown report path to configured
|
||||
bundle paths; extra output copies are not upload sources.
|
||||
|
||||
@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
|
||||
|
||||
## 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.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior briefing package
|
||||
- current briefing package
|
||||
- prior module snapshot
|
||||
- current module snapshot
|
||||
- comparison thresholds from configuration
|
||||
|
||||
Outputs:
|
||||
@@ -22,7 +22,7 @@ Outputs:
|
||||
|
||||
## 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,
|
||||
invoke Scriptorium, or compare generated report text.
|
||||
|
||||
@@ -41,7 +41,7 @@ None.
|
||||
|
||||
## 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.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
@@ -51,9 +51,11 @@ empty Recent Changes list without calling a comparison function.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily comparison requires both inputs to contain Daily briefing content.
|
||||
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
||||
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
||||
- Daily comparison requires `derived_daily_summary` and
|
||||
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
|
||||
`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
|
||||
Recent Changes empty for Storm reports.
|
||||
|
||||
|
||||
@@ -4,26 +4,24 @@ This document describes prompt input data package construction.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/promptinput` converts a structured briefing package and optional
|
||||
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
|
||||
`internal/promptinput` converts report metadata, module snapshots, and optional
|
||||
Recent Changes into the YAML `data_package` passed to Scriptorium prompts.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- `briefing.Package`
|
||||
- report metadata
|
||||
- `module.Snapshot`
|
||||
- optional `[]changes.Change`
|
||||
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||
briefing content, Recent Changes, and source warnings. Briefing content
|
||||
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.
|
||||
named briefing stanzas, Recent Changes, and source warnings.
|
||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||
`YYYY-MM-DD` in the effective report timezone.
|
||||
- optional JSON file written by `promptinput.Save`
|
||||
- optional YAML file written by `promptinput.Save`
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -34,7 +32,8 @@ Outputs:
|
||||
## Config Fields Used
|
||||
|
||||
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
|
||||
|
||||
@@ -42,7 +41,7 @@ None.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
@@ -51,9 +50,9 @@ None. Recent Changes is always present as an `items` list and may be empty.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Validation fails before render preflight when required top-level or briefing
|
||||
metadata fields are missing or inconsistent, or when no report content is
|
||||
present. Save failures include filesystem operation and path context.
|
||||
Validation fails before render preflight when required top-level fields are
|
||||
missing or inconsistent, or when no named briefing stanzas are present. Save
|
||||
failures include filesystem operation and path context.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -64,6 +63,6 @@ Inspect:
|
||||
|
||||
## Invariants
|
||||
|
||||
- Scriptorium receives structured `data_package` JSON.
|
||||
- Briefing metadata and top-level report metadata must agree.
|
||||
- Scriptorium receives structured `data_package` YAML.
|
||||
- Module stanza order is deterministic for generated snapshots.
|
||||
- Recent Changes are not inferred from rendered report text.
|
||||
|
||||
@@ -216,8 +216,8 @@ source provenance and warnings without dumping full weather payloads.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
Recent Changes are computed from structured briefing snapshots, not rendered
|
||||
Markdown text.
|
||||
Recent Changes are computed from structured module snapshots, not rendered
|
||||
Markdown or YAML text.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 project’s shape, boundaries, and invariants as the code evolves.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -988,11 +988,11 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
|
||||
previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1002,7 +1002,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
}
|
||||
switch current.Metadata.ReportID {
|
||||
switch reportID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestFetchAndSaveBundle(t *testing.T) {
|
||||
@@ -655,25 +656,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorDailyBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
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)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
|
||||
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -771,25 +754,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorDailyBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
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)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
|
||||
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -839,25 +804,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorThreeDayBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
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)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
@@ -916,25 +863,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorWeekendBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
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)
|
||||
}
|
||||
savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
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 {
|
||||
high := 58.0
|
||||
precip := 10.0
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// Package changes compares structured briefing snapshots.
|
||||
// Package changes compares structured module snapshots.
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -25,83 +27,168 @@ type Change struct {
|
||||
Current string `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Daily == nil {
|
||||
return nil, fmt.Errorf("previous daily briefing is required")
|
||||
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daily summary: %w", err)
|
||||
}
|
||||
if current.Daily == nil {
|
||||
return nil, fmt.Errorf("current daily briefing is required")
|
||||
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
|
||||
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
|
||||
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
|
||||
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
|
||||
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
|
||||
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
|
||||
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
|
||||
if previousHasTiming && currentHasTiming {
|
||||
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
|
||||
}
|
||||
|
||||
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
|
||||
var changes []Change
|
||||
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
|
||||
changes = append(changes, Change{
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
|
||||
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
|
||||
type dailySummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
var changes []Change
|
||||
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) {
|
||||
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: "wind_gust_change",
|
||||
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
|
||||
Previous: fmt.Sprintf("%.0f mph", previous.Value),
|
||||
Current: fmt.Sprintf("%.0f mph", current.Value),
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
|
||||
Previous: fmt.Sprintf("%d", *previous),
|
||||
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)
|
||||
currentSet := alertSet(current)
|
||||
var changes []Change
|
||||
@@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
||||
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
@@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
if item.previous == item.current {
|
||||
continue
|
||||
}
|
||||
changeType := item.name + "_risk_change"
|
||||
changeType := prefix + item.name + "_risk_change"
|
||||
if item.current {
|
||||
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
||||
} else {
|
||||
@@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
return changes
|
||||
}
|
||||
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
|
||||
out := indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
out.Snow = out.Snow || daypart.Snow
|
||||
out.Ice = out.Ice || daypart.Ice
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
||||
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
event := alert.Event
|
||||
@@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
|
||||
return math.Abs(current-previous) >= threshold
|
||||
}
|
||||
|
||||
func clock(t time.Time) string {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
func sortChanges(items []Change) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Type == items[j].Type {
|
||||
@@ -197,3 +280,33 @@ func sortChanges(items []Change) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -22,8 +23,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -35,8 +36,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -48,8 +49,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -61,8 +62,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
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 {
|
||||
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
||||
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
|
||||
_, 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 {
|
||||
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
||||
relevant = append(relevant, alertSummaryStanza{Event: alert})
|
||||
}
|
||||
return briefing.Package{
|
||||
Daily: &briefing.Daily{
|
||||
BottomLine: briefing.BottomLine{
|
||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||
MaxPrecipProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: precipTime,
|
||||
},
|
||||
},
|
||||
RelevantAlerts: alertOverlaps,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "morning", Indicators: indicators},
|
||||
},
|
||||
},
|
||||
return snapshot(t,
|
||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
||||
Date: "2026-05-29",
|
||||
HighTempF: &high,
|
||||
LowTempF: &low,
|
||||
MaxPopPercent: &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},
|
||||
}},
|
||||
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}},
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,77 +2,147 @@ package changes
|
||||
|
||||
import (
|
||||
"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) {
|
||||
if previous.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("previous 3-day briefing is required")
|
||||
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
||||
}
|
||||
if current.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("current 3-day briefing is required")
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||
}
|
||||
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
|
||||
currentDays := outlookDaysByDate(current.ThreeDay.Days)
|
||||
previousDays := outlookDaysFromDayparts(previousDayparts)
|
||||
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
|
||||
for date, currentDay := range currentDays {
|
||||
previousDay, ok := previousDays[date]
|
||||
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
|
||||
}
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
|
||||
}
|
||||
for date := range previousDays {
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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.Type = "outlook_" + change.Type
|
||||
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.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)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
|
||||
out := map[string]briefing.OutlookDay{}
|
||||
for _, day := range days {
|
||||
date := day.Date
|
||||
if date == "" && !day.Period.Start.IsZero() {
|
||||
date = day.Period.Start.Format(time.DateOnly)
|
||||
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
|
||||
out := map[string]outlookDay{}
|
||||
var keys []string
|
||||
for key := range dayparts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
daypart := dayparts[key]
|
||||
date := daypartDate(daypart)
|
||||
if date == "" {
|
||||
continue
|
||||
}
|
||||
if date != "" {
|
||||
out[date] = day
|
||||
day := out[date]
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,41 +2,13 @@ package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
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: ¤tTemp},
|
||||
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}}},
|
||||
}}},
|
||||
}
|
||||
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
|
||||
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
|
||||
|
||||
changes, err := CompareThreeDay(previous, current, Thresholds{
|
||||
TemperatureDegrees: 5,
|
||||
@@ -50,17 +22,20 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||
}
|
||||
var foundPrecip bool
|
||||
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 {
|
||||
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
|
||||
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,
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -3,24 +3,17 @@ package changes
|
||||
import (
|
||||
"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) {
|
||||
if previous.Weekend == nil {
|
||||
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)
|
||||
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
|
||||
}
|
||||
for i := range changes {
|
||||
changes[i].Type = "weekend_" + changes[i].Type
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
|
||||
}
|
||||
return changes, nil
|
||||
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
|
||||
}
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
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: ¤tTemp},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
|
||||
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
|
||||
|
||||
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
||||
if err != nil {
|
||||
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want weekend changes")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||
return
|
||||
}
|
||||
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
|
||||
@@ -252,8 +252,9 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
||||
return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt)
|
||||
})
|
||||
return &PriorSnapshot{
|
||||
Metadata: candidates[0],
|
||||
BriefingPath: candidates[0].BriefingPath,
|
||||
Metadata: candidates[0],
|
||||
BriefingPath: candidates[0].BriefingPath,
|
||||
ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,9 @@ func TestFindPriorSnapshot(t *testing.T) {
|
||||
if 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) {
|
||||
|
||||
@@ -27,8 +27,9 @@ type Store interface {
|
||||
}
|
||||
|
||||
type PriorSnapshot struct {
|
||||
Metadata Metadata
|
||||
BriefingPath string
|
||||
Metadata Metadata
|
||||
BriefingPath string
|
||||
ModuleSnapshotPath string
|
||||
}
|
||||
|
||||
type PreflightArtifact struct {
|
||||
|
||||
Reference in New Issue
Block a user