Add an hourly forecast module and adjust the daily summary for consistency
This commit is contained in:
@@ -201,6 +201,7 @@ reports:
|
|||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- hourly_forecast
|
||||||
```
|
```
|
||||||
|
|
||||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ Outputs:
|
|||||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||||
supported reports, fact requirements, missing-data behavior, and builder
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `alert_digest`,
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
`area_forecast_discussion`, and `weather_story`
|
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ The registry recognizes these IDs:
|
|||||||
- `metadata`
|
- `metadata`
|
||||||
- `current_conditions`
|
- `current_conditions`
|
||||||
- `narrative_forecast`
|
- `narrative_forecast`
|
||||||
|
- `hourly_forecast`
|
||||||
- `derived_daily_summary`
|
- `derived_daily_summary`
|
||||||
- `derived_daypart_summaries`
|
- `derived_daypart_summaries`
|
||||||
- `precip_timing`
|
- `precip_timing`
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ briefing:
|
|||||||
metadata: {}
|
metadata: {}
|
||||||
current_conditions: {}
|
current_conditions: {}
|
||||||
narrative_forecast: {}
|
narrative_forecast: {}
|
||||||
|
hourly_forecast: {}
|
||||||
recent_changes:
|
recent_changes:
|
||||||
items: []
|
items: []
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -86,3 +86,4 @@ reports:
|
|||||||
- long_term
|
- long_term
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
|
- hourly_forecast
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
!strings.Contains(string(data), "recent_changes:") ||
|
!strings.Contains(string(data), "recent_changes:") ||
|
||||||
!strings.Contains(string(data), "current_conditions:") ||
|
!strings.Contains(string(data), "current_conditions:") ||
|
||||||
!strings.Contains(string(data), "narrative_forecast:") ||
|
!strings.Contains(string(data), "narrative_forecast:") ||
|
||||||
|
!strings.Contains(string(data), "hourly_forecast:") ||
|
||||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
@@ -171,8 +172,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
currentIndex := strings.Index(string(data), " current_conditions:")
|
currentIndex := strings.Index(string(data), " current_conditions:")
|
||||||
narrativeIndex := strings.Index(string(data), " narrative_forecast:")
|
narrativeIndex := strings.Index(string(data), " narrative_forecast:")
|
||||||
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
|
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
|
||||||
if currentIndex < 0 || narrativeIndex < 0 || summaryIndex < 0 || !(currentIndex < narrativeIndex && narrativeIndex < summaryIndex) {
|
outdoorIndex := strings.Index(string(data), " outdoor_windows:")
|
||||||
t.Fatalf("data package stanza order is wrong, want current_conditions then narrative_forecast then derived_daily_summary:\n%s", string(data))
|
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
||||||
|
if currentIndex < 0 || narrativeIndex < 0 || summaryIndex < 0 || outdoorIndex < 0 || hourlyIndex < 0 || !(currentIndex < narrativeIndex && narrativeIndex < summaryIndex && outdoorIndex < hourlyIndex) {
|
||||||
|
t.Fatalf("data package stanza order is wrong, want current_conditions then narrative_forecast then derived_daily_summary and final hourly_forecast:\n%s", string(data))
|
||||||
}
|
}
|
||||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,6 +195,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
||||||
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
||||||
}
|
}
|
||||||
|
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
|
||||||
|
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
||||||
|
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
||||||
|
}
|
||||||
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
||||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
|||||||
{id: module.Metadata, stanza: "metadata"},
|
{id: module.Metadata, stanza: "metadata"},
|
||||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||||
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||||
|
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||||
{id: module.WeatherStory, stanza: "weather_story"},
|
{id: module.WeatherStory, stanza: "weather_story"},
|
||||||
@@ -45,6 +46,51 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||||
|
}
|
||||||
|
period := value.Periods[0]
|
||||||
|
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||||
|
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -189,11 +235,13 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
|||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
ctx.Collected.Current = nil
|
ctx.Collected.Current = nil
|
||||||
ctx.Collected.Narrative = nil
|
ctx.Collected.Narrative = nil
|
||||||
|
ctx.Collected.Hourly = nil
|
||||||
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||||
|
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||||
ctx.Collected.Discussion = nil
|
ctx.Collected.Discussion = nil
|
||||||
ctx.Collected.WeatherStory = nil
|
ctx.Collected.WeatherStory = nil
|
||||||
|
|
||||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||||
@@ -276,6 +324,10 @@ func testModuleContext() ModuleContext {
|
|||||||
narrativePop := 60.0
|
narrativePop := 60.0
|
||||||
narrativeWind := 12.0
|
narrativeWind := 12.0
|
||||||
narrativeWindDirection := 45.0
|
narrativeWindDirection := 45.0
|
||||||
|
hourlyTempF := 76.0
|
||||||
|
hourlyPop := 70.0
|
||||||
|
hourlyHumidity := 66.0
|
||||||
|
hourlyWindMph := 14.0
|
||||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
return ModuleContext{
|
return ModuleContext{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -309,6 +361,30 @@ func testModuleContext() ModuleContext {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Hourly: &weatherdata.ForecastRun{
|
||||||
|
LocationID: "test-grid",
|
||||||
|
LocationName: "Testville",
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Product: "hourly",
|
||||||
|
Periods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||||
|
TextDescription: "Tomorrow showers.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||||
}},
|
}},
|
||||||
@@ -341,6 +417,18 @@ func testModuleContext() ModuleContext {
|
|||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
Derived: facts.DerivedFacts{
|
Derived: facts.DerivedFacts{
|
||||||
|
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
},
|
||||||
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||||
{
|
{
|
||||||
Name: "Today",
|
Name: "Today",
|
||||||
|
|||||||
@@ -85,10 +85,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
|||||||
if maxGust != nil {
|
if maxGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||||
}
|
}
|
||||||
value.DominantConditions = narrativeConditions(summary.NarrativePeriods)
|
|
||||||
if len(value.DominantConditions) == 0 {
|
|
||||||
value.DominantConditions = sortedSet(conditions)
|
value.DominantConditions = sortedSet(conditions)
|
||||||
}
|
|
||||||
value.Hazards = sortedSet(hazards)
|
value.Hazards = sortedSet(hazards)
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -144,22 +141,6 @@ func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.T
|
|||||||
return maxPop
|
return maxPop
|
||||||
}
|
}
|
||||||
|
|
||||||
func narrativeConditions(periods []weatherdata.ForecastPeriod) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
var out []string
|
|
||||||
for _, period := range periods {
|
|
||||||
if period.TextDescription == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[period.TextDescription]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[period.TextDescription] = struct{}{}
|
|
||||||
out = append(out, period.TextDescription)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||||
if maxPop == nil || maxPop.Value <= 0 {
|
if maxPop == nil || maxPop.Value <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||||
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||||
}
|
}
|
||||||
if strings.Join(value.DominantConditions, "|") != "Morning storms, then partly sunny.|Clouds linger tonight." {
|
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||||
t.Fatalf("DominantConditions = %#v, want ordered narrative conditions", value.DominantConditions)
|
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||||
}
|
}
|
||||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
|
|||||||
123
internal/briefing/hourly_forecast_module.go
Normal file
123
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HourlyForecastModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPeriod struct {
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
hourly := ctx.Collected.Hourly
|
||||||
|
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := HourlyForecastModule{
|
||||||
|
Product: hourly.Product,
|
||||||
|
IssuedAt: hourly.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||||
|
SourceLocation: hourly.LocationName,
|
||||||
|
SourceLocationID: hourly.LocationID,
|
||||||
|
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod) []HourlyForecastPeriod {
|
||||||
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
out = append(out, HourlyForecastPeriod{
|
||||||
|
StartTime: period.StartTime,
|
||||||
|
EndTime: period.EndTime,
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v HourlyForecastModule) isEmpty() bool {
|
||||||
|
return v.Product == "" &&
|
||||||
|
v.IssuedAt.IsZero() &&
|
||||||
|
v.UpdatedAt == nil &&
|
||||||
|
v.SourceLocation == "" &&
|
||||||
|
v.SourceLocationID == "" &&
|
||||||
|
len(v.Periods) == 0
|
||||||
|
}
|
||||||
@@ -154,6 +154,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
|||||||
return ctx.Collected.Current != nil
|
return ctx.Collected.Current != nil
|
||||||
case module.CollectedNarrativeForecast:
|
case module.CollectedNarrativeForecast:
|
||||||
return ctx.Collected.Narrative != nil
|
return ctx.Collected.Narrative != nil
|
||||||
|
case module.CollectedHourlyForecast:
|
||||||
|
return ctx.Collected.Hourly != nil
|
||||||
case module.CollectedAlerts:
|
case module.CollectedAlerts:
|
||||||
return ctx.Collected.Alerts != nil
|
return ctx.Collected.Alerts != nil
|
||||||
case module.CollectedDiscussion:
|
case module.CollectedDiscussion:
|
||||||
@@ -274,6 +276,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: "hourly_forecast",
|
||||||
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildHourlyForecastModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ func copyBool(value *bool) *bool {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyInt(value *int) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
func copyFloat(value *float64) *float64 {
|
func copyFloat(value *float64) *float64 {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
Metadata ID = "metadata"
|
Metadata ID = "metadata"
|
||||||
CurrentConditions ID = "current_conditions"
|
CurrentConditions ID = "current_conditions"
|
||||||
NarrativeForecast ID = "narrative_forecast"
|
NarrativeForecast ID = "narrative_forecast"
|
||||||
|
HourlyForecast ID = "hourly_forecast"
|
||||||
DerivedDailySummary ID = "derived_daily_summary"
|
DerivedDailySummary ID = "derived_daily_summary"
|
||||||
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
||||||
PrecipTiming ID = "precip_timing"
|
PrecipTiming ID = "precip_timing"
|
||||||
@@ -106,6 +107,7 @@ type FactRequirement string
|
|||||||
const (
|
const (
|
||||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||||
|
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
@@ -130,6 +132,7 @@ const (
|
|||||||
type MetadataOptions struct{}
|
type MetadataOptions struct{}
|
||||||
type CurrentConditionsOptions struct{}
|
type CurrentConditionsOptions struct{}
|
||||||
type NarrativeForecastOptions struct{}
|
type NarrativeForecastOptions struct{}
|
||||||
|
type HourlyForecastOptions struct{}
|
||||||
type DerivedDailySummaryOptions struct{}
|
type DerivedDailySummaryOptions struct{}
|
||||||
type DerivedDaypartSummariesOptions struct{}
|
type DerivedDaypartSummariesOptions struct{}
|
||||||
type PrecipTimingOptions struct{}
|
type PrecipTimingOptions struct{}
|
||||||
|
|||||||
@@ -49,13 +49,25 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
func dailyTomorrowModules() []module.ConfigItem {
|
||||||
items := dailyTodayModules()
|
return moduleItems(
|
||||||
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
module.Metadata,
|
||||||
return items
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
module.HourlyForecast,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -286,6 +287,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user