diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md new file mode 100644 index 0000000..98b9f29 --- /dev/null +++ b/docs/internal/forecast-derivation.md @@ -0,0 +1,63 @@ +# Forecast Derivation Internals + +This document describes the implemented deterministic forecast summarization +boundary. + +## Purpose + +`internal/forecast` converts a normalized forecast bundle into inspectable +daily daypart summaries. These summaries are structured data for later briefing +builders; they are not rendered report text. + +## Inputs and Outputs + +Inputs: + +- `forecast.Bundle` +- local date and timezone +- configured daypart definitions with `HH:MM` start and end values + +Output: + +- `forecast.DailySummary` with a civil-day period, daypart summaries, selected + narrative periods, alert overlaps, discussion context, source warnings, and + source provenance. + +## Boundaries + +- This package groups and summarizes already-normalized forecast data. +- It does not fetch weather data, resolve report definitions, compare prior + snapshots, build prompt input packages, or call `scriptorium`. + +## Behavior + +- Daypart windows use half-open intervals. +- Overnight dayparts are supported when the end clock is not after the start + clock. +- Hourly forecast periods are selected by overlap with the daypart window. +- Each daypart computes temperature range, apparent-temperature range, maximum + precipitation probability, peak wind speed, peak wind gust, dominant + condition, notable conditions, and basic weather indicators. +- Alerts are selected by overlap with the daily period and each daypart. +- Narrative periods and discussion context are selected as broader source + context for later briefing builders. + +## Failure Behavior + +- Missing hourly forecast data returns an error. +- Invalid daypart definitions return actionable parse errors. +- Alert records without parseable RFC3339 start/end fields are skipped. + +## Tests + +Inspect: + +- `internal/forecast/derive_test.go` +- `internal/timeutil/periods_test.go` + +## Invariants + +- Weather facts come from normalized source data, not generated prose. +- Outputs remain JSON-inspectable. +- Forecast derivation remains independent of CLI, HTTP adapters, and report + registry behavior. diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index 62a1419..7a9d85c 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -5,7 +5,7 @@ This document describes the implemented weather data ingestion boundary. ## Purpose `internal/adapters/weatherapi` fetches normalized weather data from one -configured weather API endpoint and assembles an `internal/forecast.Bundle`. +configured weather API endpoint and assembles a `forecast.Bundle`. ## Inputs and Outputs diff --git a/internal/config/validate.go b/internal/config/validate.go index 3080f9d..0b7e629 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -3,7 +3,6 @@ package config import ( "fmt" "net/url" - "strconv" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -69,10 +68,10 @@ func Validate(cfg Config) error { if strings.TrimSpace(daypart.Name) == "" { return fmt.Errorf("dayparts[%d].name is required", i) } - if err := validateClockTime(daypart.Start); err != nil { + if _, err := timeutil.ParseClock(daypart.Start); err != nil { return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err) } - if err := validateClockTime(daypart.End); err != nil { + if _, err := timeutil.ParseClock(daypart.End); err != nil { return fmt.Errorf("dayparts[%d].end is invalid: %w", i, err) } } @@ -87,28 +86,3 @@ func validatePolicy(name string, policy MissingSourcePolicy) error { return fmt.Errorf("%s must be one of error, warn, or none", name) } } - -func validateClockTime(value string) error { - parts := strings.Split(value, ":") - if len(parts) != 2 { - return fmt.Errorf("expected HH:MM") - } - hour, err := strconv.Atoi(parts[0]) - if err != nil { - return fmt.Errorf("invalid hour") - } - minute, err := strconv.Atoi(parts[1]) - if err != nil { - return fmt.Errorf("invalid minute") - } - if hour < 0 || hour > 24 { - return fmt.Errorf("hour must be between 00 and 24") - } - if minute < 0 || minute > 59 { - return fmt.Errorf("minute must be between 00 and 59") - } - if hour == 24 && minute != 0 { - return fmt.Errorf("24 is only valid as 24:00") - } - return nil -} diff --git a/internal/forecast/dayparts.go b/internal/forecast/dayparts.go new file mode 100644 index 0000000..46fd73f --- /dev/null +++ b/internal/forecast/dayparts.go @@ -0,0 +1,53 @@ +package forecast + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +type DaypartDefinition struct { + Name string `json:"name"` + Start string `json:"start"` + End string `json:"end"` +} + +type DaypartWindow struct { + Name string `json:"name"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Period timeutil.Period `json:"period"` +} + +func ResolveDayparts(date time.Time, location *time.Location, definitions []DaypartDefinition) ([]DaypartWindow, error) { + if len(definitions) == 0 { + return nil, fmt.Errorf("daypart definitions are required") + } + windows := make([]DaypartWindow, 0, len(definitions)) + for i, def := range definitions { + if def.Name == "" { + return nil, fmt.Errorf("daypart[%d].name is required", i) + } + startClock, err := timeutil.ParseClock(def.Start) + if err != nil { + return nil, fmt.Errorf("daypart[%d].start: %w", i, err) + } + endClock, err := timeutil.ParseClock(def.End) + if err != nil { + return nil, fmt.Errorf("daypart[%d].end: %w", i, err) + } + period := timeutil.ClockWindow(date, location, startClock, endClock) + windows = append(windows, DaypartWindow{ + Name: def.Name, + Start: period.Start, + End: period.End, + Period: period, + }) + } + return windows, nil +} + +func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period { + return timeutil.Period{Start: period.StartTime, End: period.EndTime} +} diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go new file mode 100644 index 0000000..c4755b1 --- /dev/null +++ b/internal/forecast/derive.go @@ -0,0 +1,384 @@ +package forecast + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +type DailySummary struct { + Date string `json:"date"` + Period timeutil.Period `json:"period"` + Dayparts []DaypartSummary `json:"dayparts"` + NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"` + AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` + Discussion *Discussion `json:"discussion,omitempty"` + SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"` + SourceProvenance []Source `json:"sourceProvenance,omitempty"` +} + +type DaypartSummary struct { + Name string `json:"name"` + Period timeutil.Period `json:"period"` + HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"` + Temperature Range `json:"temperature,omitempty"` + ApparentTemperature Range `json:"apparentTemperature,omitempty"` + MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` + PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"` + PeakWindGust *TimedValue `json:"peakWindGust,omitempty"` + DominantCondition string `json:"dominantCondition,omitempty"` + NotableConditions []string `json:"notableConditions,omitempty"` + Indicators Indicators `json:"indicators"` + AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` +} + +type Range struct { + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` +} + +type TimedValue struct { + Value float64 `json:"value"` + Time time.Time `json:"time"` +} + +type Indicators struct { + Thunder bool `json:"thunder,omitempty"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` + Fog bool `json:"fog,omitempty"` + Heat bool `json:"heat,omitempty"` + Cold bool `json:"cold,omitempty"` + Wind bool `json:"wind,omitempty"` +} + +type AlertOverlap struct { + Event string `json:"event,omitempty"` + Headline string `json:"headline,omitempty"` + Severity string `json:"severity,omitempty"` + Period timeutil.Period `json:"period"` + Overlap timeutil.Period `json:"overlap"` + Description string `json:"description,omitempty"` +} + +func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { + if bundle == nil { + return nil, fmt.Errorf("forecast bundle is required") + } + if location == nil { + location = time.UTC + } + if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 { + return nil, fmt.Errorf("hourly forecast data is required") + } + day := timeutil.CivilDay(date, location) + windows, err := ResolveDayparts(date, location, dayparts) + if err != nil { + return nil, err + } + alerts := AlertOverlaps(bundle.Alerts, day) + + summary := &DailySummary{ + Date: day.Start.Format(timeutil.DateLayout), + Period: day, + NarrativePeriods: SelectNarrativePeriods(bundle, day), + AlertOverlaps: alerts, + Discussion: SelectDiscussion(bundle), + SourceWarnings: bundle.Warnings, + SourceProvenance: bundle.Sources, + } + for _, window := range windows { + periods := SelectHourlyPeriods(bundle.Hourly, window.Period) + daypartSummary := SummarizeDaypart(window.Name, window.Period, periods) + daypartSummary.AlertOverlaps = overlapsWithin(alerts, window.Period) + summary.Dayparts = append(summary.Dayparts, daypartSummary) + } + return summary, nil +} + +func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod { + if run == nil { + return nil + } + var selected []ForecastPeriod + for _, forecastPeriod := range run.Periods { + if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) { + selected = append(selected, forecastPeriod) + } + } + sort.SliceStable(selected, func(i int, j int) bool { + return selected[i].StartTime.Before(selected[j].StartTime) + }) + return selected +} + +func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod { + if bundle == nil || bundle.Narrative == nil { + return nil + } + return SelectHourlyPeriods(bundle.Narrative, period) +} + +func SelectDiscussion(bundle *Bundle) *Discussion { + if bundle == nil { + return nil + } + return bundle.Discussion +} + +func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary { + summary := DaypartSummary{ + Name: name, + Period: period, + HourlyPeriods: periods, + } + conditionCounts := map[string]int{} + conditions := map[string]struct{}{} + + for _, forecastPeriod := range periods { + addRangeValue(&summary.Temperature, periodTemperatureValues(forecastPeriod)...) + addRangeValue(&summary.ApparentTemperature, valueFromPointers(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC)...) + setMaxTimedValue(&summary.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime) + setMaxTimedValue(&summary.PeakWindSpeed, firstValue(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime) + setMaxTimedValue(&summary.PeakWindGust, firstValue(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime) + + text := strings.TrimSpace(forecastPeriod.TextDescription) + if text != "" { + conditionCounts[text]++ + conditions[text] = struct{}{} + summary.Indicators = mergeIndicators(summary.Indicators, indicatorsForText(text)) + } + summary.Indicators = mergeIndicators(summary.Indicators, numericIndicators(forecastPeriod)) + } + + summary.DominantCondition = dominantCondition(conditionCounts) + summary.NotableConditions = sortedKeys(conditions) + return summary +} + +func periodTemperatureValues(period ForecastPeriod) []*float64 { + values := []*float64{} + values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...) + values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...) + values = append(values, valueFromPointers(period.TemperatureFMax, period.TemperatureCMax)...) + return values +} + +func valueFromPointers(values ...*float64) []*float64 { + for _, value := range values { + if value != nil { + return []*float64{value} + } + } + return nil +} + +func firstValue(values ...*float64) *float64 { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func addRangeValue(target *Range, values ...*float64) { + for _, value := range values { + if value == nil { + continue + } + if target.Min == nil || *value < *target.Min { + copied := *value + target.Min = &copied + } + if target.Max == nil || *value > *target.Max { + copied := *value + target.Max = &copied + } + } +} + +func setMaxTimedValue(target **TimedValue, value *float64, at time.Time) { + if value == nil { + return + } + if *target == nil || value != nil && *value > (*target).Value { + *target = &TimedValue{Value: *value, Time: at} + } +} + +func dominantCondition(counts map[string]int) string { + var dominant string + var dominantCount int + for condition, count := range counts { + if count > dominantCount || count == dominantCount && condition < dominant { + dominant = condition + dominantCount = count + } + } + return dominant +} + +func sortedKeys(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func indicatorsForText(text string) Indicators { + lower := strings.ToLower(text) + return Indicators{ + Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"), + Snow: strings.Contains(lower, "snow"), + Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"), + Fog: strings.Contains(lower, "fog"), + Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"), + } +} + +func numericIndicators(period ForecastPeriod) Indicators { + windGust := firstValue(period.WindGustMph, period.WindGustKmh) + windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) + indicators := Indicators{} + if period.TemperatureF != nil { + indicators.Heat = *period.TemperatureF >= 95 + indicators.Cold = *period.TemperatureF <= 32 + } else if period.TemperatureC != nil { + indicators.Heat = *period.TemperatureC >= 35 + indicators.Cold = *period.TemperatureC <= 0 + } + if windGust != nil && *windGust >= 35 || windSpeed != nil && *windSpeed >= 25 { + indicators.Wind = true + } + return indicators +} + +func mergeIndicators(left Indicators, right Indicators) Indicators { + return Indicators{ + Thunder: left.Thunder || right.Thunder, + Snow: left.Snow || right.Snow, + Ice: left.Ice || right.Ice, + Fog: left.Fog || right.Fog, + Heat: left.Heat || right.Heat, + Cold: left.Cold || right.Cold, + Wind: left.Wind || right.Wind, + } +} + +func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap { + if alertRun == nil { + return nil + } + var overlaps []AlertOverlap + for _, rawAlert := range alertRun.Alerts { + alert, ok := parseAlert(rawAlert) + if !ok || !alert.Period.IsValid() || !alert.Period.Overlaps(period) { + continue + } + overlaps = append(overlaps, AlertOverlap{ + Event: alert.Event, + Headline: alert.Headline, + Severity: alert.Severity, + Period: alert.Period, + Overlap: intersect(alert.Period, period), + Description: alert.Description, + }) + } + sort.SliceStable(overlaps, func(i int, j int) bool { + return overlaps[i].Period.Start.Before(overlaps[j].Period.Start) + }) + return overlaps +} + +type parsedAlert struct { + Event string + Headline string + Severity string + Description string + Period timeutil.Period +} + +func parseAlert(raw json.RawMessage) (parsedAlert, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return parsedAlert{}, false + } + alert := parsedAlert{ + Event: stringField(fields, "event"), + Headline: firstStringField(fields, "headline", "title"), + Severity: stringField(fields, "severity"), + Description: firstStringField(fields, "description", "instruction"), + } + start, startOK := firstTimeField(fields, "effective", "onset", "startsAt", "startTime", "sent") + end, endOK := firstTimeField(fields, "expires", "ends", "endsAt", "endTime") + if !startOK || !endOK { + return parsedAlert{}, false + } + alert.Period = timeutil.Period{Start: start, End: end} + return alert, true +} + +func stringField(fields map[string]json.RawMessage, name string) string { + value, ok := fields[name] + if !ok { + return "" + } + var out string + if err := json.Unmarshal(value, &out); err != nil { + return "" + } + return out +} + +func firstStringField(fields map[string]json.RawMessage, names ...string) string { + for _, name := range names { + if value := stringField(fields, name); value != "" { + return value + } + } + return "" +} + +func firstTimeField(fields map[string]json.RawMessage, names ...string) (time.Time, bool) { + for _, name := range names { + value := stringField(fields, name) + if value == "" { + continue + } + parsed, err := time.Parse(time.RFC3339, value) + if err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period { + start := left.Start + if right.Start.After(start) { + start = right.Start + } + end := left.End + if right.End.Before(end) { + end = right.End + } + return timeutil.Period{Start: start, End: end} +} + +func overlapsWithin(alerts []AlertOverlap, period timeutil.Period) []AlertOverlap { + var out []AlertOverlap + for _, alert := range alerts { + if alert.Period.Overlaps(period) { + alert.Overlap = intersect(alert.Period, period) + out = append(out, alert) + } + } + return out +} diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go new file mode 100644 index 0000000..84b6962 --- /dev/null +++ b/internal/forecast/derive_test.go @@ -0,0 +1,245 @@ +package forecast + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + bundle := testBundle(location) + date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) + dayparts := []DaypartDefinition{ + {Name: "overnight", Start: "00:00", End: "06:00"}, + {Name: "morning", Start: "06:00", End: "12:00"}, + {Name: "afternoon", Start: "12:00", End: "18:00"}, + {Name: "evening", Start: "18:00", End: "24:00"}, + } + + summary, err := BuildDailySummary(bundle, date, location, dayparts) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + + if len(summary.Dayparts) != 4 { + t.Fatalf("Dayparts length = %d, want 4", len(summary.Dayparts)) + } + morning := summary.Dayparts[1] + if len(morning.HourlyPeriods) != 2 { + t.Fatalf("morning periods = %d, want 2", len(morning.HourlyPeriods)) + } + assertRange(t, "morning temperature", morning.Temperature, 58, 72) + if morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 70 { + t.Fatalf("morning max precip = %#v, want 70", morning.MaxPrecipitationProbability) + } + if morning.PeakWindGust == nil || morning.PeakWindGust.Value != 40 { + t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust) + } + if morning.DominantCondition != "Thunderstorms and gusty wind" { + t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition) + } + if !morning.Indicators.Thunder || !morning.Indicators.Wind { + t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators) + } + + afternoon := summary.Dayparts[2] + assertRange(t, "afternoon apparent", afternoon.ApparentTemperature, 100, 100) + if !afternoon.Indicators.Heat { + t.Fatalf("afternoon indicators = %#v, want heat", afternoon.Indicators) + } + if len(summary.NarrativePeriods) != 1 { + t.Fatalf("NarrativePeriods length = %d, want 1", len(summary.NarrativePeriods)) + } + if len(summary.AlertOverlaps) != 1 { + t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps)) + } + if len(morning.AlertOverlaps) != 1 { + t.Fatalf("morning AlertOverlaps length = %d, want 1", len(morning.AlertOverlaps)) + } + + if _, err := json.Marshal(summary.Dayparts); err != nil { + t.Fatalf("daypart summaries are not JSON inspectable: %v", err) + } +} + +func TestBuildDailySummaryFromFixtureBundle(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "daily_bundle.json")) + if err != nil { + t.Fatalf("read fixture bundle: %v", err) + } + var bundle Bundle + if err := json.Unmarshal(data, &bundle); err != nil { + t.Fatalf("decode fixture bundle: %v", err) + } + location := time.FixedZone("Test", -5*60*60) + summary, err := BuildDailySummary(&bundle, mustParse("2026-05-29T12:00:00-05:00"), location, []DaypartDefinition{ + {Name: "morning", Start: "06:00", End: "12:00"}, + {Name: "afternoon", Start: "12:00", End: "18:00"}, + }) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + if len(summary.Dayparts) != 2 { + t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts)) + } + if !summary.Dayparts[0].Indicators.Thunder { + t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators) + } + if len(summary.AlertOverlaps) != 1 { + t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps)) + } +} + +func TestOvernightGroupingAcrossMidnight(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) + bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil), + hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil), + hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil), + }}} + + summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{ + {Name: "night", Start: "22:00", End: "06:00"}, + }) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + night := summary.Dayparts[0] + if len(night.HourlyPeriods) != 2 { + t.Fatalf("night periods = %d, want 2", len(night.HourlyPeriods)) + } + if !night.Indicators.Snow || !night.Indicators.Fog || !night.Indicators.Cold { + t.Fatalf("night indicators = %#v, want snow, fog, and cold", night.Indicators) + } +} + +func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) + bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil), + hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil), + hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil), + }}} + + summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{ + {Name: "morning", Start: "06:00", End: "12:00"}, + }) + if err != nil { + t.Fatalf("BuildDailySummary() error = %v", err) + } + morning := summary.Dayparts[0] + if len(morning.HourlyPeriods) != 1 { + t.Fatalf("morning periods = %d, want only start-boundary period", len(morning.HourlyPeriods)) + } + if morning.HourlyPeriods[0].TextDescription != "Start" { + t.Fatalf("selected period = %q, want Start", morning.HourlyPeriods[0].TextDescription) + } +} + +func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { + location := time.UTC + _, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{ + {Name: "morning", Start: "06:00", End: "12:00"}, + }) + if err == nil { + t.Fatal("BuildDailySummary() error = nil, want missing hourly error") + } +} + +func TestAlertOverlap(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`) + alertRun := &AlertRun{Alerts: []json.RawMessage{raw}} + period := timeutil.Period{ + Start: mustParse("2026-05-29T06:00:00-05:00").In(location), + End: mustParse("2026-05-29T09:00:00-05:00").In(location), + } + + overlaps := AlertOverlaps(alertRun, period) + if len(overlaps) != 1 { + t.Fatalf("overlaps length = %d, want 1", len(overlaps)) + } + if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" { + t.Fatalf("overlap start = %s, want alert start", overlaps[0].Overlap.Start.Format(time.RFC3339)) + } + if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" { + t.Fatalf("overlap end = %s, want period end", overlaps[0].Overlap.End.Format(time.RFC3339)) + } +} + +func TestThresholdHelpers(t *testing.T) { + if !DifferenceAtLeast(50, 56, 5) { + t.Fatal("DifferenceAtLeast = false, want true") + } + if !CrossesAtOrAbove(29, 32, 32) { + t.Fatal("CrossesAtOrAbove = false, want true") + } + if !CrossesBelow(35, 31, 32) { + t.Fatal("CrossesBelow = false, want true") + } +} + +func testBundle(location *time.Location) *Bundle { + return &Bundle{ + Hourly: &ForecastRun{Periods: []ForecastPeriod{ + hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil), + hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)), + hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)), + hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)), + }}, + Narrative: &ForecastRun{Periods: []ForecastPeriod{ + hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil), + }}, + Alerts: &AlertRun{Alerts: []json.RawMessage{ + json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`), + }}, + Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}}, + Sources: []Source{{Name: "hourly"}}, + Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}}, + } +} + +func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod { + startTime := mustParse(start).In(location) + endTime := mustParse(end).In(location) + temp := temperature + return ForecastPeriod{ + StartTime: startTime, + EndTime: endTime, + TextDescription: text, + TemperatureF: &temp, + ApparentTemperatureF: apparent, + ProbabilityOfPrecipitationPercent: precip, + WindSpeedMph: wind, + WindGustMph: gust, + } +} + +func mustParse(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} + +func ptr(value float64) *float64 { + return &value +} + +func assertRange(t *testing.T, name string, got Range, wantMin float64, wantMax float64) { + t.Helper() + if got.Min == nil || got.Max == nil { + t.Fatalf("%s = %#v, want min and max", name, got) + } + if *got.Min != wantMin || *got.Max != wantMax { + t.Fatalf("%s = [%v,%v], want [%v,%v]", name, *got.Min, *got.Max, wantMin, wantMax) + } +} diff --git a/internal/forecast/testdata/daily_bundle.json b/internal/forecast/testdata/daily_bundle.json new file mode 100644 index 0000000..a5ef672 --- /dev/null +++ b/internal/forecast/testdata/daily_bundle.json @@ -0,0 +1,66 @@ +{ + "fetchedAt": "2026-05-29T15:00:00Z", + "hourly": { + "issuedAt": "2026-05-29T10:30:00-05:00", + "product": "hourly", + "periods": [ + { + "startTime": "2026-05-29T06:00:00-05:00", + "endTime": "2026-05-29T07:00:00-05:00", + "textDescription": "Showers and thunderstorms", + "temperatureF": 66, + "apparentTemperatureF": 67, + "probabilityOfPrecipitationPercent": 80, + "windSpeedMph": 18, + "windGustMph": 32 + }, + { + "startTime": "2026-05-29T14:00:00-05:00", + "endTime": "2026-05-29T15:00:00-05:00", + "textDescription": "Mostly sunny", + "temperatureF": 88, + "apparentTemperatureF": 91, + "probabilityOfPrecipitationPercent": 10, + "windSpeedMph": 10, + "windGustMph": 16 + } + ] + }, + "narrative": { + "issuedAt": "2026-05-29T10:30:00-05:00", + "product": "narrative", + "periods": [ + { + "startTime": "2026-05-29T06:00:00-05:00", + "endTime": "2026-05-29T18:00:00-05:00", + "name": "Today", + "textDescription": "Morning storms, then partly sunny." + } + ] + }, + "alerts": { + "alerts": [ + { + "event": "Flood Watch", + "headline": "Flooding possible", + "severity": "Moderate", + "effective": "2026-05-29T05:00:00-05:00", + "expires": "2026-05-29T09:00:00-05:00" + } + ] + }, + "discussion": { + "product": "discussion", + "issuedAt": "2026-05-29T09:25:00-05:00", + "keyMessages": [ + "Storms are most likely during the morning." + ] + }, + "sources": [ + { + "name": "hourly", + "endpoint": "/forecast/hourly", + "fetchedAt": "2026-05-29T15:00:00Z" + } + ] +} diff --git a/internal/forecast/thresholds.go b/internal/forecast/thresholds.go new file mode 100644 index 0000000..e087929 --- /dev/null +++ b/internal/forecast/thresholds.go @@ -0,0 +1,20 @@ +package forecast + +func DifferenceAtLeast(previous float64, current float64, threshold float64) bool { + return abs(current-previous) >= threshold +} + +func CrossesAtOrAbove(previous float64, current float64, threshold float64) bool { + return previous < threshold && current >= threshold +} + +func CrossesBelow(previous float64, current float64, threshold float64) bool { + return previous >= threshold && current < threshold +} + +func abs(value float64) float64 { + if value < 0 { + return -value + } + return value +} diff --git a/internal/timeutil/periods.go b/internal/timeutil/periods.go new file mode 100644 index 0000000..bd700f3 --- /dev/null +++ b/internal/timeutil/periods.go @@ -0,0 +1,66 @@ +package timeutil + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type Period struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +func (p Period) IsValid() bool { + return p.End.After(p.Start) +} + +func (p Period) Overlaps(other Period) bool { + return p.Start.Before(other.End) && other.Start.Before(p.End) +} + +func (p Period) Contains(t time.Time) bool { + return !t.Before(p.Start) && t.Before(p.End) +} + +func ParseClock(value string) (time.Duration, error) { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return 0, fmt.Errorf("expected HH:MM") + } + hour, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, fmt.Errorf("invalid hour") + } + minute, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid minute") + } + if hour < 0 || hour > 24 { + return 0, fmt.Errorf("hour must be between 00 and 24") + } + if minute < 0 || minute > 59 { + return 0, fmt.Errorf("minute must be between 00 and 59") + } + if hour == 24 && minute != 0 { + return 0, fmt.Errorf("24 is only valid as 24:00") + } + return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil +} + +func CivilDay(date time.Time, location *time.Location) Period { + local := date.In(location) + start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) + return Period{Start: start, End: start.AddDate(0, 0, 1)} +} + +func ClockWindow(date time.Time, location *time.Location, startClock time.Duration, endClock time.Duration) Period { + day := CivilDay(date, location) + start := day.Start.Add(startClock) + end := day.Start.Add(endClock) + if !end.After(start) { + end = end.AddDate(0, 0, 1) + } + return Period{Start: start, End: end} +} diff --git a/internal/timeutil/periods_test.go b/internal/timeutil/periods_test.go new file mode 100644 index 0000000..a36fe17 --- /dev/null +++ b/internal/timeutil/periods_test.go @@ -0,0 +1,35 @@ +package timeutil + +import ( + "testing" + "time" +) + +func TestPeriodOverlapUsesHalfOpenIntervals(t *testing.T) { + start := time.Date(2026, 5, 29, 6, 0, 0, 0, time.UTC) + left := Period{Start: start, End: start.Add(time.Hour)} + touching := Period{Start: start.Add(time.Hour), End: start.Add(2 * time.Hour)} + overlapping := Period{Start: start.Add(30 * time.Minute), End: start.Add(90 * time.Minute)} + + if left.Overlaps(touching) { + t.Fatal("touching half-open periods overlap, want false") + } + if !left.Overlaps(overlapping) { + t.Fatal("overlapping periods do not overlap, want true") + } +} + +func TestClockWindowHandlesOvernight(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) + start, _ := ParseClock("22:00") + end, _ := ParseClock("06:00") + + window := ClockWindow(date, location, start, end) + if got := window.Start.Format("2006-01-02T15:04"); got != "2026-05-29T22:00" { + t.Fatalf("Start = %s, want 2026-05-29T22:00", got) + } + if got := window.End.Format("2006-01-02T15:04"); got != "2026-05-30T06:00" { + t.Fatalf("End = %s, want 2026-05-30T06:00", got) + } +}