Files
weatherreporter/internal/briefing/package.go

270 lines
8.3 KiB
Go

// Package briefing builds report-specific structured briefing packages.
package briefing
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct {
Metadata Metadata `json:"metadata"`
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Region string `json:"region,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type CurrentConditionsContext struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
}
type SourceMetadata struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
}
type AlertStatus struct {
Checked bool `json:"checked"`
ActiveCount int `json:"activeCount"`
RelevantCount int `json:"relevantCount"`
Missing bool `json:"missing,omitempty"`
}
type BuildContext struct {
Resolved report.Resolved
Bundle *forecast.Bundle
Units string
Timezone string
Location *LocationContext
}
func BuildMetadata(ctx BuildContext) Metadata {
metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{
SchemaVersion: SchemaVersion,
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),
SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
}
}
func buildPackage(ctx BuildContext) Package {
return Package{
Metadata: BuildMetadata(ctx),
CurrentConditions: currentConditions(ctx.Bundle),
}
}
func copyLocation(location *LocationContext) *LocationContext {
if location == nil {
return nil
}
copied := *location
return &copied
}
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil {
return nil
}
current := bundle.Current
context := CurrentConditionsContext{
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 context.ConditionText == "" &&
context.IsDay == nil &&
context.TemperatureC == nil &&
context.TemperatureF == nil &&
context.ApparentTemperatureC == nil &&
context.ApparentTemperatureF == nil &&
context.DewpointC == nil &&
context.DewpointF == nil &&
context.RelativeHumidityPercent == nil &&
context.WindSpeedKmh == nil &&
context.WindSpeedMph == nil &&
context.WindDirectionDegrees == nil {
return nil
}
return &context
}
func copyBool(value *bool) *bool {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil
}
copied := *value
return &copied
}
func Save(path string, pkg Package) error {
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save briefing package: %w", err)
}
return nil
}
func sourceLocation(bundle *forecast.Bundle) (string, string) {
if bundle == nil {
return "", ""
}
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
if run == nil {
continue
}
if run.LocationID != "" || run.LocationName != "" {
return run.LocationID, run.LocationName
}
}
return "", ""
}
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
if bundle == nil {
return nil
}
out := make([]SourceMetadata, 0, len(bundle.Sources))
for _, source := range bundle.Sources {
out = append(out, SourceMetadata{
Name: source.Name,
Endpoint: source.Endpoint,
FetchedAt: source.FetchedAt,
IssuedAt: source.IssuedAt,
UpdatedAt: source.UpdatedAt,
DataSHA256: source.DataSHA256,
Missing: source.Missing,
Warnings: source.Warnings,
})
}
return out
}
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
if bundle == nil {
return nil
}
return bundle.Warnings
}
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
status := &AlertStatus{}
if bundle.Alerts != nil {
status.Checked = true
status.ActiveCount = len(bundle.Alerts.Alerts)
}
for _, source := range bundle.Sources {
if source.Name == "alerts" && source.Missing {
status.Missing = true
break
}
}
if !status.Checked && !status.Missing {
return nil
}
return status
}
func setRelevantAlertCount(metadata *Metadata, count int) {
if metadata.Alerts == nil {
if count == 0 {
return
}
metadata.Alerts = &AlertStatus{}
}
metadata.Alerts.RelevantCount = count
}
func variantForReport(id report.ID) string {
switch id {
case report.DailyToday:
return "today"
case report.DailyTomorrow:
return "tomorrow"
default:
return ""
}
}