package briefing import ( "fmt" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type Storm struct { TimingWindow timeutil.Period `json:"timingWindow"` EventHeadlines []string `json:"eventHeadlines,omitempty"` Hazards []string `json:"hazards,omitempty"` MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"` ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"` ConfidenceInputs []string `json:"confidenceInputs,omitempty"` WhatToWatchNext []string `json:"whatToWatchNext,omitempty"` RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"` DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"` NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"` WindowSummary forecast.DaypartSummary `json:"windowSummary"` Discussion DiscussionContext `json:"discussion,omitempty"` WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` } func BuildStorm(ctx BuildContext) (Package, error) { if ctx.Resolved.Definition.ID != report.Storm { return Package{}, fmt.Errorf("storm briefing requires a storm report definition") } if ctx.Bundle == nil { return Package{}, fmt.Errorf("forecast bundle is required") } period := ctx.Resolved.ValidPeriod hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period) narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period) daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period) alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period) summary := forecast.SummarizeDaypart("storm window", period, hourly) summary.AlertOverlaps = alerts storm := &Storm{ TimingWindow: period, EventHeadlines: stormHeadlines(alerts), Hazards: stormHazards(alerts, summary), MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary), ReasonableWorstCase: reasonableWorstCase(alerts, summary), ConfidenceInputs: stormConfidenceInputs(ctx.Bundle), WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle), RelevantAlerts: alerts, HourlyPeriods: hourly, DailyPeriods: daily, NarrativePeriods: narrative, WindowSummary: summary, Discussion: buildDiscussion(ctx.Bundle.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle), } return Package{ Metadata: BuildMetadata(ctx), Storm: storm, }, nil } func stormHeadlines(alerts []forecast.AlertOverlap) []string { var headlines []string for _, alert := range alerts { if alert.Headline != "" { headlines = appendUnique(headlines, alert.Headline) continue } if alert.Event != "" { headlines = appendUnique(headlines, alert.Event) } } if len(headlines) == 0 { return []string{"No active alert headline overlaps the selected storm window."} } return headlines } func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string { hazards := map[string]struct{}{} for _, alert := range alerts { if alert.Event != "" { hazards[alert.Event] = struct{}{} } } for _, hazard := range hazardsForIndicators(summary.Indicators) { hazards[hazard] = struct{}{} } if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 { hazards["precipitation"] = struct{}{} } if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 { hazards["wind"] = struct{}{} } out := sortedSet(hazards) if len(out) == 0 { return []string{"No storm-specific hazard signal stands out in the selected source data."} } return out } func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string { var items []string if summary.DominantCondition != "" { items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".") } if summary.MaxPrecipitationProbability != nil { items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04"))) } if summary.PeakWindGust != nil { items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04"))) } for _, period := range narrative { if period.TextDescription != "" { items = append(items, "Narrative guidance: "+period.TextDescription) break } } if len(items) == 0 && len(hourly) > 0 { items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.") } if len(items) == 0 { items = append(items, "No active storm signal is evident from the selected forecast window.") } return items } func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string { var items []string for _, alert := range alerts { label := alert.Event if label == "" { label = alert.Headline } if label != "" { items = appendUnique(items, "Alert scenario to consider: "+label+".") } } if summary.Indicators.Thunder { items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.") } if summary.Indicators.Wind { items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.") } if summary.Indicators.Snow || summary.Indicators.Ice { items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.") } if len(items) == 0 { items = append(items, "No clear reasonable worst-case signal is represented in the selected data.") } return items } func stormConfidenceInputs(bundle *forecast.Bundle) []string { var items []string if bundle == nil { return []string{"No source bundle was available for confidence context."} } if bundle.Discussion != nil { items = appendUnique(items, bundle.Discussion.KeyMessages...) if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" { items = appendUnique(items, "Short-term discussion is available for confidence context.") } } if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 { items = appendUnique(items, "Weather story source is available.") } for _, warning := range bundle.Warnings { if warning.Code != "" { items = appendUnique(items, "Source warning: "+warning.Code+".") } } if len(items) == 0 { items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.") } return items } func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string { var items []string if len(alerts) > 0 { items = append(items, "Watch for alert extensions, cancellations, or upgrades.") } if summary.MaxPrecipitationProbability != nil { items = append(items, "Watch precipitation timing and probability trends.") } if summary.PeakWindGust != nil { items = append(items, "Watch wind gust trends.") } if bundle != nil && bundle.Discussion != nil { items = append(items, "Watch the next forecast discussion update for confidence changes.") } if len(items) == 0 { items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.") } return appendUnique(nil, items...) }