Add forecast daypart derivation
This commit is contained in:
63
docs/internal/forecast-derivation.md
Normal file
63
docs/internal/forecast-derivation.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
53
internal/forecast/dayparts.go
Normal file
53
internal/forecast/dayparts.go
Normal 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
384
internal/forecast/derive.go
Normal 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
|
||||
}
|
||||
245
internal/forecast/derive_test.go
Normal file
245
internal/forecast/derive_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
internal/forecast/testdata/daily_bundle.json
vendored
Normal file
66
internal/forecast/testdata/daily_bundle.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
internal/forecast/thresholds.go
Normal file
20
internal/forecast/thresholds.go
Normal 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
|
||||
}
|
||||
66
internal/timeutil/periods.go
Normal file
66
internal/timeutil/periods.go
Normal 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}
|
||||
}
|
||||
35
internal/timeutil/periods_test.go
Normal file
35
internal/timeutil/periods_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user