Retire unused module and forecast compatibility exports

This commit is contained in:
2026-08-13 04:15:42 +00:00
parent c3ebf06bd5
commit cd7b9aef2b
11 changed files with 36 additions and 75 deletions

View File

@@ -65,7 +65,7 @@ implementation rules.
## Verification and invariants ## Verification and invariants
Focused tests cover local civil days, clipped periods, daypart resolution, Focused tests cover local civil days, clipped periods, daypart resolution,
summary metrics, precipitation windows, threshold helpers, and alert overlap: summary metrics, precipitation-window threshold behavior, and alert overlap:
```sh ```sh
go test ./internal/forecast ./internal/timeutil go test ./internal/forecast ./internal/timeutil

View File

@@ -1,6 +1,6 @@
# Module Contract Internals # Module Contract Internals
`internal/module` defines the stable envelope between report composition, `internal/module` defines the envelope between report composition,
module builders, in-memory snapshots, templates, and prompt packages. It module builders, in-memory snapshots, templates, and prompt packages. It
does not define a report, execute a builder, or choose prompt-export policy; does not define a report, execute a builder, or choose prompt-export policy;
those responsibilities belong to [report registry](report-registry.md) and those responsibilities belong to [report registry](report-registry.md) and
@@ -13,8 +13,8 @@ Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
otherwise the rich value. This permits custom prompt exports without shrinking otherwise the rich value. This permits custom prompt exports without shrinking
the template value. the template value.
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and `NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
validates it. Its JSON representation contains IDs, stanza names, and rich values only; representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich `PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
stanza into a caller-supplied type, reporting a missing stanza separately from stanza into a caller-supplied type, reporting a missing stanza separately from
a decoding error. a decoding error.

View File

@@ -137,7 +137,7 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)}, {StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")}, {StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
} }
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold) value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", 20)
if len(value) != 3 { if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value)) t.Fatalf("periods length = %d, want 3", len(value))
} }

View File

@@ -164,7 +164,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone) value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold) value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, hourlyForecastPrecipMentionProbabilityThreshold)
} }
if daypart.PeakWindGust != nil { if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)

View File

