Files
weatherreporter/internal/forecast/derive.go

475 lines
15 KiB
Go

package forecast
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DailySummary struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
}
type DaypartSummary struct {
Name string `json:"name"`
Period timeutil.Period `json:"period"`
HourlyPeriods []weatherdata.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 {
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"`
}
type PrecipTiming struct {
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
}
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
var timing PrecipTiming
for _, forecastPeriod := range periods {
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
value := TimedValue{
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
Time: forecastPeriod.StartTime,
}
if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) {
copied := value
timing.FirstPrecipitation = &copied
}
if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) {
copied := value
timing.LastPrecipitation = &copied
}
}
if mentionsThunder(forecastPeriod.TextDescription) {
timing.ThunderMentioned = true
}
}
return timing
}
func BuildDailySummary(bundle *weatherdata.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 BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required")
}
if location == nil {
location = time.UTC
}
var summaries []DailySummary
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
overlap, ok := day.Intersection(period)
if !ok {
continue
}
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
if err != nil {
return nil, err
}
summaries = append(summaries, *summary)
}
return summaries, nil
}
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
return nil, fmt.Errorf("hourly forecast data is required")
}
windows, err := ResolveDayparts(period.Start, location, dayparts)
if err != nil {
return nil, err
}
alerts := AlertOverlaps(bundle.Alerts, period)
summary := &DailySummary{
Date: period.Start.In(location).Format(timeutil.DateLayout),
Period: period,
NarrativePeriods: SelectNarrativePeriods(bundle, period),
AlertOverlaps: alerts,
Discussion: SelectDiscussion(bundle),
SourceWarnings: bundle.Warnings,
SourceProvenance: bundle.Sources,
}
for _, window := range windows {
clipped, ok := window.Period.Intersection(period)
if !ok {
continue
}
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
summary.Dayparts = append(summary.Dayparts, daypartSummary)
}
return summary, nil
}
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil {
return nil
}
var selected []weatherdata.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 *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod {
if bundle == nil || bundle.Narrative == nil {
return nil
}
return SelectHourlyPeriods(bundle.Narrative, period)
}
func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
if bundle == nil {
return nil
}
return bundle.Discussion
}
func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.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 weatherdata.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{
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 mentionsThunder(text string) bool {
return strings.Contains(strings.ToLower(text), "thunder")
}
func numericIndicators(period weatherdata.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{
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 *weatherdata.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
}