Refactor to separate each module and report into an individual file
This commit is contained in:
60
internal/briefing/alert_digest_module.go
Normal file
60
internal/briefing/alert_digest_module.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertDigestModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
RelevantCount int `json:"relevant_count"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertSummary struct {
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
||||||
|
if value == nil {
|
||||||
|
value = &AlertDigestModule{}
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
||||||
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
|
if collected.Alerts == nil && !missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := &AlertDigestModule{Missing: missing}
|
||||||
|
if collected.Alerts != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
value.RelevantCount = len(overlaps)
|
||||||
|
for _, overlap := range overlaps {
|
||||||
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
|
Event: overlap.Event,
|
||||||
|
Headline: overlap.Headline,
|
||||||
|
Severity: overlap.Severity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name && source.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
67
internal/briefing/area_forecast_discussion_module.go
Normal file
67
internal/briefing/area_forecast_discussion_module.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AreaForecastDiscussionModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
KeyMessages []string `json:"key_messages,omitempty"`
|
||||||
|
ShortTerm string `json:"short_term,omitempty"`
|
||||||
|
LongTerm string `json:"long_term,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||||
|
discussion := ctx.Collected.Discussion
|
||||||
|
if discussion == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||||
|
}
|
||||||
|
sections, err := areaForecastDiscussionSections(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value := AreaForecastDiscussionModule{}
|
||||||
|
if sections["product"] {
|
||||||
|
value.Product = discussion.Product
|
||||||
|
}
|
||||||
|
if sections["key_messages"] {
|
||||||
|
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||||
|
}
|
||||||
|
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||||
|
value.ShortTerm = discussion.ShortTerm.Text
|
||||||
|
}
|
||||||
|
if sections["long_term"] && discussion.LongTerm != nil {
|
||||||
|
value.LongTerm = discussion.LongTerm.Text
|
||||||
|
}
|
||||||
|
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||||
|
if len(options.Sections) == 0 {
|
||||||
|
return map[string]bool{
|
||||||
|
"product": true,
|
||||||
|
"key_messages": true,
|
||||||
|
"short_term": true,
|
||||||
|
"long_term": true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
sections := map[string]bool{}
|
||||||
|
for _, section := range options.Sections {
|
||||||
|
switch section {
|
||||||
|
case "product", "key_messages", "short_term", "long_term":
|
||||||
|
sections[section] = true
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MetadataModule struct {
|
|
||||||
RunID string `json:"run_id"`
|
|
||||||
ReportID report.ID `json:"report_id"`
|
|
||||||
Variant string `json:"variant,omitempty"`
|
|
||||||
PromptID string `json:"prompt_id"`
|
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
|
||||||
Units string `json:"units"`
|
|
||||||
Timezone string `json:"timezone"`
|
|
||||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
|
||||||
Location *LocationContext `json:"location,omitempty"`
|
|
||||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
|
||||||
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourceWarningSummary struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CurrentConditionsModule struct {
|
|
||||||
ConditionText string `json:"condition_text,omitempty"`
|
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
|
||||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
|
||||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
|
||||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
|
||||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
|
||||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
|
||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
|
||||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
|
||||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
|
||||||
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertDigestModule struct {
|
|
||||||
Checked bool `json:"checked"`
|
|
||||||
ActiveCount int `json:"active_count"`
|
|
||||||
RelevantCount int `json:"relevant_count"`
|
|
||||||
Missing bool `json:"missing,omitempty"`
|
|
||||||
Relevant []AlertSummary `json:"relevant,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertSummary struct {
|
|
||||||
Event string `json:"event,omitempty"`
|
|
||||||
Headline string `json:"headline,omitempty"`
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AreaForecastDiscussionModule struct {
|
|
||||||
Product string `json:"product,omitempty"`
|
|
||||||
KeyMessages []string `json:"key_messages,omitempty"`
|
|
||||||
ShortTerm string `json:"short_term,omitempty"`
|
|
||||||
LongTerm string `json:"long_term,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
|
||||||
StartTime time.Time `json:"start_time"`
|
|
||||||
EndTime time.Time `json:"end_time"`
|
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
AltText string `json:"alt_text,omitempty"`
|
|
||||||
Priority bool `json:"priority"`
|
|
||||||
Order int `json:"order"`
|
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
metadata := ctx.Resolved.Metadata()
|
|
||||||
value := MetadataModule{
|
|
||||||
RunID: metadata.RunID,
|
|
||||||
ReportID: metadata.ReportID,
|
|
||||||
Variant: variantForReport(metadata.ReportID),
|
|
||||||
PromptID: metadata.PromptID,
|
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
|
||||||
Units: ctx.Units,
|
|
||||||
Timezone: ctx.Timezone,
|
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
|
||||||
Location: copyLocation(ctx.Location),
|
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
|
||||||
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
current := ctx.Collected.Current
|
|
||||||
if current == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value := CurrentConditionsModule{
|
|
||||||
ConditionText: current.ConditionText,
|
|
||||||
IsDay: copyBool(current.IsDay),
|
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
|
||||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
|
||||||
}
|
|
||||||
if value.isEmpty() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
|
||||||
if value == nil {
|
|
||||||
value = &AlertDigestModule{}
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
|
||||||
discussion := ctx.Collected.Discussion
|
|
||||||
if discussion == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
|
||||||
}
|
|
||||||
sections, err := areaForecastDiscussionSections(opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
value := AreaForecastDiscussionModule{}
|
|
||||||
if sections["product"] {
|
|
||||||
value.Product = discussion.Product
|
|
||||||
}
|
|
||||||
if sections["key_messages"] {
|
|
||||||
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
|
||||||
}
|
|
||||||
if sections["short_term"] && discussion.ShortTerm != nil {
|
|
||||||
value.ShortTerm = discussion.ShortTerm.Text
|
|
||||||
}
|
|
||||||
if sections["long_term"] && discussion.LongTerm != nil {
|
|
||||||
value.LongTerm = discussion.LongTerm.Text
|
|
||||||
}
|
|
||||||
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
|
||||||
if len(options.Sections) == 0 {
|
|
||||||
return map[string]bool{
|
|
||||||
"product": true,
|
|
||||||
"key_messages": true,
|
|
||||||
"short_term": true,
|
|
||||||
"long_term": true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
sections := map[string]bool{}
|
|
||||||
for _, section := range options.Sections {
|
|
||||||
switch section {
|
|
||||||
case "product", "key_messages", "short_term", "long_term":
|
|
||||||
sections[section] = true
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sections, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
story := ctx.Collected.WeatherStory
|
|
||||||
if story == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value := WeatherStoryModule{
|
|
||||||
Available: true,
|
|
||||||
OfficeID: story.OfficeID,
|
|
||||||
StartTime: story.StartTime,
|
|
||||||
EndTime: story.EndTime,
|
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
|
||||||
Title: story.Title,
|
|
||||||
Description: story.Description,
|
|
||||||
AltText: story.AltText,
|
|
||||||
Priority: story.Priority,
|
|
||||||
Order: story.Order,
|
|
||||||
DownloadURL: story.DownloadURL,
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
|
||||||
out := make([]SourceWarningSummary, 0, len(warnings))
|
|
||||||
for _, warning := range warnings {
|
|
||||||
out = append(out, SourceWarningSummary{
|
|
||||||
Source: warning.Source,
|
|
||||||
Code: warning.Code,
|
|
||||||
Severity: warning.Severity,
|
|
||||||
Message: warning.Message,
|
|
||||||
CompletenessImpact: warning.CompletenessImpact,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
|
||||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
|
||||||
if collected.Alerts == nil && !missing {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
value := &AlertDigestModule{Missing: missing}
|
|
||||||
if collected.Alerts != nil {
|
|
||||||
value.Checked = true
|
|
||||||
value.ActiveCount = len(collected.Alerts.Alerts)
|
|
||||||
}
|
|
||||||
value.RelevantCount = len(overlaps)
|
|
||||||
for _, overlap := range overlaps {
|
|
||||||
value.Relevant = append(value.Relevant, AlertSummary{
|
|
||||||
Event: overlap.Event,
|
|
||||||
Headline: overlap.Headline,
|
|
||||||
Severity: overlap.Severity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
|
||||||
for _, source := range sources {
|
|
||||||
if source.Name == name && source.Missing {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
|
||||||
return v.ConditionText == "" &&
|
|
||||||
v.IsDay == nil &&
|
|
||||||
v.TemperatureC == nil &&
|
|
||||||
v.TemperatureF == nil &&
|
|
||||||
v.ApparentTemperatureC == nil &&
|
|
||||||
v.ApparentTemperatureF == nil &&
|
|
||||||
v.DewpointC == nil &&
|
|
||||||
v.DewpointF == nil &&
|
|
||||||
v.RelativeHumidityPercent == nil &&
|
|
||||||
v.WindSpeedKmh == nil &&
|
|
||||||
v.WindSpeedMph == nil &&
|
|
||||||
v.WindDirectionDegrees == nil
|
|
||||||
}
|
|
||||||
58
internal/briefing/current_conditions_module.go
Normal file
58
internal/briefing/current_conditions_module.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type CurrentConditionsModule struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
current := ctx.Collected.Current
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := CurrentConditionsModule{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: copyFloat(current.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||||
|
DewpointC: copyFloat(current.DewpointC),
|
||||||
|
DewpointF: copyFloat(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
|
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
|
return v.ConditionText == "" &&
|
||||||
|
v.IsDay == nil &&
|
||||||
|
v.TemperatureC == nil &&
|
||||||
|
v.TemperatureF == nil &&
|
||||||
|
v.ApparentTemperatureC == nil &&
|
||||||
|
v.ApparentTemperatureF == nil &&
|
||||||
|
v.DewpointC == nil &&
|
||||||
|
v.DewpointF == nil &&
|
||||||
|
v.RelativeHumidityPercent == nil &&
|
||||||
|
v.WindSpeedKmh == nil &&
|
||||||
|
v.WindSpeedMph == nil &&
|
||||||
|
v.WindDirectionDegrees == nil
|
||||||
|
}
|
||||||
88
internal/briefing/derived_daily_summary_module.go
Normal file
88
internal/briefing/derived_daily_summary_module.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
106
internal/briefing/derived_daypart_summaries_module.go
Normal file
106
internal/briefing/derived_daypart_summaries_module.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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(), "_")
|
||||||
|
}
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
64
internal/briefing/metadata_module.go
Normal file
64
internal/briefing/metadata_module.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetadataModule struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
ReportID report.ID `json:"report_id"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Units string `json:"units"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
|
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceWarningSummary struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
metadata := ctx.Resolved.Metadata()
|
||||||
|
value := MetadataModule{
|
||||||
|
RunID: metadata.RunID,
|
||||||
|
ReportID: metadata.ReportID,
|
||||||
|
Variant: variantForReport(metadata.ReportID),
|
||||||
|
PromptID: metadata.PromptID,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
Units: ctx.Units,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
|
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||||
|
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||||
|
for _, warning := range warnings {
|
||||||
|
out = append(out, SourceWarningSummary{
|
||||||
|
Source: warning.Source,
|
||||||
|
Code: warning.Code,
|
||||||
|
Severity: warning.Severity,
|
||||||
|
Message: warning.Message,
|
||||||
|
CompletenessImpact: warning.CompletenessImpact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
73
internal/briefing/module_format_helpers.go
Normal file
73
internal/briefing/module_format_helpers.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
38
internal/briefing/outdoor_windows_module.go
Normal file
38
internal/briefing/outdoor_windows_module.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/briefing/precip_timing_module.go
Normal file
30
internal/briefing/precip_timing_module.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
24
internal/briefing/tomorrow_planning_module.go
Normal file
24
internal/briefing/tomorrow_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
42
internal/briefing/weather_story_module.go
Normal file
42
internal/briefing/weather_story_module.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherStoryModule struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"alt_text,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
story := ctx.Collected.WeatherStory
|
||||||
|
if story == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||||
|
}
|
||||||
69
internal/report/daily_report.go
Normal file
69
internal/report/daily_report.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dailyTodayDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: DailyToday,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
Modules: dailyTodayModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveDailyToday,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTomorrowDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: DailyTomorrow,
|
||||||
|
Name: "Tomorrow Planning Brief",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "tomorrow.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
Modules: dailyTomorrowModules(),
|
||||||
|
Evening: true,
|
||||||
|
resolve: resolveDailyTomorrow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTodayModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTomorrowModules() []module.ConfigItem {
|
||||||
|
items := dailyTodayModules()
|
||||||
|
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if !req.Date.IsZero() {
|
||||||
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
|
}
|
||||||
|
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ package report
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||||
@@ -70,75 +68,3 @@ func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (
|
|||||||
ValidPeriod: period,
|
ValidPeriod: period,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if !req.Date.IsZero() {
|
|
||||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
|
||||||
}
|
|
||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
|
|
||||||
if location == nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
startTime, err := timeutil.ParseStormTime(start, location)
|
|
||||||
if err != nil {
|
|
||||||
return timeutil.Period{}, err
|
|
||||||
}
|
|
||||||
endTime, err := timeutil.ParseStormTime(end, location)
|
|
||||||
if err != nil {
|
|
||||||
return timeutil.Period{}, err
|
|
||||||
}
|
|
||||||
period := timeutil.Period{Start: startTime, End: endTime}
|
|
||||||
if !period.IsValid() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
|
||||||
}
|
|
||||||
return period, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
localNow := req.Now.In(req.Location)
|
|
||||||
endDate := localNow.AddDate(0, 0, 3)
|
|
||||||
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
|
|
||||||
return timeutil.Period{Start: localNow, End: end}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
localNow := req.Now.In(req.Location)
|
|
||||||
weekday := localNow.Weekday()
|
|
||||||
if weekday == time.Sunday {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
|
|
||||||
}
|
|
||||||
|
|
||||||
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
|
|
||||||
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
|
|
||||||
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
|
|
||||||
if weekday == time.Friday || weekday == time.Saturday {
|
|
||||||
friday := start.AddDate(0, 0, -1)
|
|
||||||
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
|
|
||||||
start = fridayEvening
|
|
||||||
if localNow.After(start) {
|
|
||||||
start = localNow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
|
|
||||||
return timeutil.Period{Start: start, End: end}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if req.StormStart.IsZero() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
|
|
||||||
}
|
|
||||||
if req.StormEnd.IsZero() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
|
|
||||||
}
|
|
||||||
if !req.StormEnd.After(req.StormStart) {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
|
||||||
}
|
|
||||||
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,70 +12,11 @@ type Registry struct {
|
|||||||
|
|
||||||
func DefaultRegistry() Registry {
|
func DefaultRegistry() Registry {
|
||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
{
|
dailyTodayDefinition(),
|
||||||
ID: DailyToday,
|
dailyTomorrowDefinition(),
|
||||||
Name: "Daily Report",
|
threeDayDefinition(),
|
||||||
PromptID: "weather.daily_report",
|
weekendDefinition(),
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
stormDefinition(),
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "daily.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTodayModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveDailyToday,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: DailyTomorrow,
|
|
||||||
Name: "Tomorrow Planning Brief",
|
|
||||||
PromptID: "weather.daily_report",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "tomorrow.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTomorrowModules(),
|
|
||||||
Evening: true,
|
|
||||||
resolve: resolveDailyTomorrow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: ThreeDay,
|
|
||||||
Name: "3-Day Outlook",
|
|
||||||
PromptID: "weather.three_day_outlook",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "three-day",
|
|
||||||
BatchOutputName: "three-day.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{ThreeDay},
|
|
||||||
Modules: threeDayModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveThreeDay,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: Weekend,
|
|
||||||
Name: "Weekend Outlook",
|
|
||||||
PromptID: "weather.weekend_outlook",
|
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
|
||||||
ArtifactGroup: "weekend",
|
|
||||||
BatchOutputName: "weekend.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{Weekend},
|
|
||||||
Modules: weekendModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveWeekend,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: Storm,
|
|
||||||
Name: "Storm Report",
|
|
||||||
PromptID: "weather.storm_report",
|
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
|
||||||
ArtifactGroup: "storm",
|
|
||||||
BatchOutputName: "storm.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{Storm},
|
|
||||||
Modules: stormModules(),
|
|
||||||
resolve: resolveStorm,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
registry := Registry{definitions: map[ID]Definition{}}
|
registry := Registry{definitions: map[ID]Definition{}}
|
||||||
for _, definition := range definitions {
|
for _, definition := range definitions {
|
||||||
@@ -101,63 +42,6 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
|
|||||||
return next, nil
|
return next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTodayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDailySummary,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
|
||||||
items := dailyTodayModules()
|
|
||||||
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func threeDayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moduleItems(ids ...module.ID) []module.ConfigItem {
|
func moduleItems(ids ...module.ID) []module.ConfigItem {
|
||||||
items := make([]module.ConfigItem, 0, len(ids))
|
items := make([]module.ConfigItem, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
|||||||
67
internal/report/storm_report.go
Normal file
67
internal/report/storm_report.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stormDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Storm,
|
||||||
|
Name: "Storm Report",
|
||||||
|
PromptID: "weather.storm_report",
|
||||||
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
|
ArtifactGroup: "storm",
|
||||||
|
BatchOutputName: "storm.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Storm},
|
||||||
|
Modules: stormModules(),
|
||||||
|
resolve: resolveStorm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stormModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
|
||||||
|
if location == nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
startTime, err := timeutil.ParseStormTime(start, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
endTime, err := timeutil.ParseStormTime(end, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
period := timeutil.Period{Start: startTime, End: endTime}
|
||||||
|
if !period.IsValid() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return period, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if req.StormStart.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
|
||||||
|
}
|
||||||
|
if req.StormEnd.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
|
||||||
|
}
|
||||||
|
if !req.StormEnd.After(req.StormStart) {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
|
||||||
|
}
|
||||||
44
internal/report/three_day_report.go
Normal file
44
internal/report/three_day_report.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func threeDayDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: ThreeDay,
|
||||||
|
Name: "3-Day Outlook",
|
||||||
|
PromptID: "weather.three_day_outlook",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "three-day",
|
||||||
|
BatchOutputName: "three-day.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
Modules: threeDayModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveThreeDay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func threeDayModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
endDate := localNow.AddDate(0, 0, 3)
|
||||||
|
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
return timeutil.Period{Start: localNow, End: end}, nil
|
||||||
|
}
|
||||||
60
internal/report/weekend_report.go
Normal file
60
internal/report/weekend_report.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func weekendDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Weekend,
|
||||||
|
Name: "Weekend Outlook",
|
||||||
|
PromptID: "weather.weekend_outlook",
|
||||||
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
|
ArtifactGroup: "weekend",
|
||||||
|
BatchOutputName: "weekend.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Weekend},
|
||||||
|
Modules: weekendModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveWeekend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekendModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
weekday := localNow.Weekday()
|
||||||
|
if weekday == time.Sunday {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
|
||||||
|
}
|
||||||
|
|
||||||
|
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
|
||||||
|
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
|
||||||
|
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
if weekday == time.Friday || weekday == time.Saturday {
|
||||||
|
friday := start.AddDate(0, 0, -1)
|
||||||
|
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
|
||||||
|
start = fridayEvening
|
||||||
|
if localNow.After(start) {
|
||||||
|
start = localNow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
|
||||||
|
return timeutil.Period{Start: start, End: end}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user