@@ -147,7 +147,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
t.Fatalf("BuildModule(rainy) error = %v", err) t.Fatalf("BuildModule(rainy) error = %v", err)
} }
rainy := moduleValue[PrecipTimingModule](t, output) rainy := moduleValue[PrecipTimingModule](t, output)
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned { if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != 40 || !rainy.ThunderMentioned {
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy) t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
} }
if len(rainy.PrecipitationWindows) != 2 { if len(rainy.PrecipitationWindows) != 2 {
@@ -266,7 +266,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
value := precipTimingValue(forecast.PrecipTiming{ value := precipTimingValue(forecast.PrecipTiming{
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold, ProbabilityThreshold: 40,
PrecipitationWindows: []forecast.PrecipitationWindow{ PrecipitationWindows: []forecast.PrecipitationWindow{
{ {
Start: now, Start: now,
@@ -274,7 +274,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
Value: tt.maxPop, Value: tt.maxPop,
Time: now, Time: now,
}, },
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold, ProbabilityThreshold: 40,
TextDescriptions: tt.descriptions, TextDescriptions: tt.descriptions,
}, },
}, },

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20 const hourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct { type HourlyForecastModule struct {
Product string `json:"product,omitempty"` Product string `json:"product,omitempty"`
@@ -184,7 +184,7 @@ func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecas
} }
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold) return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, hourlyForecastPrecipMentionProbabilityThreshold)
} }
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod { func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {

View File

@@ -47,7 +47,7 @@ type TimedValue struct {
Time time.Time `json:"time"` Time time.Time `json:"time"`
} }
const DefaultPrecipWindowProbabilityThreshold = 40 const precipWindowProbabilityThreshold = 40
type Indicators struct { type Indicators struct {
Snow bool `json:"snow,omitempty"` Snow bool `json:"snow,omitempty"`
@@ -86,7 +86,7 @@ type PrecipitationWindow struct {
} }
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming { func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold) return buildPrecipTimingWithThreshold(periods, precipWindowProbabilityThreshold)
} }
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming { func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {

View File

@@ -484,8 +484,8 @@ func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 { if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability) t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
} }
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold { if timing.ProbabilityThreshold != 40 {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold) t.Fatalf("ProbabilityThreshold = %v, want 40", timing.ProbabilityThreshold)
} }
if len(timing.PrecipitationWindows) != 2 { if len(timing.PrecipitationWindows) != 2 {
t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows) t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows)
@@ -567,26 +567,14 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned { if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned {
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing) t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
} }
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold { if timing.ProbabilityThreshold != 40 {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold) t.Fatalf("ProbabilityThreshold = %v, want 40", timing.ProbabilityThreshold)
} }
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 { if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability) t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
} }
} }
func TestThresholdHelpers(t *testing.T) {
if !DifferenceAtLeast(50, 56, 5) {
t.Fatal("DifferenceAtLeast = false, want true")
}
if !CrossesAtOrAbove(29, 32, 32) {
t.Fatal("CrossesAtOrAbove = false, want true")
}
if !CrossesBelow(35, 31, 32) {
t.Fatal("CrossesBelow = false, want true")
}
}
func testBundle(location *time.Location) *weatherdata.Bundle { func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{

View File

@@ -1,20 +0,0 @@
package forecast
func DifferenceAtLeast(previous float64, current float64, threshold float64) bool {
return abs(current-previous) >= threshold
}
func CrossesAtOrAbove(previous float64, current float64, threshold float64) bool {
return previous < threshold && current >= threshold
}
func CrossesBelow(previous float64, current float64, threshold float64) bool {
return previous >= threshold && current < threshold
}
func abs(value float64) float64 {
if value < 0 {
return -value
}
return value
}

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
) )
const SnapshotSchemaVersion = "weatherreporter.modules.v1" const snapshotSchemaVersion = "weatherreporter.modules.v1"
type ID string type ID string
@@ -55,16 +55,16 @@ func (o Output) DataPackageValue() any {
func NewSnapshot(outputs []Output) (Snapshot, error) { func NewSnapshot(outputs []Output) (Snapshot, error) {
snapshot := Snapshot{ snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion, SchemaVersion: snapshotSchemaVersion,
Outputs: append([]Output(nil), outputs...), Outputs: append([]Output(nil), outputs...),
} }
if err := snapshot.Validate(); err != nil { if err := snapshot.validate(); err != nil {
return Snapshot{}, err return Snapshot{}, err
} }
return snapshot, nil return snapshot, nil
} }
func (s Snapshot) Validate() error { func (s Snapshot) validate() error {
if s.SchemaVersion == "" { if s.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required") return fmt.Errorf("schemaVersion is required")
} }
@@ -89,29 +89,22 @@ func (s Snapshot) Validate() error {
return nil return nil
} }
func (s Snapshot) LookupStanza(name string) (Output, bool) {
for _, output := range s.Outputs {
if output.StanzaName == name {
return output, true
}
}
return Output{}, false
}
func StanzaValue[T any](s Snapshot, name string) (T, bool, error) { func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
var zero T var zero T
output, ok := s.LookupStanza(name) for _, output := range s.Outputs {
if !ok { if output.StanzaName != name {
return zero, false, nil continue
}
data, err := json.Marshal(output.Value)
if err != nil {
return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err)
}
if err := json.Unmarshal(data, &zero); err != nil {
return zero, true, fmt.Errorf("decode stanza %q: %w", name, err)
}
return zero, true, nil
} }
data, err := json.Marshal(output.Value) return zero, false, nil
if err != nil {
return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err)
}
if err := json.Unmarshal(data, &zero); err != nil {
return zero, true, fmt.Errorf("decode stanza %q: %w", name, err)
}
return zero, true, nil
} }
type MissingDataBehavior string type MissingDataBehavior string

View File

@@ -24,8 +24,8 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewSnapshot() error = %v", err) t.Fatalf("NewSnapshot() error = %v", err)
} }
if snapshot.SchemaVersion != SnapshotSchemaVersion { if snapshot.SchemaVersion != "weatherreporter.modules.v1" {
t.Fatalf("SchemaVersion = %q, want %q", snapshot.SchemaVersion, SnapshotSchemaVersion) t.Fatalf("SchemaVersion = %q, want weatherreporter.modules.v1", snapshot.SchemaVersion)
} }
if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest { if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest {
t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs) t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs)