Files
weatherreporter/internal/facts/facts.go

216 lines
7.2 KiB
Go

// Package facts defines collected and derived report facts.
package facts
import (
"fmt"
"sort"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type CollectedFacts struct {
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
}
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
if bundle == nil {
return CollectedFacts{}
}
return CollectedFacts{
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
}
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
}
type BuildDerivedRequest struct {
Resolved report.Resolved
Timezone string
Dayparts []forecast.DaypartDefinition
Collected CollectedFacts
}
type DerivedFacts struct {
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming
}
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
if len(f.DailySummaries) == 0 {
return nil
}
return &f.DailySummaries[0]
}
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
if !req.Resolved.ValidPeriod.IsValid() {
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
}
location, err := timeutil.LoadLocation(req.Timezone)
if err != nil {
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
}
bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
}
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID {
case report.Hourly:
case report.Daily, report.Today, report.Tomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = []forecast.DailySummary{*summary}
default:
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
}
derived.DaypartSummaries = collectDaypartSummaries(derived)
return derived, nil
}
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
var out []forecast.DaypartSummary
for _, summary := range derived.DailySummaries {
out = append(out, summary.Dayparts...)
}
return out
}
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
if run == nil {
return nil, nil
}
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
days := map[int]struct{}{}
for _, outlook := range run.Outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
continue
}
outlooks = append(outlooks, outlook)
days[outlook.Day] = struct{}{}
}
sort.SliceStable(outlooks, func(i, j int) bool {
left := outlooks[i]
right := outlooks[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.OutlookType != right.OutlookType {
return left.OutlookType < right.OutlookType
}
leftRank, leftRankOK := severityRank(left)
rightRank, rightRankOK := severityRank(right)
if leftRankOK != rightRankOK {
return leftRankOK
}
if leftRankOK && leftRank != rightRank {
return leftRank > rightRank
}
if !left.ValidFrom.Equal(right.ValidFrom) {
return left.ValidFrom.Before(right.ValidFrom)
}
if left.Label != right.Label {
return left.Label < right.Label
}
return left.ID < right.ID
})
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
for _, discussion := range run.Discussions {
if _, ok := days[discussion.Day]; ok {
discussions = append(discussions, discussion)
}
}
sort.SliceStable(discussions, func(i, j int) bool {
left := discussions[i]
right := discussions[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
return left.UpdatedAt.Before(*right.UpdatedAt)
}
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
return left.UpdatedAt != nil
}
if left.Headline != right.Headline {
return left.Headline < right.Headline
}
if left.Summary != right.Summary {
return left.Summary < right.Summary
}
return left.Discussion < right.Discussion
})
return outlooks, discussions
}
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
if outlook.SeverityRank == nil {
return 0, false
}
return *outlook.SeverityRank, true
}