11 Commits

35 changed files with 2324 additions and 94 deletions

View File

@@ -130,7 +130,9 @@ name the variable only; they should not contain the token value.
- `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy.
the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `scriptorium`
@@ -197,10 +199,13 @@ reports:
- metadata
- current_conditions
- narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
- hourly_forecast
```

View File

@@ -37,6 +37,10 @@ generation.
means the endpoint was checked and there are no current active alerts. The
adapter records a non-missing alerts source and an empty alert run.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy.
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
discussion requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`.
`format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
## SPC Convective Outlooks
The adapter fetches SPC convective outlook data from:
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
Run fields consumed by weatherreporter:
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
Outlook fields consumed:
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
Discussion fields consumed:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Endpoints Used
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior:
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a
missing-source condition.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity
For source payloads accepted into the bundle, including the explicit `null`
@@ -116,8 +180,8 @@ warnings list.
## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle
types in `internal/forecast/bundle.go`, including:
The adapter expects payload fields compatible with the internal weather data
bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values
- current condition values
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
- discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text,
and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -28,7 +28,9 @@ Outputs:
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story`
`alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning`
@@ -59,6 +61,17 @@ builders run. Configured `location` values are prompt context only; Weather API
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, severity rank, valid times, source URL, image URL, and
whether the outlook contains the configured location. It does not emit GeoJSON
geometry.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained outlook has
severity rank `3` or higher and matching discussion text exists.
## External Adapters Used
None directly.
@@ -86,6 +99,11 @@ return an error for invalid required inputs.
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests

View File

@@ -20,14 +20,23 @@ Inputs:
Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings
provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary
report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
@@ -55,6 +64,9 @@ and inspection.
derivation error for reports that require daily summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests
@@ -67,6 +79,8 @@ Inspect:
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -37,7 +37,9 @@ The registry recognizes these IDs:
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story`
- `outdoor_windows`
- `tomorrow_planning`
@@ -47,7 +49,9 @@ unknown or unimplemented module IDs fail validation instead of being skipped.
## Options
Most modules use an empty options struct. `area_forecast_discussion` accepts:
Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml
sections:
@@ -61,6 +65,28 @@ An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition
validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`severity_rank`, `valid_start`, `valid_end`, `issued_at`, `expires_at`,
`contains_location`, `source_url`, and `image_url`. It omits GeoJSON geometry.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period outlook has severity rank `3` or higher and matching discussion
text is available. Its output includes `included_because` and `discussions`;
each discussion may include `day`, `headline`, `summary`, `discussion`, and
`updated_at`.
## Boundaries
- This package owns module identifiers, config item envelopes, output

View File

@@ -41,6 +41,7 @@ briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
spc_convective_outlooks: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
@@ -49,6 +50,7 @@ briefing:
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
@@ -66,9 +68,13 @@ Within each category, stanza order follows the module snapshot output order.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products.
and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries

View File

