475 lines
15 KiB
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
|
|
}
|