package briefing import ( "fmt" "math" "sort" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) type Daily struct { BottomLine BottomLine `json:"bottomLine"` Dayparts []forecast.DaypartSummary `json:"dayparts"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` OutdoorWindows OutdoorWindows `json:"outdoorWindows"` NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` Discussion DiscussionContext `json:"discussion,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` ForecastSummaryDate string `json:"forecastSummaryDate"` } type BottomLine struct { Summary string `json:"summary"` Hazards []string `json:"hazards,omitempty"` Temperature forecast.Range `json:"temperature,omitempty"` MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"` PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"` } type OutdoorWindows struct { Best *OutdoorWindow `json:"best,omitempty"` Worst *OutdoorWindow `json:"worst,omitempty"` } type OutdoorWindow struct { Daypart string `json:"daypart"` Start string `json:"start"` End string `json:"end"` Reasons []string `json:"reasons,omitempty"` Score float64 `json:"score"` } type DiscussionContext struct { Product string `json:"product,omitempty"` KeyMessages []string `json:"keyMessages,omitempty"` ShortTerm string `json:"shortTerm,omitempty"` LongTerm string `json:"longTerm,omitempty"` } type WeatherStoryContext struct { Available bool `json:"available"` Summary string `json:"summary,omitempty"` } func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) { if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow { return Package{}, fmt.Errorf("daily briefing requires a daily report definition") } if summary == nil { return Package{}, fmt.Errorf("daily forecast summary is required") } pkg := Package{ Metadata: BuildMetadata(ctx), Daily: &Daily{ BottomLine: buildBottomLine(summary), Dayparts: summary.Dayparts, RelevantAlerts: summary.AlertOverlaps, OutdoorWindows: buildOutdoorWindows(summary.Dayparts), NarrativePeriods: summary.NarrativePeriods, Discussion: buildDiscussion(summary.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle), ForecastSummaryDate: summary.Date, }, } return pkg, nil } func buildBottomLine(summary *forecast.DailySummary) BottomLine { bottomLine := BottomLine{} conditions := map[string]struct{}{} hazards := map[string]struct{}{} for _, daypart := range summary.Dayparts { addRange(&bottomLine.Temperature, daypart.Temperature) maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability) maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust) if daypart.DominantCondition != "" { conditions[daypart.DominantCondition] = struct{}{} } for _, hazard := range hazardsForIndicators(daypart.Indicators) { hazards[hazard] = struct{}{} } } for _, alert := range summary.AlertOverlaps { if alert.Event != "" { hazards[alert.Event] = struct{}{} } } bottomLine.Hazards = sortedSet(hazards) bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards) return bottomLine } func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { var best *OutdoorWindow var worst *OutdoorWindow for _, daypart := range dayparts { if len(daypart.HourlyPeriods) == 0 { continue } window := scoreOutdoorWindow(daypart) if best == nil || window.Score < best.Score { copied := window best = &copied } if worst == nil || window.Score > worst.Score { copied := window worst = &copied } } return OutdoorWindows{Best: best, Worst: worst} } func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { if discussion == nil { return DiscussionContext{} } ctx := DiscussionContext{ Product: discussion.Product, KeyMessages: discussion.KeyMessages, } if discussion.ShortTerm != nil { ctx.ShortTerm = discussion.ShortTerm.Narrative } if discussion.LongTerm != nil { ctx.LongTerm = discussion.LongTerm.Narrative } return ctx } func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext { if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 { return nil } return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)} } func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { score := 0.0 reasons := []string{} if daypart.MaxPrecipitationProbability != nil { score += daypart.MaxPrecipitationProbability.Value if daypart.MaxPrecipitationProbability.Value >= 50 { reasons = append(reasons, "high precipitation chance") } } if daypart.PeakWindGust != nil { score += daypart.PeakWindGust.Value * 1.5 if daypart.PeakWindGust.Value >= 30 { reasons = append(reasons, "gusty wind") } } if len(daypart.AlertOverlaps) > 0 { score += float64(len(daypart.AlertOverlaps)) * 100 reasons = append(reasons, "alert overlap") } if daypart.Indicators.Thunder { score += 75 reasons = append(reasons, "thunder risk") } if daypart.Indicators.Heat || daypart.Indicators.Cold { score += 25 if daypart.Indicators.Heat { reasons = append(reasons, "heat risk") } if daypart.Indicators.Cold { reasons = append(reasons, "cold risk") } } if len(reasons) == 0 { reasons = append(reasons, "quiet weather") } return OutdoorWindow{ Daypart: daypart.Name, Start: daypart.Period.Start.Format("15:04"), End: daypart.Period.End.Format("15:04"), Reasons: dedupe(reasons), Score: math.Round(score*10) / 10, } } func bottomLineText(conditions []string, hazards []string) string { if len(conditions) == 0 && len(hazards) == 0 { return "Quiet weather is expected." } parts := []string{} if len(conditions) > 0 { parts = append(parts, "Conditions: "+strings.Join(conditions, "; ")) } if len(hazards) > 0 { parts = append(parts, "Watch points: "+strings.Join(hazards, "; ")) } return strings.Join(parts, ". ") + "." } func hazardsForIndicators(indicators forecast.Indicators) []string { var hazards []string if indicators.Thunder { hazards = append(hazards, "thunder") } if indicators.Snow { hazards = append(hazards, "snow") } if indicators.Ice { hazards = append(hazards, "ice") } if indicators.Fog { hazards = append(hazards, "fog") } if indicators.Heat { hazards = append(hazards, "heat") } if indicators.Cold { hazards = append(hazards, "cold") } if indicators.Wind { hazards = append(hazards, "wind") } return hazards } func addRange(target *forecast.Range, value forecast.Range) { if value.Min != nil { if target.Min == nil || *value.Min < *target.Min { copied := *value.Min target.Min = &copied } } if value.Max != nil { if target.Max == nil || *value.Max > *target.Max { copied := *value.Max target.Max = &copied } } } func maxTimedValue(target **forecast.TimedValue, value *forecast.TimedValue) { if value == nil { return } if *target == nil || value.Value > (*target).Value { copied := *value *target = &copied } } func sortedSet(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 dedupe(values []string) []string { seen := map[string]struct{}{} out := []string{} for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out }