Add forecast daypart derivation

This commit is contained in:
2026-05-29 17:12:20 +00:00
parent a885959d39
commit d494550b20
10 changed files with 935 additions and 29 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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}
}

384
internal/forecast/derive.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"
}
]
}

View File

@@ -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
}

View File

@@ -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}
}

View File

@@ -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)
}
}