Add manual Storm Report generation
This commit is contained in:
200
internal/briefing/storm.go
Normal file
200
internal/briefing/storm.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user