404 lines
15 KiB
Go
404 lines
15 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
type DerivedDaypartSummaryModule struct {
|
|
Date string `json:"date,omitempty"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
PeriodBegins string `json:"period_begins,omitempty"`
|
|
PeriodEnds string `json:"period_ends,omitempty"`
|
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
|
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
|
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
|
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"`
|
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
|
}
|
|
|
|
type DerivedDaypartSummaryPromptExport struct {
|
|
Date string `json:"date,omitempty"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
PeriodBegins string `json:"period_begins,omitempty"`
|
|
PeriodEnds string `json:"period_ends,omitempty"`
|
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
|
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"`
|
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
|
}
|
|
|
|
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
if len(ctx.Derived.DaypartSummaries) == 0 {
|
|
return nil, fmt.Errorf("daypart summary facts are required")
|
|
}
|
|
value := map[string]DerivedDaypartSummaryModule{}
|
|
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
|
for _, daypart := range ctx.Derived.DaypartSummaries {
|
|
key := daypartKey(daypart, prefixDates)
|
|
if existing, exists := value[key]; exists {
|
|
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
|
|
}
|
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
|
}
|
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
|
}
|
|
|
|
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
|
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
|
if !ok {
|
|
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
|
}
|
|
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
|
for key, daypart := range rich {
|
|
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
|
maxPopTime := rich.MaxPopTime
|
|
if rich.MaxPopTimeLabel != "" {
|
|
maxPopTime = rich.MaxPopTimeLabel
|
|
}
|
|
return DerivedDaypartSummaryPromptExport{
|
|
Date: rich.Date,
|
|
DisplayName: rich.DisplayName,
|
|
PeriodBegins: rich.PeriodBegins,
|
|
PeriodEnds: rich.PeriodEnds,
|
|
TempRangeF: rich.TempRangeF,
|
|
ApparentTempRangeF: rich.ApparentTempRangeF,
|
|
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
|
MaxPopTime: maxPopTime,
|
|
MentionPrecipitation: rich.MentionPrecipitation,
|
|
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
|
MaxWindGustTime: rich.MaxWindGustTime,
|
|
DominantCondition: rich.DominantCondition,
|
|
TemperatureTrend: rich.TemperatureTrend,
|
|
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
|
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
|
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
|
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
|
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
|
Snow: rich.Snow,
|
|
Ice: rich.Ice,
|
|
Fog: rich.Fog,
|
|
Heat: rich.Heat,
|
|
Cold: rich.Cold,
|
|
Wind: rich.Wind,
|
|
RelevantAlertCount: rich.RelevantAlertCount,
|
|
}
|
|
}
|
|
|
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
|
temperature := daypartTemperatureDisplay(daypart)
|
|
value := DerivedDaypartSummaryModule{
|
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
|
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
|
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
|
TempRangeF: rangeLabel(daypart.Temperature),
|
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
|
TemperatureTrend: temperature.Trend,
|
|
TemperatureStartPhraseF: temperature.StartPhrase,
|
|
TemperatureEndPhraseF: temperature.EndPhrase,
|
|
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
|
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
|
DominantCondition: daypart.DominantCondition,
|
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
|
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
|
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
|
Snow: daypart.Indicators.Snow,
|
|
Ice: daypart.Indicators.Ice,
|
|
Fog: daypart.Indicators.Fog,
|
|
Heat: daypart.Indicators.Heat,
|
|
Cold: daypart.Indicators.Cold,
|
|
Wind: daypart.Indicators.Wind,
|
|
RelevantAlertCount: len(daypart.AlertOverlaps),
|
|
}
|
|
if daypart.MaxPrecipitationProbability != nil {
|
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
|
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
|
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, hourlyForecastPrecipMentionProbabilityThreshold)
|
|
}
|
|
if daypart.PeakWindGust != nil {
|
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
|
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
|
}
|
|
return value
|
|
}
|
|
|
|
type daypartTemperaturePresentation struct {
|
|
Trend string
|
|
StartPhrase string
|
|
EndPhrase string
|
|
PeakPhrase string
|
|
SteadyPhrase string
|
|
}
|
|
|
|
type temperaturePoint struct {
|
|
value int
|
|
bandIndex int
|
|
phrase string
|
|
}
|
|
|
|
const (
|
|
temperatureTrendRising = "rising"
|
|
temperatureTrendFalling = "falling"
|
|
temperatureTrendPeaking = "peaking"
|
|
temperatureTrendSteady = "steady"
|
|
)
|
|
|
|
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
|
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
|
if len(points) == 0 {
|
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
|
}
|
|
if len(points) == 1 {
|
|
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
|
}
|
|
|
|
first := points[0]
|
|
last := points[len(points)-1]
|
|
peak, peakIndex := peakTemperaturePoint(points)
|
|
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
|
return daypartTemperaturePresentation{
|
|
Trend: temperatureTrendPeaking,
|
|
PeakPhrase: peak.phrase,
|
|
}
|
|
}
|
|
switch {
|
|
case first.bandIndex < last.bandIndex:
|
|
return daypartTemperaturePresentation{
|
|
Trend: temperatureTrendRising,
|
|
StartPhrase: first.phrase,
|
|
EndPhrase: last.phrase,
|
|
}
|
|
case first.bandIndex > last.bandIndex:
|
|
return daypartTemperaturePresentation{
|
|
Trend: temperatureTrendFalling,
|
|
StartPhrase: first.phrase,
|
|
EndPhrase: last.phrase,
|
|
}
|
|
default:
|
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
|
}
|
|
}
|
|
|
|
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
|
if phrase == "" {
|
|
return daypartTemperaturePresentation{}
|
|
}
|
|
return daypartTemperaturePresentation{
|
|
Trend: temperatureTrendSteady,
|
|
SteadyPhrase: phrase,
|
|
}
|
|
}
|
|
|
|
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
|
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
|
sort.SliceStable(sorted, func(i int, j int) bool {
|
|
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
|
})
|
|
points := make([]temperaturePoint, 0, len(sorted))
|
|
for _, period := range sorted {
|
|
temperature := forecastPeriodTemperatureF(period)
|
|
if temperature == nil {
|
|
continue
|
|
}
|
|
rounded := roundedInt(temperature)
|
|
if rounded == nil {
|
|
continue
|
|
}
|
|
points = append(points, temperaturePoint{
|
|
value: *rounded,
|
|
bandIndex: temperatureBandIndex(*rounded),
|
|
phrase: temperatureBandPhrase(*rounded),
|
|
})
|
|
}
|
|
return points
|
|
}
|
|
|
|
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
|
peak := points[0]
|
|
peakIndex := 0
|
|
for index, point := range points[1:] {
|
|
if point.value > peak.value {
|
|
peak = point
|
|
peakIndex = index + 1
|
|
}
|
|
}
|
|
return peak, peakIndex
|
|
}
|
|
|
|
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
|
switch {
|
|
case period.TemperatureF != nil:
|
|
return period.TemperatureF
|
|
case period.TemperatureFMax != nil:
|
|
return period.TemperatureFMax
|
|
case period.TemperatureFMin != nil:
|
|
return period.TemperatureFMin
|
|
case period.TemperatureC != nil:
|
|
value := celsiusToFahrenheit(*period.TemperatureC)
|
|
return &value
|
|
case period.TemperatureCMax != nil:
|
|
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
|
return &value
|
|
case period.TemperatureCMin != nil:
|
|
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
|
return &value
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func celsiusToFahrenheit(value float64) float64 {
|
|
return value*9/5 + 32
|
|
}
|
|
|
|
func temperatureBandIndex(value int) int {
|
|
decade, remainder := temperatureBandParts(value)
|
|
band := temperatureBandQualifierIndex(remainder)
|
|
return decade*3 + band
|
|
}
|
|
|
|
func temperaturePhraseF(value forecast.Range) string {
|
|
if value.Min == nil && value.Max == nil {
|
|
return ""
|
|
}
|
|
if value.Min != nil && value.Max != nil {
|
|
low := roundedInt(value.Min)
|
|
high := roundedInt(value.Max)
|
|
if low == nil || high == nil {
|
|
return ""
|
|
}
|
|
lowPhrase := temperatureBandPhrase(*low)
|
|
highPhrase := temperatureBandPhrase(*high)
|
|
if lowPhrase == highPhrase {
|
|
return lowPhrase
|
|
}
|
|
return lowPhrase + " to " + highPhrase
|
|
}
|
|
if value.Min != nil {
|
|
low := roundedInt(value.Min)
|
|
if low == nil {
|
|
return ""
|
|
}
|
|
return temperatureBandPhrase(*low)
|
|
}
|
|
high := roundedInt(value.Max)
|
|
if high == nil {
|
|
return ""
|
|
}
|
|
return temperatureBandPhrase(*high)
|
|
}
|
|
|
|
func temperatureBandPhrase(value int) string {
|
|
if value < 0 {
|
|
decade, remainder := temperatureBandParts(-value)
|
|
qualifier := temperatureBandQualifier(remainder)
|
|
if decade == 0 {
|
|
return fmt.Sprintf("%s single digits below zero", qualifier)
|
|
}
|
|
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
|
|
}
|
|
decade, remainder := temperatureBandParts(value)
|
|
qualifier := temperatureBandQualifier(remainder)
|
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
|
}
|
|
|
|
func temperatureBandParts(value int) (int, int) {
|
|
decade := value / 10
|
|
if value < 0 && value%10 != 0 {
|
|
decade--
|
|
}
|
|
return decade * 10, value - decade*10
|
|
}
|
|
|
|
func temperatureBandQualifierIndex(remainder int) int {
|
|
switch {
|
|
case remainder <= 3:
|
|
return 0
|
|
case remainder >= 7:
|
|
return 2
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func temperatureBandQualifier(remainder int) string {
|
|
switch temperatureBandQualifierIndex(remainder) {
|
|
case 0:
|
|
return "low"
|
|
case 2:
|
|
return "upper"
|
|
default:
|
|
return "mid"
|
|
}
|
|
}
|
|
|
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
|
seen := map[string]struct{}{}
|
|
for _, summary := range summaries {
|
|
seen[summary.Date] = struct{}{}
|
|
}
|
|
return len(seen) > 1
|
|
}
|
|
|
|
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
|
key := forecast.CanonicalDaypartKey(daypart.Name)
|
|
if key == "" {
|
|
key = "unnamed"
|
|
}
|
|
if !prefixDate {
|
|
return key
|
|
}
|
|
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
|
}
|