Define briefing fact requirement vocabulary

This commit is contained in:
2026-08-13 03:59:09 +00:00
parent 965f16d7a4
commit 2c4c0bbd90
5 changed files with 146 additions and 73 deletions

View File

@@ -0,0 +1,54 @@
package briefing
type factRequirementCategory string
const (
collectedFactRequirement factRequirementCategory = "collected"
derivedFactRequirement factRequirementCategory = "derived"
)
type factRequirement struct {
identity string
category factRequirementCategory
}
func (r factRequirement) String() string {
return r.identity
}
var (
currentConditionsRequirement = factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement}
narrativeForecastRequirement = factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement}
hourlyForecastRequirement = factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement}
alertsRequirement = factRequirement{identity: "collected.alerts", category: collectedFactRequirement}
discussionRequirement = factRequirement{identity: "collected.discussion", category: collectedFactRequirement}
weatherStoryRequirement = factRequirement{identity: "collected.weather_story", category: collectedFactRequirement}
spcOutlooksRequirement = factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement}
sourceMetadataRequirement = factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement}
hourlyPeriodsRequirement = factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement}
narrativePeriodsRequirement = factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement}
alertOverlapsRequirement = factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement}
dailySummariesRequirement = factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement}
daypartSummariesRequirement = factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement}
precipTimingRequirement = factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement}
spcDerivedOutlooksRequirement = factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement}
)
var factRequirementVocabulary = []factRequirement{
currentConditionsRequirement,
narrativeForecastRequirement,
hourlyForecastRequirement,
alertsRequirement,
discussionRequirement,
weatherStoryRequirement,
spcOutlooksRequirement,
sourceMetadataRequirement,
hourlyPeriodsRequirement,
narrativePeriodsRequirement,
alertOverlapsRequirement,
dailySummariesRequirement,
daypartSummariesRequirement,
precipTimingRequirement,
spcDerivedOutlooksRequirement,
}

View File

@@ -28,8 +28,8 @@ type ModuleDefinition struct {
ID module.ID
StanzaName string
DefaultOptions any
RequiredCollected []module.FactRequirement
RequiredDerived []module.FactRequirement
RequiredCollected []factRequirement
RequiredDerived []factRequirement
SupportedReports []report.ID
MissingData module.MissingDataBehavior
AllowDuplicate bool
@@ -154,55 +154,55 @@ func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []strin
var missing []string
for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
missing = append(missing, requirement.String())
}
}
for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
missing = append(missing, requirement.String())
}
}
return missing
}
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
func collectedFactAvailable(requirement factRequirement, ctx ModuleContext) bool {
switch requirement {
case module.CollectedCurrentConditions:
case currentConditionsRequirement:
return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
case narrativeForecastRequirement:
return ctx.Collected.Narrative != nil
case module.CollectedHourlyForecast:
case hourlyForecastRequirement:
return ctx.Collected.Hourly != nil
case module.CollectedAlerts:
case alertsRequirement:
return ctx.Collected.Alerts != nil
case module.CollectedDiscussion:
case discussionRequirement:
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
case weatherStoryRequirement:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
case spcOutlooksRequirement:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
case sourceMetadataRequirement:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
}
}
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
func derivedFactAvailable(requirement factRequirement, ctx ModuleContext) bool {
switch requirement {
case module.RequiresDerivedHourlyPeriods:
case hourlyPeriodsRequirement:
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
case module.RequiresDerivedNarrativePeriods:
case narrativePeriodsRequirement:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case module.RequiresDerivedAlertOverlaps:
case alertOverlapsRequirement:
return true
case module.RequiresDerivedDailySummaries:
case dailySummariesRequirement:
return len(ctx.Derived.DailySummaries) > 0
case module.RequiresDerivedDaypartSummaries:
case daypartSummariesRequirement:
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
case precipTimingRequirement:
return true
case module.RequiresDerivedSPCConvectiveOutlooks:
case spcDerivedOutlooksRequirement:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
@@ -291,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
RequiredCollected: []factRequirement{sourceMetadataRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule,
@@ -300,7 +300,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.CurrentConditions,
StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
RequiredCollected: []factRequirement{currentConditionsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
@@ -310,8 +310,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.NarrativeForecast,
StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
RequiredCollected: []factRequirement{narrativeForecastRequirement},
RequiredDerived: []factRequirement{narrativePeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
@@ -320,8 +320,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
RequiredCollected: []factRequirement{hourlyForecastRequirement},
RequiredDerived: []factRequirement{hourlyPeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
@@ -331,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
RequiredDerived: []factRequirement{dailySummariesRequirement, precipTimingRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule,
@@ -340,7 +340,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
@@ -350,7 +350,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
RequiredDerived: []factRequirement{precipTimingRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
@@ -359,8 +359,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AlertDigest,
StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
RequiredCollected: []factRequirement{alertsRequirement},
RequiredDerived: []factRequirement{alertOverlapsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
@@ -369,8 +369,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
RequiredCollected: []factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
@@ -379,7 +379,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
RequiredCollected: []factRequirement{discussionRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
@@ -388,8 +388,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
RequiredCollected: []factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
@@ -398,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.WeatherStory,
StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
RequiredCollected: []factRequirement{weatherStoryRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule,
@@ -407,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.OutdoorWindows,
StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule,
@@ -416,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
@@ -425,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
@@ -434,7 +434,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,

View File

@@ -30,6 +30,52 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
}
}
func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
vocabulary := make(map[string]factRequirement, len(factRequirementVocabulary))
for _, requirement := range factRequirementVocabulary {
if requirement.identity == "" {
t.Fatal("fact requirement identity is empty")
}
if requirement.category != collectedFactRequirement && requirement.category != derivedFactRequirement {
t.Fatalf("fact requirement %q category = %q, want collected or derived", requirement.identity, requirement.category)
}
if _, exists := vocabulary[requirement.identity]; exists {
t.Fatalf("duplicate fact requirement %q", requirement.identity)
}
vocabulary[requirement.identity] = requirement
}
used := map[factRequirement]struct{}{}
for _, definition := range defaultModuleDefinitions() {
for _, requirement := range definition.RequiredCollected {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement)
}
for _, requirement := range definition.RequiredDerived {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, derivedFactRequirement)
}
}
for _, requirement := range factRequirementVocabulary {
if _, ok := used[requirement]; !ok {
t.Fatalf("fact requirement %q is not used by a module definition", requirement.identity)
}
}
}
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]factRequirement, used map[factRequirement]struct{}, moduleID module.ID, requirement factRequirement, want factRequirementCategory) {
t.Helper()
descriptor, ok := vocabulary[requirement.identity]
if !ok {
t.Fatalf("module %q uses unknown fact requirement %q", moduleID, requirement.identity)
}
if descriptor != requirement {
t.Fatalf("module %q requirement %q does not use the vocabulary descriptor", moduleID, requirement.identity)
}
if requirement.category != want {
t.Fatalf("module %q requirement %q category = %q, want %q", moduleID, requirement.identity, requirement.category, want)
}
used[requirement] = struct{}{}
}
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
@@ -467,24 +513,24 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
if collectedFactAvailable(spcOutlooksRequirement, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
if !collectedFactAvailable(spcOutlooksRequirement, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
if derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
if !derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
}
}

View File

@@ -114,26 +114,6 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
return zero, true, nil
}
type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
)
type MissingDataBehavior string
const (

View File

@@ -120,13 +120,6 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
}
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
}
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
}
_ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{}
}