331 lines
12 KiB
Go
331 lines
12 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
)
|
|
|
|
type DerivedDailySummaryModule struct {
|
|
Date string `json:"date,omitempty"`
|
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
MaxPopWindow string `json:"max_pop_window,omitempty"`
|
|
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
|
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
|
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
|
Hazards []string `json:"hazards,omitempty"`
|
|
}
|
|
|
|
type DerivedDaypartSummaryModule struct {
|
|
Period timeutil.Period `json:"period"`
|
|
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"`
|
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
|
DominantCondition string `json:"dominant_condition,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 PrecipTimingModule struct {
|
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
|
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
|
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
|
}
|
|
|
|
type OutdoorWindowsModule struct {
|
|
Best *OutdoorWindowModule `json:"best,omitempty"`
|
|
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
|
}
|
|
|
|
type OutdoorWindowModule struct {
|
|
Daypart string `json:"daypart"`
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
Reasons []string `json:"reasons,omitempty"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type TomorrowPlanningModule struct {
|
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
|
}
|
|
|
|
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
summary := ctx.Derived.FirstDailySummary()
|
|
if summary == nil {
|
|
return nil, fmt.Errorf("daily summary facts are required")
|
|
}
|
|
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
|
}
|
|
|
|
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)
|
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
|
}
|
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
|
}
|
|
|
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
|
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
|
}
|
|
|
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
|
value := OutdoorWindowsModule{
|
|
Best: outdoorWindowValue(windows.Best),
|
|
Worst: outdoorWindowValue(windows.Worst),
|
|
}
|
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
|
}
|
|
|
|
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
summary := ctx.Derived.FirstDailySummary()
|
|
if summary == nil {
|
|
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
|
}
|
|
planning := buildTomorrowPlanning(summary)
|
|
value := TomorrowPlanningModule{}
|
|
if planning != nil {
|
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
|
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
|
}
|
|
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
|
}
|
|
|
|
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
|
value := DerivedDailySummaryModule{
|
|
Date: summary.Date,
|
|
ThunderMentioned: timing.ThunderMentioned,
|
|
}
|
|
conditions := map[string]struct{}{}
|
|
hazards := map[string]struct{}{}
|
|
var temperature forecast.Range
|
|
var apparent forecast.Range
|
|
var maxPop *forecast.TimedValue
|
|
var maxGust *forecast.TimedValue
|
|
var maxPopWindow timeutil.Period
|
|
for _, daypart := range summary.Dayparts {
|
|
addRange(&temperature, daypart.Temperature)
|
|
addRange(&apparent, daypart.ApparentTemperature)
|
|
if daypart.MaxPrecipitationProbability != nil {
|
|
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
|
|
copied := *daypart.MaxPrecipitationProbability
|
|
maxPop = &copied
|
|
maxPopWindow = daypart.Period
|
|
}
|
|
}
|
|
maxTimedValue(&maxGust, 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{}{}
|
|
}
|
|
}
|
|
value.HighTempF = roundedInt(temperature.Max)
|
|
value.LowTempF = roundedInt(temperature.Min)
|
|
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
|
if maxPop != nil {
|
|
value.MaxPopPercent = roundedInt(&maxPop.Value)
|
|
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
|
|
}
|
|
if maxGust != nil {
|
|
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
|
}
|
|
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
|
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
|
value.DominantConditions = sortedSet(conditions)
|
|
value.Hazards = sortedSet(hazards)
|
|
return value, nil
|
|
}
|
|
|
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
|
value := DerivedDaypartSummaryModule{
|
|
Period: daypart.Period,
|
|
TempRangeF: rangeLabel(daypart.Temperature),
|
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
|
DominantCondition: 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)
|
|
}
|
|
if daypart.PeakWindGust != nil {
|
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
|
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
|
value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned}
|
|
if timing.MaxPrecipitationProbability != nil {
|
|
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
|
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
|
}
|
|
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
|
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
|
return value
|
|
}
|
|
|
|
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
|
if window == nil {
|
|
return nil
|
|
}
|
|
return &OutdoorWindowModule{
|
|
Daypart: window.Daypart,
|
|
Start: window.Start,
|
|
End: window.End,
|
|
Reasons: append([]string(nil), window.Reasons...),
|
|
Score: window.Score,
|
|
}
|
|
}
|
|
|
|
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 := normalizedKey(daypart.Name)
|
|
if key == "" {
|
|
key = "unnamed"
|
|
}
|
|
if !prefixDate {
|
|
return key
|
|
}
|
|
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
|
}
|
|
|
|
func normalizedKey(value string) string {
|
|
lower := strings.ToLower(strings.TrimSpace(value))
|
|
var out strings.Builder
|
|
lastUnderscore := false
|
|
for _, r := range lower {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
out.WriteRune(r)
|
|
lastUnderscore = false
|
|
continue
|
|
}
|
|
if !lastUnderscore {
|
|
out.WriteByte('_')
|
|
lastUnderscore = true
|
|
}
|
|
}
|
|
return strings.Trim(out.String(), "_")
|
|
}
|
|
|
|
func rangeLabel(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 && *low == *high {
|
|
return fmt.Sprintf("%d", *low)
|
|
}
|
|
return fmt.Sprintf("%d-%d", *low, *high)
|
|
}
|
|
if value.Min != nil {
|
|
low := roundedInt(value.Min)
|
|
return fmt.Sprintf("%d", *low)
|
|
}
|
|
high := roundedInt(value.Max)
|
|
return fmt.Sprintf("%d", *high)
|
|
}
|
|
|
|
func daypartApparentRangeLabel(value forecast.Range) string {
|
|
if value.Min == nil && value.Max == nil {
|
|
return ""
|
|
}
|
|
return rangeLabel(value)
|
|
}
|
|
|
|
func roundedInt(value *float64) *int {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
rounded := int(*value + 0.5)
|
|
if *value < 0 {
|
|
rounded = int(*value - 0.5)
|
|
}
|
|
return &rounded
|
|
}
|
|
|
|
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return clockLabel(value.Time, timezone)
|
|
}
|
|
|
|
func periodClockLabel(period timeutil.Period, timezone string) string {
|
|
if !period.IsValid() {
|
|
return ""
|
|
}
|
|
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
|
}
|
|
|
|
func clockLabel(value time.Time, timezone string) string {
|
|
location, err := timeutil.LoadLocation(timezone)
|
|
if err != nil {
|
|
location = time.UTC
|
|
}
|
|
label := value.In(location).Format("3 PM")
|
|
if label == "12 AM" && value.In(location).Minute() == 0 {
|
|
return "12 AM"
|
|
}
|
|
return label
|
|
}
|