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"` } const DefaultPrecipWindowProbabilityThreshold = 40 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"` ProbabilityThreshold float64 `json:"probabilityThreshold"` PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"` ThunderMentioned bool `json:"thunderMentioned,omitempty"` } type PrecipitationWindow struct { Start time.Time `json:"start"` End *time.Time `json:"end,omitempty"` MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"` ProbabilityThreshold float64 `json:"probabilityThreshold"` } func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming { return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold) } func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming { timing := PrecipTiming{ProbabilityThreshold: threshold} sorted := append([]weatherdata.ForecastPeriod(nil), periods...) sort.SliceStable(sorted, func(i int, j int) bool { return sorted[i].StartTime.Before(sorted[j].StartTime) }) var active *PrecipitationWindow var activeLastEnd time.Time closeActive := func() { if active == nil { return } end := activeLastEnd active.End = &end timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active) active = nil } startActive := func(forecastPeriod weatherdata.ForecastPeriod, probability float64) { active = &PrecipitationWindow{ Start: forecastPeriod.StartTime, MaxPrecipitationProbability: TimedValue{ Value: probability, Time: forecastPeriod.StartTime, }, ProbabilityThreshold: threshold, } activeLastEnd = forecastPeriod.EndTime if timing.FirstPrecipitation == nil { timing.FirstPrecipitation = &TimedValue{ Value: probability, Time: forecastPeriod.StartTime, } } } for _, forecastPeriod := range sorted { setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime) if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold { closeActive() } else { probability := *forecastPeriod.ProbabilityOfPrecipitationPercent if active == nil { startActive(forecastPeriod, probability) } else { if forecastPeriod.StartTime.After(activeLastEnd) { closeActive() startActive(forecastPeriod, probability) } if probability > active.MaxPrecipitationProbability.Value { active.MaxPrecipitationProbability = TimedValue{ Value: probability, Time: forecastPeriod.StartTime, } } if forecastPeriod.EndTime.After(activeLastEnd) { activeLastEnd = forecastPeriod.EndTime } } } if mentionsThunder(forecastPeriod.TextDescription) { timing.ThunderMentioned = true } } if active != nil { timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active) } if len(timing.PrecipitationWindows) > 0 { final := timing.PrecipitationWindows[len(timing.PrecipitationWindows)-1] if final.End != nil { timing.LastPrecipitation = &TimedValue{ Value: final.MaxPrecipitationProbability.Value, Time: *final.End, } } } 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 }