Add forecast daypart derivation
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user