Split weather data types from forecast derivation

This commit is contained in:
2026-06-09 20:16:47 +00:00
parent d8b417458b
commit 454f47b2b5
22 changed files with 234 additions and 223 deletions

View File

@@ -1,168 +0,0 @@
// Package forecast defines normalized weather data consumed by report builders.
package forecast
import (
"encoding/json"
"time"
)
type Bundle struct {
FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type Source struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
Query map[string]string `json:"query,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 []SourceWarning `json:"warnings,omitempty"`
}
type SourceWarning struct {
Source string `json:"source"`
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
Endpoint string `json:"endpoint,omitempty"`
CompletenessImpact string `json:"completenessImpact,omitempty"`
}
type Observation struct {
StationID string `json:"stationId,omitempty"`
StationName string `json:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp"`
ConditionCode *int `json:"conditionCode,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
PresentWeather []json.RawMessage `json:"presentWeather,omitempty"`
}
type Current struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,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"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
}
type ForecastRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
ElevationMeters *float64 `json:"elevationMeters,omitempty"`
ElevationFeet *float64 `json:"elevationFeet,omitempty"`
Periods []ForecastPeriod `json:"periods"`
}
type ForecastPeriod struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
ConditionCode *int `json:"conditionCode,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty"`
SnowfallDepthMM *float64 `json:"snowfallDepthMM,omitempty"`
SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
}
type AlertRun struct {
AsOf *time.Time `json:"asOf,omitempty"`
Alerts []json.RawMessage `json:"alerts,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type Discussion struct {
OfficeID string `json:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty"`
Product string `json:"product"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm *DiscussionSection `json:"shortTerm,omitempty"`
LongTerm *DiscussionSection `json:"longTerm,omitempty"`
}
type DiscussionSection struct {
Qualifier string `json:"qualifier,omitempty"`
Text string `json:"text,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
}
type WeatherStory struct {
OfficeID string `json:"officeId,omitempty"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AltText string `json:"altText,omitempty"`
Priority bool `json:"priority"`
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DaypartDefinition struct {
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
return windows, nil
}
func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period {
func PeriodForForecastPeriod(period weatherdata.ForecastPeriod) timeutil.Period {
return timeutil.Period{Start: period.StartTime, End: period.EndTime}
}

View File

@@ -8,32 +8,33 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DailySummary struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []Source `json:"sourceProvenance,omitempty"`
Date string `json:"date"`
Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
}
type DaypartSummary struct {
Name string `json:"name"`
Period timeutil.Period `json:"period"`
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"`
PeakWindGust *TimedValue `json:"peakWindGust,omitempty"`
DominantCondition string `json:"dominantCondition,omitempty"`
NotableConditions []string `json:"notableConditions,omitempty"`
Indicators Indicators `json:"indicators"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Name string `json:"name"`
Period timeutil.Period `json:"period"`
HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"`
PeakWindGust *TimedValue `json:"peakWindGust,omitempty"`
DominantCondition string `json:"dominantCondition,omitempty"`
NotableConditions []string `json:"notableConditions,omitempty"`
Indicators Indicators `json:"indicators"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
}
type Range struct {
@@ -64,7 +65,7 @@ type AlertOverlap struct {
Description string `json:"description,omitempty"`
}
func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
@@ -99,7 +100,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
return summary, nil
}
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required")
}
@@ -121,7 +122,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
return summaries, nil
}
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
@@ -155,11 +156,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
return summary, nil
}
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil {
return nil
}
var selected []ForecastPeriod
var selected []weatherdata.ForecastPeriod
for _, forecastPeriod := range run.Periods {
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
selected = append(selected, forecastPeriod)
@@ -171,21 +172,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
return selected
}
func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod {
func SelectNarrativePeriods(bundle *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod {
if bundle == nil || bundle.Narrative == nil {
return nil
}
return SelectHourlyPeriods(bundle.Narrative, period)
}
func SelectDiscussion(bundle *Bundle) *Discussion {
func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
if bundle == nil {
return nil
}
return bundle.Discussion
}
func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary {
func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary {
summary := DaypartSummary{
Name: name,
Period: period,
@@ -215,7 +216,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
return summary
}
func periodTemperatureValues(period ForecastPeriod) []*float64 {
func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
values := []*float64{}
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
@@ -297,7 +298,7 @@ func indicatorsForText(text string) Indicators {
}
}
func numericIndicators(period ForecastPeriod) Indicators {
func numericIndicators(period weatherdata.ForecastPeriod) Indicators {
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
indicators := Indicators{}
@@ -325,7 +326,7 @@ func mergeIndicators(left Indicators, right Indicators) Indicators {
}
}
func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap {
func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []AlertOverlap {
if alertRun == nil {
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if err != nil {
t.Fatalf("read fixture bundle: %v", err)
}
var bundle Bundle
var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode fixture bundle: %v", err)
}
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
@@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
@@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
location := time.UTC
_, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{
_, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
})
if err == nil {
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
@@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
alertRun := &AlertRun{Alerts: []json.RawMessage{raw}}
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
@@ -221,31 +222,31 @@ func TestThresholdHelpers(t *testing.T) {
}
}
func testBundle(location *time.Location) *Bundle {
return &Bundle{
Hourly: &ForecastRun{Periods: []ForecastPeriod{
func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
}},
Narrative: &ForecastRun{Periods: []ForecastPeriod{
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
}},
Alerts: &AlertRun{Alerts: []json.RawMessage{
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
}},
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []Source{{Name: "hourly"}},
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}},
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
}
}
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod {
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod {
startTime := mustParse(start).In(location)
endTime := mustParse(end).In(location)
temp := temperature
return ForecastPeriod{
return weatherdata.ForecastPeriod{
StartTime: startTime,
EndTime: endTime,
TextDescription: text,