361 lines
10 KiB
Go
361 lines
10 KiB
Go
package generatedtext
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
)
|
|
|
|
type HourlyRenderContext struct {
|
|
ReportTitle string
|
|
LocationName string
|
|
ValidPeriod string
|
|
GeneratedAt string
|
|
GeneratedText Hourly
|
|
CurrentConditions string
|
|
HourlyForecast []HourlyForecastRow
|
|
PrecipitationTiming string
|
|
Alerts []string
|
|
SPCOutlooks []string
|
|
ForecastDiscussion ForecastDiscussion
|
|
SPCDiscussions []string
|
|
WeatherStory string
|
|
}
|
|
|
|
type HourlyForecastRow struct {
|
|
Time string
|
|
Summary string
|
|
Temperature string
|
|
Precipitation string
|
|
Wind string
|
|
}
|
|
|
|
type ForecastDiscussion struct {
|
|
KeyMessages []string
|
|
ShortTerm string
|
|
}
|
|
|
|
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) {
|
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
|
if err != nil {
|
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
|
|
}
|
|
if metadata.GeneratedAt.IsZero() {
|
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
|
|
}
|
|
if !metadata.ValidPeriod.IsValid() {
|
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
|
}
|
|
|
|
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
|
if err != nil {
|
|
return HourlyRenderContext{}, err
|
|
}
|
|
|
|
return HourlyRenderContext{
|
|
ReportTitle: "Hourly Report",
|
|
LocationName: locationName(metadata),
|
|
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
|
|
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
|
|
GeneratedText: generated,
|
|
CurrentConditions: currentConditionsLabel(current),
|
|
HourlyForecast: hourlyForecastRows(hourly),
|
|
PrecipitationTiming: precipitationTimingLabel(precip),
|
|
Alerts: alertLabels(alerts),
|
|
SPCOutlooks: outlookLabels(outlooks),
|
|
ForecastDiscussion: ForecastDiscussion{
|
|
KeyMessages: append([]string(nil), discussion.KeyMessages...),
|
|
ShortTerm: discussion.ShortTerm,
|
|
},
|
|
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
|
|
WeatherStory: weatherStoryLabel(story),
|
|
}, nil
|
|
}
|
|
|
|
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
|
value, ok, err := module.StanzaValue[T](snapshot, name)
|
|
if err != nil {
|
|
var zero T
|
|
return zero, fmt.Errorf("build hourly render context: %w", err)
|
|
}
|
|
if !ok {
|
|
var zero T
|
|
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
|
value, _, err := module.StanzaValue[T](snapshot, name)
|
|
if err != nil {
|
|
var zero T
|
|
return zero, fmt.Errorf("build hourly render context: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func locationName(metadata briefing.Metadata) string {
|
|
if metadata.Location != nil {
|
|
if metadata.Location.Name != "" && metadata.Location.Region != "" {
|
|
return metadata.Location.Name + ", " + metadata.Location.Region
|
|
}
|
|
if metadata.Location.Name != "" {
|
|
return metadata.Location.Name
|
|
}
|
|
}
|
|
if metadata.SourceLocation != "" {
|
|
return metadata.SourceLocation
|
|
}
|
|
if metadata.SourceLocationID != "" {
|
|
return metadata.SourceLocationID
|
|
}
|
|
return "Unknown location"
|
|
}
|
|
|
|
func periodLabel(period timeutil.Period, location *time.Location) string {
|
|
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
|
|
}
|
|
|
|
func timeLabel(value time.Time, location *time.Location) string {
|
|
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
|
}
|
|
|
|
func currentConditionsLabel(current briefing.CurrentConditionsModule) string {
|
|
parts := []string{}
|
|
if current.ConditionText != "" {
|
|
parts = append(parts, current.ConditionText)
|
|
}
|
|
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
|
|
parts = append(parts, temperature)
|
|
}
|
|
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
|
|
parts = append(parts, "feels like "+apparent)
|
|
}
|
|
if current.RelativeHumidityPercent != nil {
|
|
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
|
|
}
|
|
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
|
|
parts = append(parts, wind)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "No current conditions available."
|
|
}
|
|
return strings.Join(parts, "; ") + "."
|
|
}
|
|
|
|
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
|
|
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
|
|
for _, period := range hourly.Periods {
|
|
summary := period.TextDescription
|
|
if summary == "" {
|
|
summary = period.Name
|
|
}
|
|
rows = append(rows, HourlyForecastRow{
|
|
Time: firstNonEmpty(period.PeriodBegins, period.Name),
|
|
Summary: summary,
|
|
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
|
|
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
|
|
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
|
|
parts := []string{}
|
|
if timing.MaxPopPercent != nil {
|
|
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
|
|
if timing.MaxPopTime != "" {
|
|
max += " at " + timing.MaxPopTime
|
|
}
|
|
parts = append(parts, max)
|
|
}
|
|
for _, window := range timing.PrecipitationWindows {
|
|
label := window.PeriodBegins
|
|
if window.PeriodEnds != "" {
|
|
label += " to " + window.PeriodEnds
|
|
}
|
|
if window.MaxPopPercent != nil {
|
|
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
|
|
if window.MaxPopTime != "" {
|
|
label += " at " + window.MaxPopTime
|
|
}
|
|
label += ")"
|
|
}
|
|
parts = append(parts, label)
|
|
}
|
|
if timing.ThunderMentioned {
|
|
parts = append(parts, "Thunder is mentioned in the forecast.")
|
|
}
|
|
if len(parts) == 0 {
|
|
return "No precipitation timing signal above threshold."
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
func alertLabels(alerts briefing.AlertDigestModule) []string {
|
|
if alerts.Missing {
|
|
return []string{"Alert source missing."}
|
|
}
|
|
out := make([]string, 0, len(alerts.Relevant))
|
|
for _, alert := range alerts.Relevant {
|
|
main := firstNonEmpty(alert.Event, alert.Headline)
|
|
if main == "" {
|
|
continue
|
|
}
|
|
if alert.Headline != "" && alert.Headline != main {
|
|
main += ": " + alert.Headline
|
|
}
|
|
if alert.Severity != "" {
|
|
main += " (" + alert.Severity + ")"
|
|
}
|
|
out = append(out, main)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
|
|
out := make([]string, 0, len(outlooks.Outlooks))
|
|
for _, outlook := range outlooks.Outlooks {
|
|
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
if outlook.PeriodBegins != "" {
|
|
label += " from " + outlook.PeriodBegins
|
|
if outlook.PeriodEnds != "" {
|
|
label += " to " + outlook.PeriodEnds
|
|
}
|
|
}
|
|
out = append(out, label)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
|
|
out := make([]string, 0, len(discussion.Discussions))
|
|
for _, record := range discussion.Discussions {
|
|
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
if record.Summary != "" && record.Summary != label {
|
|
label += ": " + record.Summary
|
|
}
|
|
out = append(out, label)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
|
|
if !story.Available {
|
|
return "No weather story available."
|
|
}
|
|
parts := []string{}
|
|
if story.Title != "" {
|
|
parts = append(parts, story.Title)
|
|
}
|
|
if story.Description != "" {
|
|
parts = append(parts, story.Description)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "Weather story is available."
|
|
}
|
|
return strings.Join(parts, " - ")
|
|
}
|
|
|
|
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
|
|
if fahrenheit != nil {
|
|
return fmt.Sprintf("%d F", rounded(*fahrenheit))
|
|
}
|
|
if celsius != nil {
|
|
return fmt.Sprintf("%d C", rounded(*celsius))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func precipitationLabel(percent *float64) string {
|
|
if percent == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
|
|
}
|
|
|
|
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
|
|
speed := ""
|
|
if mph != nil {
|
|
speed = fmt.Sprintf("%d mph", rounded(*mph))
|
|
} else if kmh != nil {
|
|
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
|
|
}
|
|
if direction != "" && speed != "" {
|
|
speed = direction + " " + speed
|
|
} else if direction != "" {
|
|
speed = direction + " wind"
|
|
}
|
|
gust := ""
|
|
if gustMph != nil {
|
|
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
|
|
} else if gustKmh != nil {
|
|
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
|
|
}
|
|
switch {
|
|
case speed != "" && gust != "":
|
|
return "wind " + speed + ", " + gust
|
|
case speed != "":
|
|
return "wind " + speed
|
|
case gust != "":
|
|
return "wind " + gust
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func rounded(value float64) int {
|
|
if value < 0 {
|
|
return int(value - 0.5)
|
|
}
|
|
return int(value + 0.5)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|