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
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`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
|
## Inputs and Outputs
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -69,10 +68,10 @@ func Validate(cfg Config) error {
|
|||||||
if strings.TrimSpace(daypart.Name) == "" {
|
if strings.TrimSpace(daypart.Name) == "" {
|
||||||
return fmt.Errorf("dayparts[%d].name is required", i)
|
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)
|
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)
|
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)
|
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