@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one
configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and module builders. Module builders expose normalized current
conditions and weather story context when those sources are available.
`internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API and assembles the bundle consumed by forecast derivation
and module builders. Module builders expose normalized current conditions and
weather story context when those sources are available.
## Inputs And Outputs
@@ -21,7 +21,8 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings
records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers
## Boundaries
@@ -54,9 +55,19 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source.
represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted,
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests

View File

@@ -0,0 +1,591 @@
# SPC Convective Outlook Implementation Roadmap
## Purpose
This roadmap defines the concrete implementation sequence for
`docs/roadmap/outlook.md`. It is written for an LLM coding agent that will
implement the stages in order.
This is a future-work roadmap. Until a stage is implemented, non-roadmap docs
must not describe SPC convective outlook behavior as available.
## Source Feature Roadmap
Use `docs/roadmap/outlook.md` as the authoritative feature roadmap for intent,
target shape, and policy decisions. This implementation roadmap is the
step-by-step work plan. If the two documents conflict, update the feature
roadmap first when the target behavior changes, then update this implementation
plan.
## Locked Implementation Decisions
- Preserve existing CLI syntax, generated artifact paths, report IDs, prompt
IDs, distributor behavior, and Scriptorium invocation.
- Use source key and module IDs based on `spc_convective_outlooks`.
- Add two modules:
- `spc_convective_outlooks` in `applicable_risk_products`;
- `spc_convective_discussion` in `narrative_products`.
- Fetch the upstream Weather API source once in
`internal/adapters/weatherapi`; module builders must not fetch upstream data.
- Use a Weather API adapter constant for the path:
`/outlooks/convective`.
- Initial query parameters for the outlook endpoint are `format=json` and the
configured `tz`; do not send `precision`.
- Treat the source as optional under existing missing-source policy.
- Treat `data: null` as no latest run and therefore missing/unavailable
optional source data.
- Treat a non-null run with empty `outlooks` and `discussions` arrays as
checked, non-missing empty data.
- Preserve GeoJSON geometry in collected facts and persisted bundle artifacts,
but omit geometry from prompt-facing module output.
- Filter outlooks by overlap with the resolved report valid period in Go.
- Do not depend on `/outlooks/convective/active` or
`/outlooks/convective/location` for initial behavior.
- Include SPC discussion text only when at least one retained report-period
outlook has `severity_rank >= 3`.
- Define the discussion threshold as an internal constant, initially `3`, not a
config field.
## Stage 1: Weather Data Contract
Goal: add typed collected SPC outlook facts without changing fetching or prompt
output yet.
Files to inspect:
- `internal/weatherdata/bundle.go`
- `internal/facts/facts.go`
- `internal/module/module.go`
- `internal/briefing/modules.go`
- `internal/weatherdata` and `internal/facts` tests
Implementation:
- Add `ConvectiveOutlookRun` to `internal/weatherdata`.
- Add `ConvectiveOutlook` with fields matching the consumed Weather API
outlook fields:
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
- Store `geometry` as `json.RawMessage` or an equivalent JSON-preserving type;
do not introduce a GeoJSON dependency.
- Add `ConvectiveOutlookDiscussion` with:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
- Add `SPCConvectiveOutlooks *ConvectiveOutlookRun` to
`weatherdata.Bundle`.
- Add matching fields to `facts.CollectedFacts`, `BuildCollected`, and
`CollectedFacts.Bundle`.
- Add module constants in `internal/module`:
- `SPCConvectiveOutlooks ID = "spc_convective_outlooks"`
- `SPCConvectiveDiscussion ID = "spc_convective_discussion"`
- Add option structs:
- `SPCConvectiveOutlooksOptions struct{}`
- `SPCConvectiveDiscussionOptions struct{}`
- Add fact requirement constants:
- `CollectedSPCConvectiveOutlooks`
- `RequiresDerivedSPCConvectiveOutlooks`
- Wire the new collected requirement into
`briefing.collectedFactAvailable`.
Acceptance criteria:
- No Weather API request is added in this stage.
- No report default module list changes in this stage.
- No prompt package shape changes in this stage.
- Existing tests pass.
Suggested validation:
```bash
go test ./internal/weatherdata ./internal/facts ./internal/module ./internal/briefing
```
This stage is small enough for one implementation prompt.
## Stage 2: Weather API Adapter Fetch
Goal: fetch `/outlooks/convective`, decode it into the new weather data
contract, and preserve source provenance.
Files to inspect:
- `internal/adapters/weatherapi/client.go`
- `internal/adapters/weatherapi/client_test.go`
- `internal/adapters/weatherapi/testdata/`
- `docs/roadmap/outlook.md`
- upstream Weather API docs under the Convective Outlooks section
Implementation:
- Add a package-level adapter constant, for example:
`convectiveOutlooksEndpoint = "/outlooks/convective"`.
- Add source-name constant or narrowly scoped source key for
`spc_convective_outlooks`.
- Add fan-out fetch for the new optional source.
- Build the query as:
- `format=json`
- `tz=<configured weather_api.timezone>`
- Do not send `precision` to this endpoint.
- Do not send `units` to this endpoint. If existing query helpers add units by
default, add a route-specific option such as `omitUnits` so the final request
remains `format` plus `tz`.
- Decode the Weather API envelope and payload into
`weatherdata.ConvectiveOutlookRun`.
- Preserve `data: null` as optional missing/unavailable source data through
existing missing-source policy.
- Preserve a non-null run with empty arrays as checked non-missing source data.
- Record source provenance:
- source name `spc_convective_outlooks`;
- endpoint constant path;
- exact query parameters sent;
- fetched time;
- issued time from run `issuedAt` when present, otherwise `asOf`;
- source hash over compact raw `data` JSON;
- source warnings when missing-source policy emits them.
- Add `internal/adapters/weatherapi/testdata/convective_outlooks.json`.
Acceptance criteria:
- Complete fixture fetch includes `bundle.SPCConvectiveOutlooks`.
- Source record is present and non-missing when the payload has a non-null run.
- `data: null` follows optional missing-source policy.
- Non-null empty arrays do not produce a missing-source warning.
- Adapter tests assert request path and query, including absence of
`precision`.
- Existing hourly required-source behavior is unchanged.
Suggested validation:
```bash
go test ./internal/adapters/weatherapi ./internal/weatherdata ./internal/facts
```
This stage is small enough for one implementation prompt.
## Stage 3: Report-Period Outlook Filtering
Goal: derive report-scoped SPC outlook facts by valid-period overlap.
Files to inspect:
- `internal/facts/facts.go`
- `internal/forecast/derive.go`
- `internal/timeutil/periods.go`
- `internal/report/*_report.go`
- existing forecast/facts tests for period slicing
Implementation:
- Add a report-scoped derived value for retained outlooks and discussions.
Recommended shape:
- `DerivedFacts.SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook`
- `DerivedFacts.SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion`
- Add a small deterministic helper under `internal/facts`; this first
implementation is report-scoped selection of already-collected facts, not a
broader meteorological derivation.
- Select outlooks whose half-open valid interval overlaps the resolved report
valid period.
- Treat missing `severityRank` as lower than the discussion threshold, but do
not drop the outlook from the risk-product module solely because rank is
missing.
- Retain discussion records only for days represented by retained outlooks.
- Sort retained outlooks deterministically by:
- day;
- outlook type;
- severity rank descending when present;
- valid start;
- label;
- id.
- Sort retained discussions by day, then updated time when present.
- Keep empty retained slices distinct from a missing collected source.
Acceptance criteria:
- Daily Today, Daily Tomorrow, 3-Day, Weekend, and Storm valid periods select
expected outlooks by overlap.
- Tomorrow and multi-day reports do not depend on server-current active
filtering.
- Empty retained results are still available to modules as checked empty data
when the collected source exists.
Suggested validation:
```bash
go test ./internal/facts ./internal/forecast ./internal/report
```
This stage is small enough for one implementation prompt.
## Stage 4: Prompt Category Plumbing
Goal: prepare prompt-package category placement without registering
builderless modules.
Files to inspect:
- `internal/module/module.go`
- `internal/promptinput/package.go`
- `internal/promptinput/package_test.go`
Implementation:
- Add prompt input category mapping:
- `spc_convective_outlooks` -> `applicable_risk_products`;
- `spc_convective_discussion` -> `narrative_products`.
- Use synthetic module snapshots in tests if needed; do not add module
definitions to `defaultModuleDefinitions` until the real builders are added
in Stages 5 and 6.
Acceptance criteria:
- Prompt category tests prove both new stanzas route to the intended groups.
- The module registry still rejects unknown or builderless modules.
- No report default includes the new modules yet.
Suggested validation:
```bash
go test ./internal/module ./internal/promptinput
```
This stage is small enough for one implementation prompt.
## Stage 5: SPC Convective Outlooks Module
Goal: add the prompt-facing risk-product module.
Files to inspect:
- `internal/briefing/alert_digest_module.go`
- `internal/briefing/weather_story_module.go`
- `internal/briefing/module_format_helpers.go`
- `internal/briefing/base_modules_test.go`
- `docs/roadmap/outlook.md`
Implementation:
- Add `internal/briefing/spc_convective_outlooks_module.go`.
- Add the `SPCConvectiveOutlooks` module definition to
`defaultModuleDefinitions` in the same change as its real builder.
- Register stanza `spc_convective_outlooks`.
- Require:
- `CollectedSPCConvectiveOutlooks`;
- `RequiresDerivedSPCConvectiveOutlooks`.
- Use `MissingDataEmpty` so checked empty data can emit an explicit empty
risk-product stanza.
- Build from collected source metadata plus derived retained outlooks.
- Emit concise prompt-facing fields:
- `checked`;
- `as_of`;
- `issued_at`;
- `location_id`;
- `location_name`;
- `outlook_count`;
- `outlooks`.
- For each outlook, emit:
- `day`;
- `outlook_type`;
- `label`;
- `label_text`;
- `severity_rank`;
- `valid_start`;
- `valid_end`;
- `issued_at`;
- `expires_at`;
- `contains_location`;
- `source_url`;
- `image_url`.
- Use human-readable local time helpers consistent with current modules.
- Do not emit GeoJSON geometry.
- If the source was checked and no retained outlooks overlap the report
period, emit `checked: true`, `outlook_count: 0`, and an empty or omitted
`outlooks` list according to the existing YAML style for empty lists.
Acceptance criteria:
- Module output is deterministic and omits geometry.
- Checked empty data produces an explicit checked-empty stanza.
- Missing collected source follows registry missing-data behavior.
- Module tests cover populated, checked-empty, and missing cases.
Suggested validation:
```bash
go test ./internal/briefing ./internal/module ./internal/promptinput
```
This stage is small enough for one implementation prompt.
## Stage 6: SPC Convective Discussion Module
Goal: add optional SPC discussion narrative context with a severity threshold.
Files to inspect:
- `internal/briefing/area_forecast_discussion_module.go`
- `internal/briefing/weather_story_module.go`
- `internal/briefing/module_format_helpers.go`
- `internal/briefing/base_modules_test.go`
Implementation:
- Add `internal/briefing/spc_convective_discussion_module.go`.
- Add the `SPCConvectiveDiscussion` module definition to
`defaultModuleDefinitions` in the same change as its real builder.
- Register stanza `spc_convective_discussion`.
- Require:
- `CollectedSPCConvectiveOutlooks`;
- `RequiresDerivedSPCConvectiveOutlooks`.
- Use `MissingDataOmit` so unavailable or below-threshold discussion text is
omitted.
- Define a package-private constant near the module, for example:
`defaultSPCConvectiveDiscussionMinimumSeverityRank = 3`.
- Build from derived retained outlooks and discussions.
- Include discussion text only when at least one retained outlook has
`severityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank`.
- When the threshold is not met, return `nil` output so the stanza is omitted.
- When threshold is met, include discussions for retained outlook days with:
- `day`;
- `headline`;
- `summary`;
- `discussion`;
- `updated_at`.
- Include a concise reason field such as:
`included_because: "severity_rank >= 3"`.
Acceptance criteria:
- Slight Risk or higher retained outlooks include matching discussion records
when available.
- Lower-risk retained outlooks still appear in `spc_convective_outlooks` but
do not emit `spc_convective_discussion`.
- Missing discussion text omits the stanza without failing report generation.
- Tests cover threshold below, threshold equal, threshold above, and missing
discussion cases.
Suggested validation:
```bash
go test ./internal/briefing ./internal/promptinput
```
This stage is small enough for one implementation prompt.
## Stage 7: Report Composition And Config Examples
Goal: add the implemented modules to default report definitions and maintained
examples.
Files to inspect:
- `internal/report/daily_report.go`
- `internal/report/three_day_report.go`
- `internal/report/weekend_report.go`
- `internal/report/storm_report.go`
- `internal/config/reports.go`
- `internal/config/config_test.go`
- `examples/config.yml`
Implementation:
- Add `spc_convective_outlooks` to default report module lists for:
- Daily Today;
- Daily Tomorrow;
- 3-Day;
- Weekend;
- Storm.
- Place `spc_convective_outlooks` immediately after `alert_digest` when
`alert_digest` is present.
- Add `spc_convective_discussion` immediately after
`area_forecast_discussion` when `area_forecast_discussion` is present.
- Update maintained example config module overrides if they enumerate module
lists.
- Keep CLI syntax and config field names unchanged.
Acceptance criteria:
- All default report module compositions validate.
- Example config loads successfully.
- Config override tests can include both new module IDs.
- Existing report IDs, prompt IDs, output names, and valid-period behavior are
unchanged.
Suggested validation:
```bash
go test ./internal/report ./internal/config ./internal/briefing ./internal/app
```
This stage is small enough for one implementation prompt.
## Stage 8: App And Prompt Workflow Coverage
Goal: prove the end-to-end generated data package contains the new stanzas in
the intended categories when fixture data warrants them.
Files to inspect:
- `internal/app/app_test.go`
- `internal/promptinput/package_test.go`
- Weather API test server fixtures in `internal/adapters/weatherapi`
- `internal/state` artifact path behavior if tests inspect saved files
Implementation:
- Extend app-level Weather API fixtures to serve convective outlook data.
- Add or update workflow tests so saved YAML contains:
- `briefing.applicable_risk_products.spc_convective_outlooks`;
- `briefing.narrative_products.spc_convective_discussion` when severity rank
is at least `3`;
- no geometry in prompt-facing YAML.
- Add a workflow case where outlook data is checked but empty and the
risk-product stanza remains explicit.
- Add a workflow case where severity rank is below `3` and the discussion
stanza is omitted.
- Keep existing Recent Changes behavior unchanged unless a later roadmap adds
comparisons for SPC outlooks.
Acceptance criteria:
- Module snapshot and YAML data package remain deterministic.
- Prompt category grouping preserves module order within categories.
- No Scriptorium or distributor behavior changes are required.
Suggested validation:
```bash
go test ./internal/app ./internal/promptinput ./internal/state
```
This stage is small enough for one implementation prompt.
## Stage 9: Implemented Documentation
Goal: update non-roadmap docs after the feature is implemented.
Files to inspect and update:
- `docs/integrations/weatherapi.md`
- `docs/internal/weather-data.md`
- `docs/internal/facts.md`
- `docs/internal/module.md`
- `docs/internal/briefing.md`
- `docs/internal/prompt-input.md`
- `docs/config.md` if example module override behavior changes
- `examples/config.yml` if not already updated in Stage 7
Documentation requirements:
- Describe only the implemented SPC behavior outside `docs/roadmap/`.
- In the Weather API integration doc, include only the route, query, response
fields, and missing/empty semantics used by weatherreporter.
- In internal docs, distinguish:
- collected source facts and geometry/provenance;
- derived report-period filtering;
- prompt-facing module output that omits geometry.
- In prompt-input docs, list:
- `spc_convective_outlooks` under `applicable_risk_products`;
- `spc_convective_discussion` under `narrative_products`.
- Keep deferred route choices, geometry presentation, and user-configurable
threshold ideas under roadmap docs only.
Acceptance criteria:
- Non-roadmap docs do not describe deferred SPC behavior as current behavior.
- Maintained examples load.
- Documentation links and module ID lists are consistent with code.
Suggested validation:
```bash
go test ./internal/config
git diff --check
```
This stage is small enough for one implementation prompt.
## Stage 10: Final Validation
Goal: validate the complete feature and guard against regressions.
Run:
```bash
go test ./internal/adapters/weatherapi ./internal/weatherdata ./internal/facts ./internal/forecast ./internal/briefing ./internal/module ./internal/report ./internal/config ./internal/app ./internal/promptinput
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Manual review:
- Confirm `/outlooks/convective` is referenced through an adapter constant.
- Confirm Weather API outlook requests do not send `precision`.
- Confirm no module builder performs Weather API calls.
- Confirm prompt YAML omits GeoJSON geometry.
- Confirm checked-empty outlook data is not represented as missing data.
- Confirm SPC discussion text appears only at severity rank `3` or higher.
- Confirm public CLI syntax, output paths, distributor upload behavior, and
Scriptorium argv remain unchanged.
This stage is small enough for one implementation prompt.
## Deferred Work
Out of scope for the initial implementation:
- use of `/outlooks/convective/active`;
- use of `/outlooks/convective/location`;
- per-report Weather API filters such as `day` or `outlookType`;
- user-configurable SPC discussion severity threshold;
- prompt-facing GeoJSON geometry;
- polygon distance, area, map summaries, or rendered images;
- Mesoscale Discussions, watches, WPC outlooks, radar, QPF, or other risk
products;
- Recent Changes comparisons for SPC outlook changes;
- module-owned upstream fetching.
## Global Validation Checklist
Before considering the feature complete:
- all focused package tests pass;
- `go test ./...` passes;
- `go run ./cmd/weatherreporter --help` still matches documented CLI syntax;
- `git diff --check` passes;
- examples load through config tests;
- non-roadmap docs describe only implemented behavior;
- no secret values or large raw geometry are introduced into prompt-facing
output;
- source provenance and warnings remain inspectable through existing metadata
and state artifacts.
## Open Questions
No question blocks implementation.
The recommended approach is to implement the locked decisions exactly as
described above. The main viable alternative is to query
`/outlooks/convective/active` or `/outlooks/convective/location`, but that
would make tomorrow, multi-day, weekend, and event reports depend on
server-current active filtering rather than report valid periods. That
alternative should be deferred unless fixture payloads from the base latest-run
route prove too large or too irrelevant for prompt use.

190
docs/roadmap/outlook.md Normal file
View File

@@ -0,0 +1,190 @@
# SPC Convective Outlook Roadmap
## Purpose
This roadmap defines future work to add SPC convective outlook support to
`weatherreporter`. The feature is not implemented yet, so current user,
operator, integration, and internal documentation must not describe it as
available behavior until the implementation lands.
The goal is to consume location-filtered SPC convective outlook facts from the
Weather API once per report run, preserve source provenance, and expose concise
prompt-facing risk and narrative stanzas without making modules responsible for
upstream fetching.
## Upstream Contract
The Weather API currently documents these convective outlook routes:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
The initial `weatherreporter` integration should use the latest-run route,
`/outlooks/convective`, because report valid periods may target tomorrow,
multi-day, weekend, or event windows. The `/active` and `/location` routes
filter using the server's current UTC time, which is useful for "active right
now" views but is too narrow for report-period-oriented generation.
Important response semantics:
- `data: null` means no latest outlook run exists.
- a non-null `data` object with empty `outlooks` and `discussions` arrays means
the endpoint was checked successfully and no matching outlooks were present.
- `precision` and unknown query parameters are rejected by the upstream
outlook routes.
- outlook GeoJSON coordinates use longitude, latitude order.
- `format`, `units`, and `tz` are supported by the upstream contract, but the
initial weatherreporter request should send only values needed for JSON
decoding and local-time presentation.
## Locked Decisions
- Add the source as an optional Weather API source named
`spc_convective_outlooks`.
- Define the Weather API endpoint path with a package-level constant in the
Weather API adapter rather than embedding a string literal throughout the
implementation.
- Start with the base latest-run route, not `/active` or `/location`.
- Fetch outlook facts once in the Weather API adapter and expose them through
`weatherdata.Bundle`, `facts.CollectedFacts`, and report-scoped derived
filtering.
- Do not let module builders make Weather API calls.
- Keep GeoJSON geometry in collected facts and persisted bundle/debug artifacts,
but omit geometry from prompt-facing module output by default.
- Add two prompt-facing modules backed by the same collected source:
`spc_convective_outlooks` and `spc_convective_discussion`.
- Place `spc_convective_outlooks` under `applicable_risk_products`.
- Place `spc_convective_discussion` under `narrative_products`, immediately
after `area_forecast_discussion` in report module order when both are present.
- Include SPC outlook discussion text only when at least one retained outlook
for the report valid period has `severity_rank >= 3`.
- Define that threshold as an internal constant so it can be adjusted later
without searching through module code.
- Treat a non-null run with empty arrays as checked empty data, not missing
data.
- Treat `data: null`, HTTP errors, and malformed payloads as optional-source
missing or malformed conditions using the configured missing-source policy.
## Target Internal Shape
Add normalized collected facts to `internal/weatherdata`:
- `ConvectiveOutlookRun`
- `ConvectiveOutlook`
- `ConvectiveOutlookDiscussion`
The run should include upstream run metadata, ordered outlooks, ordered
discussions, and enough raw/provenance data for inspection. The bundle should
gain a field similar to:
```go
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
```
`facts.CollectedFacts` should expose the same collected source. Derived facts
should provide report-period-filtered outlooks and discussions, or a small
forecast/facts helper should perform that filtering before module builders
shape prompt output. The filtering rule should use overlap with the resolved
report valid period, not server-current active status.
## Prompt-Facing Shape
The risk-product module should be concise and location-oriented:
```yaml
briefing:
applicable_risk_products:
spc_convective_outlooks:
checked: true
as_of: "2026-06-12 at 7:00 AM"
issued_at: "2026-06-12 at 6:00 AM"
outlooks:
- day: 1
outlook_type: categorical
label: SLGT
label_text: Slight Risk
severity_rank: 3
valid_start: "2026-06-12 at 8:00 AM"
valid_end: "2026-06-13 at 7:00 AM"
contains_location: true
source_url: "https://..."
image_url: "https://..."
```
The discussion module should be separate narrative context:
```yaml
briefing:
narrative_products:
area_forecast_discussion: {}
spc_convective_discussion:
included_because: "severity_rank >= 3"
discussions:
- day: 1
headline: "Severe storms possible"
summary: "Scattered severe storms are possible."
discussion: "SPC discussion text."
updated_at: "2026-06-12 at 6:30 AM"
```
If outlook data is checked successfully and no report-period outlooks apply,
the risk-product stanza should make that explicit with `checked: true` and an
empty outlook count or empty list. The discussion stanza should be omitted when
the severity threshold is not met or no relevant discussion is available.
## Implementation Plan
The staged implementation plan for this feature lives in
`docs/roadmap/implementation.md`. This document remains the feature roadmap:
it defines the target state, user intent, and policy decisions that future
implementation work should preserve.
## Test Plan
Add focused coverage for:
- Weather API decode of run metadata, outlooks, discussions, and geometry;
- endpoint path/query construction using the endpoint constant;
- `data: null` optional-source policy behavior;
- non-null empty `outlooks`/`discussions` as checked empty data;
- source provenance and data hash recording;
- valid-period overlap filtering for today, tomorrow, 3-day, weekend, and
storm windows;
- risk-product module output, omitted geometry, checked-empty behavior, and
prompt category;
- discussion module severity threshold behavior and prompt category;
- report default composition validation;
- app or prompt-input workflow proving generated YAML contains the new stanzas
when fixture data warrants them.
Run at minimum:
```bash
go test ./internal/adapters/weatherapi ./internal/weatherdata ./internal/facts ./internal/briefing ./internal/module ./internal/report ./internal/app ./internal/promptinput
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
## Deferred Work
Do not include these in the first implementation unless a separate roadmap
expands the scope:
- calling `/outlooks/convective/active` or `/outlooks/convective/location`;
- user-configurable SPC discussion severity threshold;
- prompt-facing GeoJSON geometry;
- polygon distance, area, or map-rendered risk summaries;
- Mesoscale Discussions, watches, WPC outlooks, radar, QPF, or other risk
products;
- module-owned upstream fetching;
- custom per-report Weather API query filters such as `day` or `outlookType`.
## Open Questions
No open question blocks implementation. The recommended defaults above should
be used for the first pass. If fixture testing shows that the base latest-run
route includes too much irrelevant data, the viable alternative is to add
report-aware adapter query filters later, but that should be driven by observed
payload size or prompt quality rather than by the initial design.

View File

@@ -21,7 +21,7 @@ notify:
token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s
failure_policy: error
pipeline_id_template: "weatherreporter.{artifact_group}"
pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates:
@@ -77,6 +77,7 @@ reports:
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
@@ -84,6 +85,7 @@ reports:
- key_messages
- short_term
- long_term
- spc_convective_discussion
- weather_story
- outdoor_windows
- hourly_forecast

View File

@@ -21,6 +21,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil
}
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
}
var run weatherdata.ConvectiveOutlookRun
if err := decodeSource(raw, &run); err != nil {
return b.handleMalformed(&source, err, false)
}
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
source.IssuedAt = run.AsOf
}
source.UpdatedAt = run.UpdatedAt
b.bundle.SPCConvectiveOutlooks = &run
b.addSource(source)
return nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true
if required {

View File

@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
}
if len(bundle.Sources) != 7 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
}
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
}
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
}
if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
@@ -70,6 +79,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
}
if !containsPath(requested, convectiveOutlooksEndpoint) {
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
}
continue
}
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
}
continue
}
if !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing units=us", rawURL)
}
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
}
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if outlooks.Endpoint != convectiveOutlooksEndpoint {
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
}
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
}
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
}
if outlooks.Missing {
t.Fatal("convective outlook source Missing = true, want false")
}
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
}
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
}
}
func TestHTTPErrorIsActionable(t *testing.T) {
@@ -189,6 +226,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
}
}
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks != nil {
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
}
}
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks == nil {
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
}
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if source.Missing || len(source.Warnings) != 0 {
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
}
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
}
for _, warning := range bundle.Warnings {
if warning.Source == sourceSPCConvectiveOutlooks {
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct {
name string
@@ -363,13 +453,14 @@ type handlerOverride struct {
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {

View File

@@ -0,0 +1,50 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"asOf": "2026-05-29T16:00:00Z",
"issuedAt": "2026-05-29T15:45:00Z",
"updatedAt": "2026-05-29T16:05:00Z",
"product": "convective_outlook",
"outlooks": [
{
"id": "day1-categorical-slight",
"provider": "spc",
"product": "convective_outlook",
"day": 1,
"outlookType": "categorical",
"label": "SLGT",
"labelText": "Slight Risk",
"forecaster": "Smith",
"severityRank": 3,
"validFrom": "2026-05-29T13:00:00-05:00",
"validTo": "2026-05-30T07:00:00-05:00",
"issuedAt": "2026-05-29T15:45:00Z",
"expiresAt": "2026-05-30T07:00:00-05:00",
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
"containsLocation": true,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-91.0, 38.0],
[-90.0, 38.5],
[-89.5, 37.8],
[-91.0, 38.0]
]
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "A few storms may become severe during the afternoon.",
"updatedAt": "2026-05-29T16:05:00Z"
}
]
}
}

View File

@@ -39,6 +39,8 @@ func TestFetchAndSaveBundle(t *testing.T) {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}
@@ -167,9 +169,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
!strings.Contains(string(data), "current_conditions:") ||
!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:") ||
!strings.Contains(string(data), "spc_convective_outlooks:") {
t.Fatalf("data package missing expected content:\n%s", string(data))
}
if strings.Contains(string(data), "spc_convective_discussion:") {
t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data))
}
if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
}
@@ -182,9 +188,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
storyIndex := strings.Index(string(data), " weather_story:")
currentIndex := strings.Index(string(data), " current_conditions:")
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
outlookIndex := strings.Index(string(data), " spc_convective_outlooks:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
}
savedDataPackage, err := promptinput.LoadYAML(data)
@@ -197,6 +204,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
t.Fatal("data package metadata stanza missing")
}
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
}
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
@@ -244,6 +255,86 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
}
}
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok {
t.Fatal("module snapshot missing spc_convective_discussion stanza")
}
data := readDataPackageForTest(t, result)
text := string(data)
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
for _, want := range []string{
" spc_convective_outlooks:",
" spc_convective_discussion:",
" included_because: severity_rank >= 3",
" label_text: Slight Risk",
" severity_rank: 3",
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
} {
if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:")
alertIndex := strings.Index(text, " alert_digest:")
outlookIndex := strings.Index(text, " spc_convective_outlooks:")
derivedIndex := strings.Index(text, " derived_summaries:")
narrativeIndex := strings.Index(text, " narrative_products:")
forecastIndex := strings.Index(text, " narrative_forecast:")
afdIndex := strings.Index(text, " area_forecast_discussion:")
discussionIndex := strings.Index(text, " spc_convective_discussion:")
storyIndex := strings.Index(text, " weather_story:")
rawIndex := strings.Index(text, " raw_data:")
if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) ||
!(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
}
loaded, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok {
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
}
text := string(readDataPackageForTest(t, result))
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") || !strings.Contains(text, " severity_rank: 2") {
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
}
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
}
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
}
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
@@ -1117,6 +1208,11 @@ func moduleIDsForTest(ids []module.ID) []string {
}
func dailyBundleServer(t *testing.T) *httptest.Server {
t.Helper()
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
}
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -1134,6 +1230,8 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(convectiveResponse))
default:
http.NotFound(w, r)
}
@@ -1142,6 +1240,12 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
return server
}
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
func TestResolveGenerateStorm(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
@@ -1335,6 +1439,37 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
return cfg
}
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper()
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
return result
}
func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
t.Helper()
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
return data
}
func assertPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {

View File

@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
}
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
},
{
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
},
{
ID: module.WeatherStory,
StanzaName: "weather_story",

View File

@@ -4,8 +4,10 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -128,6 +130,30 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
}
}
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}

View File

@@ -0,0 +1,71 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"`
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
}
type SPCConvectiveDiscussionRecord struct {
Day int `json:"day,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) {
return nil, nil
}
records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
if len(records) == 0 {
return nil, nil
}
value := SPCConvectiveDiscussionModule{
IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank),
Discussions: records,
}
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
}
func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool {
for _, outlook := range outlooks {
if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank {
return true
}
}
return false
}
func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
retainedDays := map[int]struct{}{}
for _, outlook := range outlooks {
retainedDays[outlook.Day] = struct{}{}
}
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
for _, discussion := range discussions {
if _, ok := retainedDays[discussion.Day]; !ok {
continue
}
if discussion.Discussion == "" {
continue
}
records = append(records, SPCConvectiveDiscussionRecord{
Day: discussion.Day,
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
})
}
return records
}

View File

@@ -0,0 +1,155 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule(discussion) error = %v", err)
}
if output != nil {
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
}
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule(outlooks) error = %v", err)
}
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
}
}
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 {
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
}
discussion := value.Discussions[0]
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
}
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
}
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
t.Fatalf("value = %#v, want above-threshold discussion", value)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveDiscussions = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted for missing optional source", output)
}
}
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = discussions
return ctx
}
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
return weatherdata.ConvectiveOutlookDiscussion{
Day: day,
Headline: headline,
Summary: summary,
Discussion: discussion,
UpdatedAt: ptrModuleTime(updatedAt),
}
}
func ptrModuleTime(value string) *time.Time {
parsed := mustParseModuleTime(value)
return &parsed
}

View File

@@ -0,0 +1,93 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type SPCConvectiveOutlooksModule struct {
Checked bool `json:"checked"`
AsOf string `json:"as_of,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
LocationID string `json:"location_id,omitempty"`
LocationName string `json:"location_name,omitempty"`
OutlookCount int `json:"outlook_count"`
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
}
type SPCConvectiveOutlookRecord struct {
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
SeverityRank *int `json:"severity_rank,omitempty"`
ValidStart string `json:"valid_start,omitempty"`
ValidEnd string `json:"valid_end,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
SourceURL string `json:"source_url,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := SPCConvectiveOutlooksModule{}
run := ctx.Collected.SPCConvectiveOutlooks
if run != nil {
value.Checked = true
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
value.LocationID = run.LocationID
value.LocationName = run.LocationName
}
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
}
if value.Checked && value.IssuedAt == "" && ok {
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
}
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone)
value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
}
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks {
records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
SeverityRank: copyInt(outlook.SeverityRank),
ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone),
ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone),
ContainsLocation: outlook.ContainsLocation,
SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL,
})
}
return records
}
func friendlyOptionalTime(value *time.Time, timezone string) string {
if value == nil {
return ""
}
return friendlyDateTimeLabel(*value, timezone)
}
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
for _, source := range sources {
if source.Name == name {
return source, true
}
}
return weatherdata.Source{}, false
}

View File

@@ -0,0 +1,141 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 3
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
IssuedAt: &issuedAt,
ExpiresAt: &expiresAt,
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
IssuedAt: &issuedAt,
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
t.Fatalf("output = %#v, want spc convective outlook output", output)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
}
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
}
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
got := value.Outlooks[0]
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.SeverityRank == nil || *got.SeverityRank != 3 {
t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank)
}
if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
}
if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and source/image URLs", got)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} {
if strings.Contains(text, omitted) {
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
}
}
}
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
Outlooks: []weatherdata.ConvectiveOutlook{},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Contains(string(data), "outlooks") {
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
}
}
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{
Name: string(module.SPCConvectiveOutlooks),
Missing: true,
}}
ctx.Derived.SPCConvectiveOutlooks = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
}
}

View File

@@ -996,6 +996,8 @@ func dailyServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}

View File

@@ -85,7 +85,7 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location)
}
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" {
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
}
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
@@ -126,10 +126,13 @@ reports:
deterministic_modules:
- metadata
- current_conditions
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
`)
cfg, err := LoadFile(path)
@@ -138,15 +141,20 @@ reports:
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday]
if len(items) != 3 {
t.Fatalf("daily override length = %d, want 3", len(items))
if len(items) != 6 {
t.Fatalf("daily override length = %d, want 6", len(items))
}
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
if items[0].ID != module.Metadata ||
items[1].ID != module.CurrentConditions ||
items[2].ID != module.AlertDigest ||
items[3].ID != module.SPCConvectiveOutlooks ||
items[4].ID != module.AreaForecastDiscussion ||
items[5].ID != module.SPCConvectiveDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items)
}
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
@@ -599,7 +607,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
"notify:\n" +
" distributor:\n" +
" enabled: true\n" +
" pipeline_id_template: weatherreporter.{artifact_group}\n"
" pipeline_id_template: weatherreporter.{report_id}\n"
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}

View File

@@ -3,6 +3,7 @@ package facts
import (
"fmt"
"sort"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
@@ -12,15 +13,16 @@ import (
)
type CollectedFacts struct {
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
return CollectedFacts{}
}
return CollectedFacts{
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
}
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
}
@@ -73,6 +77,8 @@ type DerivedFacts struct {
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming
@@ -96,11 +102,14 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
}
bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
}
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
@@ -139,3 +148,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
}
return out
}
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
if run == nil {
return nil, nil
}
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
days := map[int]struct{}{}
for _, outlook := range run.Outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
continue
}
outlooks = append(outlooks, outlook)
days[outlook.Day] = struct{}{}
}
sort.SliceStable(outlooks, func(i, j int) bool {
left := outlooks[i]
right := outlooks[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.OutlookType != right.OutlookType {
return left.OutlookType < right.OutlookType
}
leftRank, leftRankOK := severityRank(left)
rightRank, rightRankOK := severityRank(right)
if leftRankOK != rightRankOK {
return leftRankOK
}
if leftRankOK && leftRank != rightRank {
return leftRank > rightRank
}
if !left.ValidFrom.Equal(right.ValidFrom) {
return left.ValidFrom.Before(right.ValidFrom)
}
if left.Label != right.Label {
return left.Label < right.Label
}
return left.ID < right.ID
})
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
for _, discussion := range run.Discussions {
if _, ok := days[discussion.Day]; ok {
discussions = append(discussions, discussion)
}
}
sort.SliceStable(discussions, func(i, j int) bool {
left := discussions[i]
right := discussions[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
return left.UpdatedAt.Before(*right.UpdatedAt)
}
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
return left.UpdatedAt != nil
}
if left.Headline != right.Headline {
return left.Headline < right.Headline
}
if left.Summary != right.Summary {
return left.Summary < right.Summary
}
return left.Discussion < right.Discussion
})
return outlooks, discussions
}
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
if outlook.SeverityRank == nil {
return 0, false
}
return *outlook.SeverityRank, true
}

View File

@@ -2,6 +2,7 @@ package facts
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -16,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
Product: "convective_outlook",
Outlooks: []weatherdata.ConvectiveOutlook{{
ID: "day1-categorical-slight",
Label: "SLGT",
}},
},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
}
collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
}
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
}
@@ -36,6 +47,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
}
roundTrip := collected.Bundle()
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
}
}
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
@@ -166,6 +182,108 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
}
}
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
location := testLocation()
now := mustParse("2026-05-29T08:00:00-05:00")
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
tests := []struct {
name string
resolved report.Resolved
wantOutlookIDs []string
wantDiscussion []string
}{
{
name: "daily today",
resolved: resolveForTest(t, report.DailyToday, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
{
name: "daily tomorrow",
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"},
},
{
name: "three day",
resolved: resolveForTest(t, report.ThreeDay, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
},
{
name: "weekend",
resolved: resolveForTest(t, report.Weekend, now, location),
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day2", "day3"},
},
{
name: "storm",
resolved: resolveStormForTest(t, location),
wantOutlookIDs: []string{"fri-storm", "fri-low"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: tt.resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
}
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
}
})
}
}
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(missing source) error = %v", err)
}
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
}
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
derived, err = BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
}
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
}
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
}
}
func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
@@ -193,6 +311,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
}
}
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
rank1 := 1
rank2 := 2
rank3 := 3
rank4 := 4
rank5 := 5
return &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
},
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
},
}
}
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: id,
Day: day,
OutlookType: outlookType,
Label: label,
SeverityRank: rank,
ValidFrom: mustParse(validFrom).In(location),
ValidTo: mustParse(validTo).In(location),
}
}
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
out := make([]string, 0, len(outlooks))
for _, outlook := range outlooks {
out = append(out, outlook.ID)
}
return out
}
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
out := make([]string, 0, len(discussions))
for _, discussion := range discussions {
out = append(out, discussion.Headline)
}
return out
}
func ptrTime(value time.Time) *time.Time {
return &value
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0
return weatherdata.ForecastPeriod{

View File

@@ -21,6 +21,8 @@ const (
AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning"
)
@@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
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"
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"
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
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
}
type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{}

View File

@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
t.Fatal("StanzaValue(missing) found = true, want false")
}
}
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
}
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{}
}

View File

@@ -35,6 +35,7 @@ var briefingCategoryOrder = []string{
var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries,
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData,

View File

@@ -164,6 +164,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
}
}
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
text := string(data)
riskIndex := strings.Index(text, " applicable_risk_products:\n")
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
narrativeIndex := strings.Index(text, " narrative_products:\n")
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
}
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
}
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {

View File

@@ -46,7 +46,9 @@ func dailyTodayModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
@@ -62,7 +64,9 @@ func dailyTomorrowModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,

View File

@@ -267,7 +267,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
@@ -283,7 +285,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
@@ -298,7 +302,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
@@ -311,7 +317,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
@@ -323,7 +331,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},

View File

@@ -29,7 +29,9 @@ func stormModules() []module.ConfigItem {
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
)
}

View File

@@ -30,7 +30,9 @@ func threeDayModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)

View File

@@ -31,7 +31,9 @@ func weekendModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)

View File

@@ -7,17 +7,18 @@ import (
)
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"`
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"`
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type Source struct {
@@ -166,3 +167,42 @@ type WeatherStory struct {
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
}
type ConvectiveOutlookRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
AsOf *time.Time `json:"asOf,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product,omitempty"`
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
}
type ConvectiveOutlook struct {
ID string `json:"id,omitempty"`
Provider string `json:"provider,omitempty"`
Product string `json:"product,omitempty"`
Day int `json:"day,omitempty"`
OutlookType string `json:"outlookType,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"labelText,omitempty"`
Forecaster string `json:"forecaster,omitempty"`
SeverityRank *int `json:"severityRank,omitempty"`
ValidFrom time.Time `json:"validFrom"`
ValidTo time.Time `json:"validTo"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
ContainsLocation bool `json:"containsLocation"`
Geometry json.RawMessage `json:"geometry,omitempty"`
}
type ConvectiveOutlookDiscussion struct {
Day int `json:"day,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}

View File

@@ -0,0 +1,78 @@
package weatherdata
import (
"encoding/json"
"testing"
"time"
)
func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) {
severity := 3
validFrom := mustParseBundleTime("2026-06-12T13:00:00Z")
validTo := mustParseBundleTime("2026-06-13T12:00:00Z")
issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z")
updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z")
run := ConvectiveOutlookRun{
LocationID: "test-grid",
LocationName: "Brentwood",
AsOf: &updatedAt,
IssuedAt: &issuedAt,
Product: "convective_outlook",
Outlooks: []ConvectiveOutlook{{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &severity,
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: &issuedAt,
ExpiresAt: &validTo,
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`),
}},
Discussions: []ConvectiveOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &updatedAt,
}},
}
data, err := json.Marshal(run)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var decoded ConvectiveOutlookRun
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" {
t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks)
}
if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` {
t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got)
}
if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity {
t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity)
}
if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions)
}
}
func mustParseBundleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}