216 lines
7.2 KiB
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
|
|
}
|