// 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 StormWindowSummary *forecast.DaypartSummary } 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} case report.ThreeDay, report.Weekend: summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts) if err != nil { return DerivedFacts{}, err } derived.DailySummaries = summaries case report.Storm: summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods) summary.AlertOverlaps = derived.AlertOverlaps derived.StormWindowSummary = &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...) } if derived.StormWindowSummary != nil { out = append(out, *derived.StormWindowSummary) } 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 }