Compare commits
22 Commits
v0.7.0
...
b3f7c9c1f2
| Author | SHA1 | Date | |
|---|---|---|---|
| b3f7c9c1f2 | |||
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 | |||
| 0b1423c90d | |||
| e6a4bb2d16 | |||
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b | |||
| 2aba52f552 |
12
docs/cli.md
12
docs/cli.md
@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -37,8 +38,11 @@ weatherreporter inspect sources [--config PATH] RUN_ID
|
|||||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
||||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
artifact, managed Markdown report, and metadata under the configured workspace.
|
||||||
`--out` writes an extra Markdown copy for the operator; distributor
|
`--out` writes an extra Markdown copy for the operator; distributor
|
||||||
notification uses the managed report path, not the extra copy. `generate storm`
|
notification uses the managed report path, not the extra copy. `generate
|
||||||
requires explicit event-window bounds with `--start` and `--end`.
|
near-term` uses the current generation time, covers the next six hours in the
|
||||||
|
effective report timezone, and does not accept date or event window flags.
|
||||||
|
`generate storm` requires explicit event-window bounds with `--start` and
|
||||||
|
`--end`.
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||||
@@ -50,6 +54,9 @@ notification is enabled, batch summaries and status lines include notification
|
|||||||
status, accepted distributor run ID, or notification error fields for each
|
status, accepted distributor run ID, or notification error fields for each
|
||||||
attempted report.
|
attempted report.
|
||||||
|
|
||||||
|
Near-Term Report generation is explicit only; it is not included in `run
|
||||||
|
morning` or `run evening`.
|
||||||
|
|
||||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
They do not fetch weather data or invoke `scriptorium`.
|
They do not fetch weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
@@ -76,6 +83,7 @@ are no distributor-specific CLI flags.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
|
weatherreporter generate near-term --out ./near-term.md
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
weatherreporter generate weekend --out ./weekend.md
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||||
|
|||||||
@@ -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.
|
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||||
|
|
||||||
Hourly forecast data is required for generated reports. Optional sources use
|
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`
|
### `scriptorium`
|
||||||
|
|
||||||
@@ -179,9 +181,10 @@ snapshot exists and a threshold is crossed.
|
|||||||
`reports` optionally overrides the ordered deterministic modules declared by
|
`reports` optionally overrides the ordered deterministic modules declared by
|
||||||
report definitions. Omit a report entry to use its default module order.
|
report definitions. Omit a report entry to use its default module order.
|
||||||
|
|
||||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
Supported report keys are `daily`, `tomorrow`, `near_term`, `near-term`,
|
||||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
`three_day`, `weekend`, and `storm`. Canonical report IDs such as
|
||||||
also accepted.
|
`daily_today` and `daily_tomorrow` are also accepted. `near_term` and
|
||||||
|
`near-term` are aliases for the same Near-Term Report override.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
@@ -197,10 +200,13 @@ reports:
|
|||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
- narrative_forecast
|
- narrative_forecast
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ generation.
|
|||||||
means the endpoint was checked and there are no current active alerts. The
|
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.
|
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
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
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`
|
- `units`: from `weather_api.units`
|
||||||
- `precision`: from `weather_api.precision` on observations, current
|
- `precision`: from `weather_api.precision` on observations, current
|
||||||
conditions, hourly forecast, and narrative forecast requests
|
conditions, hourly forecast, and narrative forecast requests
|
||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||||
discussion requests
|
discussion, and SPC convective outlook requests
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
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
|
## Endpoints Used
|
||||||
|
|
||||||
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
|
|||||||
- `/alerts/active`
|
- `/alerts/active`
|
||||||
- `/discussion`
|
- `/discussion`
|
||||||
- `/weatherstories/latest`
|
- `/weatherstories/latest`
|
||||||
|
- `/outlooks/convective`
|
||||||
|
|
||||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||||
subsection endpoints. Report-period selection and daypart summarization happen
|
subsection endpoints. Report-period selection and daypart summarization happen
|
||||||
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `alerts` for `/alerts/active`
|
- `alerts` for `/alerts/active`
|
||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
- `weather_story` for `/weatherstories/latest`
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
- `spc_convective_outlooks` for `/outlooks/convective`
|
||||||
|
|
||||||
Policy behavior:
|
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
|
follows the relevant error path, but explicit `data: null` is not a
|
||||||
missing-source condition.
|
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
|
## Source Identity
|
||||||
|
|
||||||
For source payloads accepted into the bundle, including the explicit `null`
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
@@ -116,8 +180,8 @@ warnings list.
|
|||||||
|
|
||||||
## Compatibility Assumptions
|
## Compatibility Assumptions
|
||||||
|
|
||||||
The adapter expects payload fields compatible with the internal forecast bundle
|
The adapter expects payload fields compatible with the internal weather data
|
||||||
types in `internal/forecast/bundle.go`, including:
|
bundle types in `internal/weatherdata/bundle.go`, including:
|
||||||
|
|
||||||
- observation timestamps and observation values
|
- observation timestamps and observation values
|
||||||
- current condition 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
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
- latest weather story title, description, timing, priority, order, alt text,
|
- latest weather story title, description, timing, priority, order, alt text,
|
||||||
and download URL
|
and download URL
|
||||||
|
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
||||||
|
geometry
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ Outputs:
|
|||||||
supported reports, fact requirements, missing-data behavior, and builder
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
`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:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
@@ -36,6 +38,14 @@ Outputs:
|
|||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
Near-Term Report supports source and valid-period modules that operate over its
|
||||||
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||||
|
It does not support daily/daypart-only modules such as
|
||||||
|
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
|
||||||
|
`tomorrow_planning`.
|
||||||
|
|
||||||
Prompt-facing module values use local, human-readable date and time labels
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
remain in report metadata, source provenance, and integration artifacts.
|
remain in report metadata, source provenance, and integration artifacts.
|
||||||
@@ -57,7 +67,24 @@ builders run. Configured `location` values are prompt context only; Weather API
|
|||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
|
|
||||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||||
subset of discussion fields.
|
subset of discussion fields. Near-Term Report defaults this module to
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
|
`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, `period_begins`, `period_ends`, image URL, and whether the
|
||||||
|
outlook contains the configured location. It does not emit GeoJSON geometry,
|
||||||
|
source URL, expiration time, or severity rank.
|
||||||
|
|
||||||
|
Prompt-facing module intervals use friendly local `period_begins` and
|
||||||
|
`period_ends` labels. Canonical report metadata, source provenance,
|
||||||
|
`issued_at`, `updated_at`, and point-in-time fields remain separate.
|
||||||
|
|
||||||
|
`spc_convective_discussion` uses the same derived report-period outlooks and
|
||||||
|
discussion records. It is omitted unless at least one retained categorical
|
||||||
|
outlook for the same SPC day has severity rank `3` or higher and matching
|
||||||
|
discussion text exists.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -86,6 +113,11 @@ return an error for invalid required inputs.
|
|||||||
forecast discussion, and weather story stanzas.
|
forecast discussion, and weather story stanzas.
|
||||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
source data.
|
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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,29 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
- `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,
|
- `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
|
||||||
|
|
||||||
|
Near-Term Report uses the generic valid-period hourly and narrative selection
|
||||||
|
for its rolling six-hour window. Its derived facts include precipitation timing
|
||||||
|
from the selected hourly periods, alert overlaps for the six-hour period, and
|
||||||
|
SPC outlooks/discussions overlapping that period. It does not build daily
|
||||||
|
summaries, daypart summaries, or a storm-window summary.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns fact assembly and reusable deterministic derivation for a
|
- This package owns fact assembly and reusable deterministic derivation for a
|
||||||
report run.
|
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
|
- It does not fetch upstream data, build prompt wording, compare prior
|
||||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||||
|
|
||||||
@@ -53,8 +68,13 @@ and inspection.
|
|||||||
- Invalid timezone names return an error.
|
- Invalid timezone names return an error.
|
||||||
- Missing required hourly forecast data returns the underlying forecast
|
- Missing required hourly forecast data returns the underlying forecast
|
||||||
derivation error for reports that require daily summaries.
|
derivation error for reports that require daily summaries.
|
||||||
|
- Near-Term Report can derive its default module facts without daily or
|
||||||
|
daypart summaries.
|
||||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||||
produces empty or nil derived fields.
|
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
|
## Tests
|
||||||
|
|
||||||
@@ -67,6 +87,8 @@ Inspect:
|
|||||||
|
|
||||||
- Collected facts are built once from a fetched bundle.
|
- Collected facts are built once from a fetched bundle.
|
||||||
- Derived facts are scoped to one resolved report.
|
- 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.
|
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||||
package.
|
package.
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ The registry recognizes these IDs:
|
|||||||
- `derived_daypart_summaries`
|
- `derived_daypart_summaries`
|
||||||
- `precip_timing`
|
- `precip_timing`
|
||||||
- `alert_digest`
|
- `alert_digest`
|
||||||
|
- `spc_convective_outlooks`
|
||||||
- `area_forecast_discussion`
|
- `area_forecast_discussion`
|
||||||
|
- `spc_convective_discussion`
|
||||||
- `weather_story`
|
- `weather_story`
|
||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
@@ -45,9 +47,29 @@ The registry recognizes these IDs:
|
|||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## Near-Term Composition
|
||||||
|
|
||||||
|
The default Near-Term Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `hourly_forecast`
|
||||||
|
4. `precip_timing`
|
||||||
|
5. `alert_digest`
|
||||||
|
6. `spc_convective_outlooks`
|
||||||
|
7. `area_forecast_discussion`
|
||||||
|
8. `spc_convective_discussion`
|
||||||
|
9. `weather_story`
|
||||||
|
|
||||||
|
Near-Term Report does not include daily or daypart summary modules by default.
|
||||||
|
Its `area_forecast_discussion` item is configured to include only
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
## Options
|
## 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
|
```yaml
|
||||||
sections:
|
sections:
|
||||||
@@ -61,6 +83,31 @@ An omitted or empty `sections` list includes all available discussion sections.
|
|||||||
Invalid option shapes fail during config normalization or composition
|
Invalid option shapes fail during config normalization or composition
|
||||||
validation.
|
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`,
|
||||||
|
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
||||||
|
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
||||||
|
severity rank.
|
||||||
|
|
||||||
|
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||||
|
report-period categorical 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`, `period_begins`,
|
||||||
|
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
||||||
|
Discussions are included only for SPC days whose retained categorical outlooks
|
||||||
|
meet the severity threshold.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns module identifiers, config item envelopes, output
|
- This package owns module identifiers, config item envelopes, output
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ briefing:
|
|||||||
metadata: {}
|
metadata: {}
|
||||||
applicable_risk_products:
|
applicable_risk_products:
|
||||||
alert_digest: {}
|
alert_digest: {}
|
||||||
|
spc_convective_outlooks: {}
|
||||||
derived_summaries:
|
derived_summaries:
|
||||||
derived_daily_summary: {}
|
derived_daily_summary: {}
|
||||||
derived_daypart_summaries: {}
|
derived_daypart_summaries: {}
|
||||||
@@ -49,6 +50,7 @@ briefing:
|
|||||||
narrative_products:
|
narrative_products:
|
||||||
narrative_forecast: {}
|
narrative_forecast: {}
|
||||||
area_forecast_discussion: {}
|
area_forecast_discussion: {}
|
||||||
|
spc_convective_discussion: {}
|
||||||
weather_story: {}
|
weather_story: {}
|
||||||
raw_data:
|
raw_data:
|
||||||
current_conditions: {}
|
current_conditions: {}
|
||||||
@@ -62,13 +64,26 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
|
|||||||
presentation concern only: module snapshots remain flat, and loaded
|
presentation concern only: module snapshots remain flat, and loaded
|
||||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||||
Within each category, stanza order follows the module snapshot output order.
|
Within each category, stanza order follows the module snapshot output order.
|
||||||
|
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||||
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
|
timestamps where applicable.
|
||||||
|
|
||||||
|
Near-Term Report uses the same package schema and categories. Its default
|
||||||
|
package includes `precip_timing` under `derived_summaries`, alert and SPC
|
||||||
|
outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story
|
||||||
|
under `narrative_products`, and current/hourly data under `raw_data`. It does
|
||||||
|
not include daily or daypart summary stanzas.
|
||||||
|
|
||||||
Current categories are:
|
Current categories are:
|
||||||
|
|
||||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
- `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.
|
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||||
- `narrative_products`: official narrative text products and forecast stories.
|
- `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.
|
- `raw_data`: minimally transformed underlying weather data.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -97,6 +112,7 @@ None.
|
|||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
None. Recent Changes is always present as an `items` list and may be empty.
|
None. Recent Changes is always present as an `items` list and may be empty.
|
||||||
|
Near-Term Report currently writes an empty `items` list.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Each report definition declares:
|
|||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||||
|
| Near-Term Report | `near_term` | `weather.near_term_report` | `near-term` | `near-term.md` | Near-Term Report |
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||||
@@ -42,6 +43,9 @@ All report definitions are eligible for generation.
|
|||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Today covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Daily Tomorrow covers the next local civil day from generation time.
|
- Daily Tomorrow covers the next local civil day from generation time.
|
||||||
|
- Near-Term Report covers the half-open six-hour period from generation time in
|
||||||
|
the effective report timezone. The duration is an internal report constant,
|
||||||
|
not a configuration field.
|
||||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||||
three days later.
|
three days later.
|
||||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||||
@@ -64,7 +68,15 @@ IDs, then uses the registry for report policy.
|
|||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions.
|
output path copying uses batch output names from report definitions. Report
|
||||||
|
module overrides can use the `near_term` or `near-term` keys for Near-Term
|
||||||
|
Report.
|
||||||
|
|
||||||
|
## Batch Membership
|
||||||
|
|
||||||
|
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
|
on Sunday. Evening batches include Daily Tomorrow. Near-Term Report is not part
|
||||||
|
of a scheduled batch.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from the
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API and assembles the bundle consumed by forecast derivation
|
||||||
derivation and module builders. Module builders expose normalized current
|
and module builders. Module builders expose normalized current conditions and
|
||||||
conditions and weather story context when those sources are available.
|
weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ Outputs:
|
|||||||
|
|
||||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, latest weather story, source
|
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
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -54,9 +55,19 @@ contract used by this project.
|
|||||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
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.
|
`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
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
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.
|
- Optional sources follow missing-source policy.
|
||||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
alert run.
|
alert run.
|
||||||
|
- Explicit `data: null` from `/outlooks/convective` follows optional
|
||||||
|
missing-source policy.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Generation commands:
|
|||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate tomorrow
|
||||||
|
weatherreporter generate near-term
|
||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
weatherreporter generate weekend
|
weatherreporter generate weekend
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
@@ -25,6 +26,10 @@ Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
|||||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
||||||
is not used as the distributor upload source.
|
is not used as the distributor upload source.
|
||||||
|
|
||||||
|
`generate near-term` is an explicit generation command. It covers the six-hour
|
||||||
|
rolling period from generation time in the effective report timezone and is not
|
||||||
|
part of scheduled morning or evening batches.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -62,6 +67,10 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
|
near-term/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.modules.json
|
||||||
|
<run_id>.metadata.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -76,6 +85,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
|
near-term/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
@@ -89,6 +101,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
near-term/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
@@ -102,6 +117,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
|
near-term/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
@@ -112,6 +130,8 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
weekend/
|
weekend/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
near-term/
|
||||||
|
<run_id>.md
|
||||||
storm/
|
storm/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
@@ -218,8 +238,8 @@ Markdown or YAML text.
|
|||||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
compatible Weekend snapshots for the same weekend window. Near-Term Report and
|
||||||
Recent Changes empty.
|
Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
|
|||||||
343
docs/roadmap/generated-text.md
Normal file
343
docs/roadmap/generated-text.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# GeneratedText Report Rendering Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines planned work to move selected reports from full Markdown
|
||||||
|
LLM generation to deterministic Markdown rendering with structured LLM-filled
|
||||||
|
text slots. The first target report is the rolling next-hours report, now named
|
||||||
|
`hourly` rather than `near_term`.
|
||||||
|
|
||||||
|
This feature is not implemented yet, so this document lives under
|
||||||
|
`docs/roadmap/`.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
The current module and prompt-input system has reached the point where many
|
||||||
|
report facts can be calculated deterministically. The next step is to let Go
|
||||||
|
own report structure while using the LLM for only the prose that benefits from
|
||||||
|
language synthesis.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
1. collect upstream facts once;
|
||||||
|
2. derive deterministic report facts;
|
||||||
|
3. build module outputs and the prompt data package;
|
||||||
|
4. ask Scriptorium for structured JSON using the schema associated with the
|
||||||
|
prompt ID on the Scriptorium backend;
|
||||||
|
5. validate and persist that structured response as `GeneratedText`;
|
||||||
|
6. build a curated render context;
|
||||||
|
7. render final Markdown from an embedded human-editable template.
|
||||||
|
|
||||||
|
This should improve consistency for headings, dates, ordering, units, recurring
|
||||||
|
sections, and report-specific format while preserving the LLM's role in
|
||||||
|
summarizing and explaining weather impacts.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- `CollectedFacts`: normalized upstream inputs from Weather API sources.
|
||||||
|
- `DerivedFacts`: deterministic Go-calculated facts sliced to a report period.
|
||||||
|
- `ModuleOutput`: prompt-facing deterministic module stanzas.
|
||||||
|
- `GeneratedText`: validated structured LLM output. It is not authoritative
|
||||||
|
weather data; it is prose or short text intended to fill named template
|
||||||
|
slots.
|
||||||
|
- `RenderContext`: the final deterministic object passed to a Markdown
|
||||||
|
template. It may expose selected values from CollectedFacts, DerivedFacts,
|
||||||
|
ModuleOutput, report metadata, and GeneratedText.
|
||||||
|
|
||||||
|
The report template should not receive the unbounded raw data package directly.
|
||||||
|
It should receive a curated render context so templates remain readable and do
|
||||||
|
not become a second application logic layer.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
- Use `GeneratedText` as the name for the structured LLM output layer.
|
||||||
|
- Continue using Scriptorium behind `internal/adapters/scriptorium`.
|
||||||
|
- Use Scriptorium structured JSON output. The Scriptorium backend associates
|
||||||
|
the prompt ID with the JSON schema; weatherreporter does not pass a schema
|
||||||
|
file at runtime.
|
||||||
|
- Keep Scriptorium retries inside Scriptorium.
|
||||||
|
- Also validate the returned JSON inside `weatherreporter` before rendering.
|
||||||
|
- Use embedded, separate, human-editable Markdown template files.
|
||||||
|
- Use embedded, separate, human-readable JSON schema files.
|
||||||
|
- Use Go standard library `text/template` for Markdown rendering unless a
|
||||||
|
concrete limitation appears.
|
||||||
|
- Let Scriptorium enforce the prompt-associated JSON Schema and perform
|
||||||
|
retries, then have weatherreporter validate by unmarshalling into a typed
|
||||||
|
GeneratedText struct and checking required fields. Do not add a Go JSON
|
||||||
|
Schema dependency in the first implementation.
|
||||||
|
- Do not generate schemas or templates from inline Go.
|
||||||
|
- Start with the rolling next-hours report only.
|
||||||
|
- Rename the planned `near_term` report to `hourly`.
|
||||||
|
- Do not keep both `near_term` and `hourly` public report IDs.
|
||||||
|
- Do not add a generic workflow engine or plugin system.
|
||||||
|
|
||||||
|
## Hourly Report Target
|
||||||
|
|
||||||
|
The planned `near_term` report should become the `hourly` report.
|
||||||
|
|
||||||
|
Target report identity:
|
||||||
|
|
||||||
|
- report ID: `hourly`
|
||||||
|
- CLI command: `weatherreporter generate hourly`
|
||||||
|
- display name: `Hourly Report`
|
||||||
|
- prompt ID for structured text generation: `weather.hourly_generated_text`
|
||||||
|
- Markdown template ID: `hourly`
|
||||||
|
- GeneratedText schema ID: `hourly`
|
||||||
|
- artifact group: `hourly`
|
||||||
|
- batch output name: `hourly.md`
|
||||||
|
- default valid-period length: 6 hours
|
||||||
|
- valid period: `[generation_time, generation_time + hourlyReportHours)`
|
||||||
|
|
||||||
|
Use a package-owned constant for the valid-period length, initially 6 hours.
|
||||||
|
The duration should not be user-configurable in the first implementation.
|
||||||
|
|
||||||
|
The report remains explicit-generation only at first. Do not add it to morning
|
||||||
|
or evening batches until cadence and downstream behavior are proven.
|
||||||
|
|
||||||
|
## Embedded Asset Layout
|
||||||
|
|
||||||
|
Recommended asset package:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/reporttemplate/
|
||||||
|
templates/
|
||||||
|
hourly.md.tmpl
|
||||||
|
schemas/
|
||||||
|
hourly.generated_text.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/reporttemplate` should embed assets with `go:embed` and expose a
|
||||||
|
narrow API for:
|
||||||
|
|
||||||
|
- looking up a template by template ID;
|
||||||
|
- looking up a schema by schema ID;
|
||||||
|
- rendering Markdown from a typed render context.
|
||||||
|
|
||||||
|
Do not allow template or schema asset IDs to come from free-form user input in
|
||||||
|
the initial implementation. Report definitions should declare the IDs.
|
||||||
|
|
||||||
|
## Report Definition Fields
|
||||||
|
|
||||||
|
Extend report definitions only as needed for this generation mode. Required
|
||||||
|
fields:
|
||||||
|
|
||||||
|
- `GenerationMode`
|
||||||
|
- `TemplateID`
|
||||||
|
- `GeneratedTextSchemaID`
|
||||||
|
|
||||||
|
Recommended generation modes:
|
||||||
|
|
||||||
|
- `GenerationModeScriptoriumMarkdown`: existing full Markdown generation path.
|
||||||
|
- `GenerationModeGeneratedTextTemplate`: structured GeneratedText plus
|
||||||
|
deterministic template rendering.
|
||||||
|
|
||||||
|
Existing reports should stay on the current full Markdown path until migrated.
|
||||||
|
The `hourly` report should use the GeneratedText/template path from the start.
|
||||||
|
For generated-text reports, the existing `PromptID` field remains the
|
||||||
|
Scriptorium prompt ID and should be set to `weather.hourly_generated_text`.
|
||||||
|
|
||||||
|
## GeneratedText Contract
|
||||||
|
|
||||||
|
The hourly JSON schema should define only the text slots the template needs.
|
||||||
|
Keep the first schema small. Example shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "Brief near-term overview.",
|
||||||
|
"timing": "Plain-language timing of notable changes.",
|
||||||
|
"impacts": "Practical impacts for the next few hours.",
|
||||||
|
"confidence": "Optional confidence or uncertainty note."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact schema should be locked during implementation based on the first
|
||||||
|
hourly template. Required fields should be genuinely required by the template.
|
||||||
|
Optional fields should be omitted from the rendered Markdown when empty.
|
||||||
|
|
||||||
|
GeneratedText should be persisted as structured JSON exactly as validated by
|
||||||
|
weatherreporter. Raw Scriptorium output should also be persisted for debugging
|
||||||
|
when practical.
|
||||||
|
|
||||||
|
## Template Contract
|
||||||
|
|
||||||
|
The hourly Markdown template should be a readable file that a human can edit
|
||||||
|
without digging into Go code. It should own:
|
||||||
|
|
||||||
|
- Markdown heading structure;
|
||||||
|
- section order;
|
||||||
|
- deterministic fact placement;
|
||||||
|
- conditional inclusion of optional GeneratedText slots;
|
||||||
|
- final report layout.
|
||||||
|
|
||||||
|
The template should not own:
|
||||||
|
|
||||||
|
- Weather API fetching;
|
||||||
|
- fact derivation;
|
||||||
|
- module composition;
|
||||||
|
- Scriptorium invocation;
|
||||||
|
- schema validation;
|
||||||
|
- path construction;
|
||||||
|
- distributor upload behavior.
|
||||||
|
|
||||||
|
The render context should prepare strings and simple values so template logic
|
||||||
|
stays shallow. Prefer small conditionals over complex template functions.
|
||||||
|
|
||||||
|
## Scriptorium Adapter Direction
|
||||||
|
|
||||||
|
`internal/adapters/scriptorium` should expose weatherreporter-owned request and
|
||||||
|
result types for structured JSON generation. The adapter should hide
|
||||||
|
Scriptorium argv details from app, report, facts, module, prompt-input, and
|
||||||
|
template packages.
|
||||||
|
|
||||||
|
Scriptorium uses the prompt ID to select the backend-associated structured
|
||||||
|
output schema. The adapter should not pass a schema path at runtime.
|
||||||
|
|
||||||
|
## State And Artifacts
|
||||||
|
|
||||||
|
Add managed artifacts for the new generation path. Recommended logical
|
||||||
|
artifacts:
|
||||||
|
|
||||||
|
- module snapshot JSON;
|
||||||
|
- data package YAML;
|
||||||
|
- raw GeneratedText JSON written by Scriptorium;
|
||||||
|
- structured Scriptorium command result for the GeneratedText run;
|
||||||
|
- validated GeneratedText JSON;
|
||||||
|
- render context JSON;
|
||||||
|
- rendered Markdown report;
|
||||||
|
- metadata linking all of the above.
|
||||||
|
|
||||||
|
The existing `preflight` artifact should remain as the rendered prompt/input
|
||||||
|
inspection artifact before structured generation. GeneratedText reports add
|
||||||
|
separate artifacts for raw GeneratedText, structured run result, validated
|
||||||
|
GeneratedText, and render context. Metadata should record the
|
||||||
|
`GeneratedTextSchemaID`; no per-run schema artifact is persisted.
|
||||||
|
|
||||||
|
Artifact paths should remain under the workspace and use the report artifact
|
||||||
|
group, valid start date, and RunID conventions already used by state.
|
||||||
|
|
||||||
|
## End-To-End Behavior
|
||||||
|
|
||||||
|
Reports using `GenerationModeGeneratedTextTemplate` should follow the same
|
||||||
|
fact collection, derivation, module snapshot, prompt package, state, and
|
||||||
|
notification boundaries as current reports. The difference is limited to the
|
||||||
|
last rendering segment: Scriptorium returns structured GeneratedText, Go
|
||||||
|
validates that structure, Go builds a curated render context, and an embedded
|
||||||
|
template renders the final Markdown.
|
||||||
|
|
||||||
|
The existing full-Markdown Scriptorium path should remain available for
|
||||||
|
reports that have not migrated.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
GeneratedText rendering adds one new generation mode, not a replacement for the
|
||||||
|
whole report system. Reports that opt into this mode should continue to use
|
||||||
|
the existing report registry, fact pipeline, module snapshot, data package,
|
||||||
|
state store, and notification boundaries.
|
||||||
|
|
||||||
|
The target flow is:
|
||||||
|
|
||||||
|
1. `internal/report` declares whether a report uses the existing full-Markdown
|
||||||
|
Scriptorium path or the GeneratedText/template path.
|
||||||
|
2. The app builds CollectedFacts, DerivedFacts, module snapshots, and the
|
||||||
|
prompt data package using the same boundaries as existing reports.
|
||||||
|
3. `internal/adapters/scriptorium` asks Scriptorium for structured JSON using
|
||||||
|
the report's prompt ID. The Scriptorium backend owns the prompt-to-schema
|
||||||
|
association.
|
||||||
|
4. Weatherreporter validates the returned JSON as typed `GeneratedText`.
|
||||||
|
5. A report-specific render context is built from selected deterministic facts,
|
||||||
|
module outputs, metadata, and GeneratedText.
|
||||||
|
6. `internal/reporttemplate` renders the final Markdown from an embedded,
|
||||||
|
human-editable template.
|
||||||
|
7. The managed Markdown report remains the canonical output and the distributor
|
||||||
|
upload source.
|
||||||
|
|
||||||
|
This structure keeps each layer understandable:
|
||||||
|
|
||||||
|
- CollectedFacts and DerivedFacts remain weather-data contracts.
|
||||||
|
- ModuleOutput remains the prompt-facing deterministic facts contract.
|
||||||
|
- GeneratedText remains LLM-written prose slots, not weather truth.
|
||||||
|
- RenderContext remains a curated template input, not a second raw data package.
|
||||||
|
- Templates own presentation order and headings, not weather derivation logic.
|
||||||
|
|
||||||
|
## Editing Surface
|
||||||
|
|
||||||
|
The desired human editing surface is file-based and report-specific. For the
|
||||||
|
hourly report, the important files should be easy to find and inspect:
|
||||||
|
|
||||||
|
- one Markdown template file for the report layout;
|
||||||
|
- one JSON schema file describing the expected GeneratedText payload;
|
||||||
|
- one report definition declaring the prompt ID, template ID, schema ID, module
|
||||||
|
composition, valid period, artifact group, and output naming policy.
|
||||||
|
|
||||||
|
The template and schema should be embedded into the binary, but they should
|
||||||
|
remain ordinary files in the repository. A maintainer should be able to tweak
|
||||||
|
headings, reorder sections, or inspect the structured prose contract without
|
||||||
|
reading unrelated orchestration code.
|
||||||
|
|
||||||
|
## Boundary Guidelines
|
||||||
|
|
||||||
|
- Keep report identity, prompt IDs, generation mode, template IDs, schema IDs,
|
||||||
|
valid periods, module composition, and output naming in `internal/report`.
|
||||||
|
- Keep Scriptorium subprocess details in `internal/adapters/scriptorium`.
|
||||||
|
- Keep Markdown template lookup and rendering in `internal/reporttemplate`.
|
||||||
|
- Keep GeneratedText validation in a narrow package or component with typed
|
||||||
|
report-specific structs.
|
||||||
|
- Keep app orchestration responsible for sequencing and artifact persistence,
|
||||||
|
not for report-specific prose or template structure.
|
||||||
|
- Keep distributor upload behavior unchanged: upload the managed Markdown
|
||||||
|
report after it exists.
|
||||||
|
- Do not let templates fetch data, derive facts, perform source selection, or
|
||||||
|
construct workspace paths.
|
||||||
|
- Do not let Scriptorium-specific flags, schema paths, or subprocess details
|
||||||
|
leak into report definitions or domain packages.
|
||||||
|
- Do not add user-provided template or schema paths in the first
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Target Acceptance Criteria
|
||||||
|
|
||||||
|
The feature is successful when:
|
||||||
|
|
||||||
|
- `weatherreporter generate hourly` produces a deterministic Markdown report
|
||||||
|
whose headings, dates, and section order come from an embedded template;
|
||||||
|
- Scriptorium fills only the report's structured GeneratedText slots;
|
||||||
|
- weatherreporter validates GeneratedText before rendering;
|
||||||
|
- the raw GeneratedText, validated GeneratedText, render context, rendered
|
||||||
|
Markdown, and metadata are inspectable after a run;
|
||||||
|
- existing daily, tomorrow, three-day, weekend, and storm reports keep their
|
||||||
|
current full-Markdown Scriptorium path until explicitly migrated;
|
||||||
|
- failures in structured generation, validation, or template rendering are
|
||||||
|
diagnosable from saved artifacts where practical;
|
||||||
|
- the managed Markdown report remains the source for optional copies and
|
||||||
|
distributor uploads;
|
||||||
|
- the implementation does not introduce a generic workflow engine, plugin
|
||||||
|
system, or broad report rewrite.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
Do not include these in the first implementation:
|
||||||
|
|
||||||
|
- migrating daily, tomorrow, three-day, weekend, or storm to GeneratedText;
|
||||||
|
- user-configurable template or local schema source overrides;
|
||||||
|
- user-defined templates or schemas;
|
||||||
|
- a generic workflow engine;
|
||||||
|
- template hot reloading;
|
||||||
|
- dynamic module selection for templates;
|
||||||
|
- distributor-specific generated-text behavior;
|
||||||
|
- prompt/schema generation from Go structs;
|
||||||
|
- scheduled hourly batch membership;
|
||||||
|
- configurable hourly report duration.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
No questions block roadmap creation.
|
||||||
|
|
||||||
|
Recommended approach: keep JSON schema enforcement in Scriptorium for retry
|
||||||
|
behavior, and also perform weatherreporter-side typed validation before
|
||||||
|
rendering. Weatherreporter should validate the returned JSON by unmarshalling
|
||||||
|
into the report's GeneratedText type and checking required fields; it should
|
||||||
|
not add a Go JSON Schema dependency in the first implementation.
|
||||||
|
|
||||||
|
Viable alternative: trust Scriptorium validation and only unmarshal into typed
|
||||||
|
Go structs without explicit required-field checks. This is simpler, but it
|
||||||
|
weakens weatherreporter's rendering contract and may make empty or malformed
|
||||||
|
LLM output harder to diagnose.
|
||||||
778
docs/roadmap/implementation.md
Normal file
778
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
# GeneratedText Hourly Report Implementation Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines the concrete implementation sequence for
|
||||||
|
`docs/roadmap/generated-text.md`. It is written for an LLM coding agent that
|
||||||
|
will implement each stage in order.
|
||||||
|
|
||||||
|
This is a future-work roadmap. Until a stage is implemented, non-roadmap docs
|
||||||
|
must not describe `GeneratedText`, embedded report templates, or
|
||||||
|
`weatherreporter generate hourly` as available behavior.
|
||||||
|
|
||||||
|
## Source Feature Roadmap
|
||||||
|
|
||||||
|
Use `docs/roadmap/generated-text.md` as the authoritative feature roadmap for
|
||||||
|
intent, terminology, target behavior, and policy choices. This document is the
|
||||||
|
step-by-step implementation plan. If target behavior changes, update
|
||||||
|
`generated-text.md` first, then update this plan.
|
||||||
|
|
||||||
|
Future implementation agents should read `generated-text.md` before starting
|
||||||
|
Stage 1. Treat that file as the conceptual contract for why the feature exists
|
||||||
|
and what final shape it should have; treat this file as the ordered work plan
|
||||||
|
for reaching that shape.
|
||||||
|
|
||||||
|
## Locked Implementation Decisions
|
||||||
|
|
||||||
|
- Implement the first GeneratedText path for the rolling next-hours report,
|
||||||
|
named `hourly`.
|
||||||
|
- Replace planned/public `near_term` naming with `hourly`; do not keep a
|
||||||
|
public `near-term` alias.
|
||||||
|
- Use report ID `hourly`, artifact group `hourly`, batch output name
|
||||||
|
`hourly.md`, and CLI command `weatherreporter generate hourly`.
|
||||||
|
- Keep the rolling report explicit-generation only; do not add scheduled batch
|
||||||
|
membership in this implementation.
|
||||||
|
- Keep the valid-period length as a package-owned constant, initially 6 hours.
|
||||||
|
- Add report generation modes:
|
||||||
|
- `scriptorium_markdown` for existing reports;
|
||||||
|
- `generated_text_template` for `hourly`.
|
||||||
|
- Keep existing reports on the current Scriptorium Markdown path.
|
||||||
|
- For generated-text reports, keep `Definition.PromptID` as the Scriptorium
|
||||||
|
prompt ID and set the hourly value to `weather.hourly_generated_text`.
|
||||||
|
- Add `TemplateID` and `GeneratedTextSchemaID` to report definitions.
|
||||||
|
- Use embedded assets under `internal/reporttemplate`.
|
||||||
|
- Use standard-library `text/template`.
|
||||||
|
- Let Scriptorium enforce its prompt-associated JSON Schema and retries;
|
||||||
|
weatherreporter validates by unmarshalling into a typed GeneratedText struct
|
||||||
|
and checking required fields. Do not add a Go JSON Schema dependency in this
|
||||||
|
implementation.
|
||||||
|
- Keep the existing preflight artifact for prompt/input inspection.
|
||||||
|
- Add managed artifacts for raw GeneratedText JSON, structured Scriptorium run
|
||||||
|
result, validated GeneratedText JSON, and render context JSON.
|
||||||
|
- Distributor notification continues to upload the managed Markdown report only.
|
||||||
|
- Preserve current CLI behavior, artifact paths, and generated output behavior
|
||||||
|
for daily, tomorrow, three-day, weekend, and storm reports.
|
||||||
|
|
||||||
|
## Stage 1: Roadmap Reconciliation And Hourly Identity
|
||||||
|
|
||||||
|
Goal: remove conflicting future instructions and establish `hourly` as the only
|
||||||
|
planned public name for the rolling next-hours report.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `docs/roadmap/generated-text.md`
|
||||||
|
- `docs/roadmap/near-term.md`
|
||||||
|
- `docs/roadmap/future.md`
|
||||||
|
- `docs/roadmap/implementation.md`
|
||||||
|
- `internal/app/app.go`
|
||||||
|
- `internal/report/definition.go`
|
||||||
|
- `internal/report/registry.go`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Replace `docs/roadmap/near-term.md` with a short superseded note pointing to
|
||||||
|
`docs/roadmap/generated-text.md`, or delete it if the repository policy at
|
||||||
|
implementation time prefers removing obsolete roadmap files.
|
||||||
|
- Ensure no roadmap instructs a future agent to implement public
|
||||||
|
`near_term` / `near-term` behavior.
|
||||||
|
- If partial `near_term` code already exists, rename it to `hourly` rather
|
||||||
|
than adding aliases:
|
||||||
|
- `ReportNearTerm` -> `ReportHourly`;
|
||||||
|
- `report.NearTerm` -> `report.Hourly`;
|
||||||
|
- CLI command `near-term` -> `hourly`;
|
||||||
|
- artifact group `near-term` -> `hourly`;
|
||||||
|
- prompt IDs and tests updated to hourly names.
|
||||||
|
- Do not add compatibility aliases for `near-term`.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Active roadmap docs use `hourly` for the rolling report.
|
||||||
|
- No active roadmap describes `near_term` as the target public report ID.
|
||||||
|
- Existing code compiles after any rename work.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "near_term|near-term|NearTerm" docs internal
|
||||||
|
go test ./internal/app ./internal/report ./internal/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 2: Report Definition Generation Mode
|
||||||
|
|
||||||
|
Goal: add generation-mode metadata to report definitions without changing the
|
||||||
|
current report generation path.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/report/definition.go`
|
||||||
|
- `internal/report/registry.go`
|
||||||
|
- `internal/report/*_report.go`
|
||||||
|
- `internal/report/period_test.go`
|
||||||
|
- `internal/state/filesystem.go`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add a `GenerationMode` type in `internal/report`.
|
||||||
|
- Add constants:
|
||||||
|
- `GenerationModeScriptoriumMarkdown`
|
||||||
|
- `GenerationModeGeneratedTextTemplate`
|
||||||
|
- Add fields to `report.Definition`:
|
||||||
|
- `GenerationMode GenerationMode`
|
||||||
|
- `TemplateID string`
|
||||||
|
- `GeneratedTextSchemaID string`
|
||||||
|
- Default or explicitly set existing reports to
|
||||||
|
`GenerationModeScriptoriumMarkdown`.
|
||||||
|
- Update report definition tests so every generated report declares a
|
||||||
|
generation mode.
|
||||||
|
- Do not wire app behavior in this stage.
|
||||||
|
- Keep existing `PromptID` semantics: it is the Scriptorium prompt ID for the
|
||||||
|
report's generation mode.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Existing report definitions retain current prompt IDs and behavior.
|
||||||
|
- Registry tests fail if a generated report omits generation mode.
|
||||||
|
- Generated-text-only fields are empty for existing Markdown reports.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/state
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 3: Hourly Report Definition, Modules, Facts, And CLI Shell
|
||||||
|
|
||||||
|
Goal: add the `hourly` report identity, rolling valid period, module
|
||||||
|
composition, facts derivation, config override aliases, and CLI command shell.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/report/definition.go`
|
||||||
|
- `internal/report/registry.go`
|
||||||
|
- new `internal/report/hourly_report.go`
|
||||||
|
- `internal/report/period_test.go`
|
||||||
|
- `internal/briefing/modules.go`
|
||||||
|
- `internal/facts/facts.go`
|
||||||
|
- `internal/facts/facts_test.go`
|
||||||
|
- `internal/config/reports.go`
|
||||||
|
- `internal/config/config_test.go`
|
||||||
|
- `internal/app/app.go`
|
||||||
|
- `internal/cli/root.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add report ID `Hourly ID = "hourly"`.
|
||||||
|
- Add comparison strategy `CompareRollingWindow = "rolling_window"`.
|
||||||
|
- Add `hourlyReportHours` constant, initially `6`.
|
||||||
|
- Add `hourlyDefinition()`:
|
||||||
|
- `Name: "Hourly Report"`
|
||||||
|
- `PromptID: "weather.hourly_generated_text"`
|
||||||
|
- `GenerationMode: GenerationModeGeneratedTextTemplate`
|
||||||
|
- `TemplateID: "hourly"`
|
||||||
|
- `GeneratedTextSchemaID: "hourly"`
|
||||||
|
- `ComparisonStrategy: CompareRollingWindow`
|
||||||
|
- `ArtifactGroup: "hourly"`
|
||||||
|
- `BatchOutputName: "hourly.md"`
|
||||||
|
- `Generated: true`
|
||||||
|
- `CompatiblePriorIDs: []ID{Hourly}`
|
||||||
|
- no morning/evening batch membership
|
||||||
|
- Resolve valid period as `[generation_time, generation_time + 6h)` in the
|
||||||
|
effective timezone.
|
||||||
|
- Add default hourly modules in order:
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `hourly_forecast`
|
||||||
|
4. `precip_timing`
|
||||||
|
5. `alert_digest`
|
||||||
|
6. `spc_convective_outlooks`
|
||||||
|
7. `area_forecast_discussion`
|
||||||
|
8. `spc_convective_discussion`
|
||||||
|
9. `weather_story`
|
||||||
|
- Configure hourly `area_forecast_discussion` options to include only:
|
||||||
|
- `key_messages`
|
||||||
|
- `short_term`
|
||||||
|
- Update module supported-report lists:
|
||||||
|
- include `report.Hourly` in all broadly compatible modules;
|
||||||
|
- include `report.Hourly` for `hourly_forecast`;
|
||||||
|
- keep daily/daypart modules incompatible.
|
||||||
|
- Add `facts.BuildDerived` support for hourly:
|
||||||
|
- valid-period hourly slice;
|
||||||
|
- precipitation timing from that slice;
|
||||||
|
- alert overlap filtering;
|
||||||
|
- SPC outlook/discussion overlap filtering;
|
||||||
|
- no daily summaries, daypart summaries, or storm summary.
|
||||||
|
- Add config report aliases:
|
||||||
|
- `hourly`
|
||||||
|
- reject or do not recognize `near_term` / `near-term`.
|
||||||
|
- Add app report kind `ReportHourly = "hourly"` and map it to
|
||||||
|
`report.Hourly`.
|
||||||
|
- Add CLI parsing and help for:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do not add `--date`, `--start`, `--end`, or duration flags.
|
||||||
|
- It is acceptable for `generate hourly` to return an explicit
|
||||||
|
"generation mode not implemented" error until the app workflow stage, but
|
||||||
|
parser and resolution tests should pass.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `report.DefaultRegistry().Lookup(report.Hourly)` succeeds.
|
||||||
|
- hourly valid period is exactly six hours and not civil-day truncated.
|
||||||
|
- hourly default module composition validates.
|
||||||
|
- daily-only modules reject hourly.
|
||||||
|
- facts derivation supports the hourly module list.
|
||||||
|
- CLI help includes `generate hourly`.
|
||||||
|
- Existing command behavior is unchanged.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/briefing ./internal/facts
|
||||||
|
go test ./internal/config ./internal/app ./internal/cli
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is large but still suitable for one implementation prompt if kept
|
||||||
|
strictly to report identity/facts/CLI shell. If it becomes unwieldy, split it
|
||||||
|
after report/facts and implement CLI/config aliases in a follow-up prompt.
|
||||||
|
|
||||||
|
## Stage 4: Embedded Template And Schema Assets
|
||||||
|
|
||||||
|
Goal: create the embedded asset package and first hourly template/schema.
|
||||||
|
|
||||||
|
Files to create:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/reporttemplate.go`
|
||||||
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
- `internal/reporttemplate/schemas/hourly.generated_text.schema.json`
|
||||||
|
- `internal/reporttemplate/reporttemplate_test.go`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Use `go:embed` for templates and schemas.
|
||||||
|
- Expose a narrow API:
|
||||||
|
- `Template(id string) (string, error)` or equivalent;
|
||||||
|
- `Schema(id string) ([]byte, error)` or equivalent;
|
||||||
|
- `Render(id string, data any) ([]byte, error)`.
|
||||||
|
- Use `text/template`.
|
||||||
|
- Register only `hourly` assets initially.
|
||||||
|
- Missing or unknown asset IDs must return actionable errors.
|
||||||
|
- Keep template functions minimal. Prefer prepared render-context strings over
|
||||||
|
complex template logic.
|
||||||
|
- Add an initial hourly JSON Schema with:
|
||||||
|
- required `summary`;
|
||||||
|
- required `timing`;
|
||||||
|
- required `impacts`;
|
||||||
|
- optional `confidence`;
|
||||||
|
- all fields strings;
|
||||||
|
- `additionalProperties: false`.
|
||||||
|
- Add an initial hourly Markdown template that is readable and deterministic.
|
||||||
|
It should include fixed headings, report valid period, selected deterministic
|
||||||
|
facts, and GeneratedText slots.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- schema asset can be looked up for tests, local validation, and out-of-band
|
||||||
|
Scriptorium registration;
|
||||||
|
- template asset can be looked up;
|
||||||
|
- template renders from a test render context;
|
||||||
|
- missing template/schema IDs fail clearly;
|
||||||
|
- no generated schema or template content is inline in Go.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/reporttemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 5: GeneratedText Contract And Render Context
|
||||||
|
|
||||||
|
Goal: add typed GeneratedText validation and a typed hourly render context.
|
||||||
|
|
||||||
|
Files to create or inspect:
|
||||||
|
|
||||||
|
- new `internal/generatedtext` package, or another narrow package if the
|
||||||
|
implementation chooses a better local name
|
||||||
|
- `internal/reporttemplate`
|
||||||
|
- `internal/module`
|
||||||
|
- `internal/briefing`
|
||||||
|
- `internal/promptinput`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add typed hourly GeneratedText:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Hourly struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Timing string `json:"timing"`
|
||||||
|
Impacts string `json:"impacts"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add validation:
|
||||||
|
- malformed JSON fails;
|
||||||
|
- empty required fields fail after trimming whitespace;
|
||||||
|
- unknown fields fail by using `json.Decoder.DisallowUnknownFields`;
|
||||||
|
- validated output is normalized to stable JSON.
|
||||||
|
- Do not add a Go JSON Schema dependency in this implementation.
|
||||||
|
- Add typed hourly render context.
|
||||||
|
- Build render context from:
|
||||||
|
- report metadata and location;
|
||||||
|
- selected module outputs from the module snapshot;
|
||||||
|
- validated hourly GeneratedText.
|
||||||
|
- Use `module.StanzaValue` or equivalent typed decoding rather than ad hoc map
|
||||||
|
traversal where practical.
|
||||||
|
- Keep render context curated; do not pass the full raw data package to the
|
||||||
|
template.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- valid GeneratedText passes and round-trips to stable JSON;
|
||||||
|
- missing required fields fail;
|
||||||
|
- unknown fields fail;
|
||||||
|
- render context has enough deterministic fields for the hourly template;
|
||||||
|
- render context construction fails clearly when required module stanzas are
|
||||||
|
missing.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/generatedtext ./internal/reporttemplate ./internal/module
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 6: State Artifacts For GeneratedText Reports
|
||||||
|
|
||||||
|
Goal: add explicit managed paths and save/load helpers for generated-text
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/state/filesystem.go`
|
||||||
|
- `internal/state/store.go`
|
||||||
|
- `internal/state/metadata.go`
|
||||||
|
- `internal/state/filesystem_test.go`
|
||||||
|
- `internal/fileutil`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Extend `state.ArtifactPaths` with:
|
||||||
|
- `GeneratedTextRaw`
|
||||||
|
- `GeneratedTextResult`
|
||||||
|
- `GeneratedText`
|
||||||
|
- `RenderContext`
|
||||||
|
- Store all four under the existing snapshots tree:
|
||||||
|
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json`
|
||||||
|
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json`
|
||||||
|
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json`
|
||||||
|
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json`
|
||||||
|
- Do not add new workspace config directories.
|
||||||
|
- Extend `state.Metadata` with optional path fields for those artifacts.
|
||||||
|
Also record `GeneratedTextSchemaID` as metadata for auditability without
|
||||||
|
copying schema contents into every run.
|
||||||
|
- Add store methods as needed, for example:
|
||||||
|
- `SaveGeneratedTextRaw`
|
||||||
|
- `SaveGeneratedTextResult`
|
||||||
|
- `SaveGeneratedText`
|
||||||
|
- `SaveRenderContext`
|
||||||
|
- Use atomic writes through existing `fileutil` helpers.
|
||||||
|
- Keep existing metadata JSON shape for Markdown-path reports, with new fields
|
||||||
|
omitted when empty.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- path tests cover all new artifact paths;
|
||||||
|
- save helpers create parent directories and write atomically;
|
||||||
|
- metadata links generated-text paths for hourly reports;
|
||||||
|
- existing report metadata tests still pass.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/state ./internal/fileutil
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 7: Scriptorium GeneratedText Run Adapter
|
||||||
|
|
||||||
|
Goal: add a GeneratedText run helper behind the Scriptorium adapter while
|
||||||
|
preserving the existing `scriptorium run` command shape.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/adapters/scriptorium/runner.go`
|
||||||
|
- `internal/adapters/scriptorium/runner_test.go`
|
||||||
|
- `docs/integrations/scriptorium.md` after implementation
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add weatherreporter-owned request/result types:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StructuredRunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
DataPackagePath string
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add `StructuredRun(ctx, req)` to the runner.
|
||||||
|
- Validate prompt ID, data package path, and output path before
|
||||||
|
subprocess execution.
|
||||||
|
- Preserve current command result capture behavior:
|
||||||
|
- full argv;
|
||||||
|
- stdout;
|
||||||
|
- stderr;
|
||||||
|
- truncation flags;
|
||||||
|
- exit code;
|
||||||
|
- output path.
|
||||||
|
- Do not change current `Render` or `Run` methods.
|
||||||
|
- Scriptorium selects the structured output schema from the backend prompt
|
||||||
|
configuration associated with `PromptID`; do not pass a schema path at
|
||||||
|
runtime.
|
||||||
|
- Use the same `scriptorium run` command form as Markdown report generation.
|
||||||
|
The only material difference is that `--out` points to the raw GeneratedText
|
||||||
|
JSON artifact path rather than a Markdown report path.
|
||||||
|
- Use this argv shape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scriptorium run \
|
||||||
|
--prompt <prompt_id> \
|
||||||
|
--input data_package=<path> \
|
||||||
|
--out <generated_text_raw_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do not add `--format json` or any schema flag. Structured output is selected
|
||||||
|
by Scriptorium's internal prompt configuration.
|
||||||
|
- If local Scriptorium documentation needs updating after implementation,
|
||||||
|
update `docs/integrations/scriptorium.md` to describe this contract.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- argv construction tests prove `scriptorium run` is invoked without
|
||||||
|
`--format` or schema flags and with `--out <generated_text_raw_path>`;
|
||||||
|
- nonzero exits return captured result plus error;
|
||||||
|
- missing required fields fail before subprocess execution;
|
||||||
|
- current Scriptorium render/run tests still pass;
|
||||||
|
- tokens/secrets are not added to argv or errors.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/adapters/scriptorium
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 8: GeneratedText App Workflow Success Path
|
||||||
|
|
||||||
|
Goal: wire the successful generated-text/template path into app orchestration
|
||||||
|
for hourly while preserving the existing path for all current reports.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/app/app.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/adapters/scriptorium`
|
||||||
|
- `internal/reporttemplate`
|
||||||
|
- `internal/generatedtext`
|
||||||
|
- `internal/state`
|
||||||
|
- `internal/promptinput`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Extend the app `Renderer` interface, or introduce a second narrow interface,
|
||||||
|
so tests can fake structured generation.
|
||||||
|
- Branch report generation by `req.Resolved.Definition.GenerationMode`.
|
||||||
|
- For `GenerationModeScriptoriumMarkdown`, keep the existing flow unchanged.
|
||||||
|
- For the successful `GenerationModeGeneratedTextTemplate` path:
|
||||||
|
1. run existing preflight and save preflight artifact;
|
||||||
|
2. call Scriptorium structured generation with raw output path
|
||||||
|
`GeneratedTextRaw`;
|
||||||
|
3. save structured Scriptorium command result to `GeneratedTextResult`;
|
||||||
|
4. validate raw JSON into typed GeneratedText;
|
||||||
|
5. save normalized GeneratedText to `GeneratedText`;
|
||||||
|
6. build hourly render context from metadata, module snapshot, and
|
||||||
|
GeneratedText;
|
||||||
|
7. save render context JSON;
|
||||||
|
8. render Markdown via embedded template to managed report path;
|
||||||
|
9. save final metadata with all generated-text paths and the schema ID;
|
||||||
|
- Defer distributor notification and optional output copy regression coverage
|
||||||
|
to Stage 10.
|
||||||
|
- Keep failure-specific persistence tests minimal in this stage; Stage 9 owns
|
||||||
|
failure artifact behavior.
|
||||||
|
- Keep Recent Changes empty for hourly.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- hourly generation reaches Scriptorium structured generation, validates
|
||||||
|
GeneratedText, renders Markdown, and persists final metadata;
|
||||||
|
- daily/tomorrow/three-day/weekend/storm generation still uses existing
|
||||||
|
Markdown Scriptorium path;
|
||||||
|
- generated-text artifacts are linked from metadata for successful hourly runs;
|
||||||
|
- app tests cover the successful hourly workflow with fake structured
|
||||||
|
Scriptorium output.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/app ./internal/state ./internal/reporttemplate ./internal/generatedtext
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt if failure behavior is
|
||||||
|
kept to the later stages.
|
||||||
|
|
||||||
|
## Stage 9: GeneratedText Failure Artifact Persistence
|
||||||
|
|
||||||
|
Goal: make generated-text failures inspectable without changing successful
|
||||||
|
behavior from Stage 8.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/app/app.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/adapters/scriptorium`
|
||||||
|
- `internal/generatedtext`
|
||||||
|
- `internal/reporttemplate`
|
||||||
|
- `internal/state`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add focused app tests for failures at each generated-text step:
|
||||||
|
- preflight failure;
|
||||||
|
- structured Scriptorium generation failure;
|
||||||
|
- GeneratedText validation failure;
|
||||||
|
- render context construction failure;
|
||||||
|
- Markdown template rendering failure.
|
||||||
|
- Ensure failures preserve available artifacts:
|
||||||
|
- failed preflight saves preflight output when possible;
|
||||||
|
- failed structured generation saves command result when available;
|
||||||
|
- validation failures preserve raw GeneratedText output when available;
|
||||||
|
- render-context failures preserve validated GeneratedText when already
|
||||||
|
available;
|
||||||
|
- template failures preserve GeneratedText and render context when already
|
||||||
|
built.
|
||||||
|
- Return actionable errors that include report ID, RunID, and operation context
|
||||||
|
without exposing data package contents or secrets.
|
||||||
|
- Do not run distributor notification after any generated-text failure.
|
||||||
|
- Do not create optional `--out` copies after any generated-text failure.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- every generated-text failure point has an app-level regression test;
|
||||||
|
- partial artifacts are persisted where available and linked from metadata when
|
||||||
|
metadata can be safely written;
|
||||||
|
- failed hourly runs do not notify distributor;
|
||||||
|
- existing Markdown-generation failure behavior is unchanged.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/app ./internal/state ./internal/generatedtext ./internal/reporttemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 10: GeneratedText Output Copy And Notification Regression Coverage
|
||||||
|
|
||||||
|
Goal: preserve existing user-facing output behavior and distributor boundaries
|
||||||
|
for generated-text reports.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/app/app.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/adapters/distributor`
|
||||||
|
- `internal/state`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Keep `--out` copy behavior unchanged after the managed Markdown report
|
||||||
|
exists.
|
||||||
|
- Keep `--out-dir` behavior unchanged for batch paths if hourly is ever run
|
||||||
|
through a batch helper during tests.
|
||||||
|
- Run distributor notification, if enabled, only after:
|
||||||
|
- structured generation succeeds;
|
||||||
|
- GeneratedText validates;
|
||||||
|
- Markdown template rendering succeeds;
|
||||||
|
- final metadata is saved.
|
||||||
|
- Use the managed Markdown report path as the distributor source.
|
||||||
|
- Never use optional `--out` or `--out-dir` copies as distributor source files.
|
||||||
|
- Keep distributor request construction template-driven; do not add
|
||||||
|
hourly-specific path branching.
|
||||||
|
- Keep Recent Changes empty for hourly in this implementation.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `--out` copies the managed generated-text Markdown report after success;
|
||||||
|
- distributor notification receives the managed Markdown report path;
|
||||||
|
- distributor notification is not called on any failure from Stage 9;
|
||||||
|
- enabled distributor failure makes hourly generation fail with the same
|
||||||
|
semantics as existing generated reports;
|
||||||
|
- existing daily/tomorrow/three-day/weekend/storm distributor behavior is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/app ./internal/adapters/distributor ./internal/state
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 11: End-To-End CLI, Config, And Prompt Package Coverage
|
||||||
|
|
||||||
|
Goal: prove the public hourly workflow and configured module overrides work
|
||||||
|
through the CLI/app boundary.
|
||||||
|
|
||||||
|
Files to inspect:
|
||||||
|
|
||||||
|
- `internal/cli/root.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
- `internal/config/reports.go`
|
||||||
|
- `internal/config/config_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `examples/config.yml`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add CLI command tests for `generate hourly`.
|
||||||
|
- Reject `generate near-term`.
|
||||||
|
- Reject hourly `--date`, `--start`, `--end`, and duration-like flags.
|
||||||
|
- Add config override tests for `reports.hourly.deterministic_modules`.
|
||||||
|
- Ensure `near_term` and `near-term` report config keys are rejected.
|
||||||
|
- Add app/CLI workflow tests using fake Scriptorium structured output.
|
||||||
|
- Update examples only if they enumerate report module overrides.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `weatherreporter --help` lists `generate hourly`;
|
||||||
|
- `generate near-term` is not accepted;
|
||||||
|
- hourly config overrides validate compatible modules;
|
||||||
|
- incompatible daily/daypart modules fail clearly for hourly;
|
||||||
|
- examples load successfully.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/cli ./internal/config ./internal/app
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 12: Implemented Documentation
|
||||||
|
|
||||||
|
Goal: document implemented behavior after the code exists.
|
||||||
|
|
||||||
|
Files to inspect and update:
|
||||||
|
|
||||||
|
- `docs/cli.md`
|
||||||
|
- `docs/operations.md`
|
||||||
|
- `docs/config.md`
|
||||||
|
- `docs/internal/app-orchestration.md`
|
||||||
|
- `docs/internal/report-registry.md`
|
||||||
|
- `docs/internal/briefing.md`
|
||||||
|
- `docs/internal/prompt-input.md`
|
||||||
|
- `docs/internal/scriptorium-adapter.md`
|
||||||
|
- `docs/internal/state.md`
|
||||||
|
- new `docs/internal/reporttemplate.md`
|
||||||
|
- `docs/integrations/scriptorium.md`
|
||||||
|
- `docs/roadmap/future.md`
|
||||||
|
- `docs/roadmap/near-term.md`
|
||||||
|
- `examples/config.yml`, only if examples changed
|
||||||
|
|
||||||
|
Documentation requirements:
|
||||||
|
|
||||||
|
- Non-roadmap docs describe only implemented hourly and GeneratedText behavior.
|
||||||
|
- `docs/cli.md` documents `generate hourly`, not `generate near-term`.
|
||||||
|
- `docs/config.md` documents `reports.hourly` only if override support is
|
||||||
|
implemented.
|
||||||
|
- Internal docs distinguish:
|
||||||
|
- CollectedFacts;
|
||||||
|
- DerivedFacts;
|
||||||
|
- ModuleOutput;
|
||||||
|
- GeneratedText;
|
||||||
|
- RenderContext.
|
||||||
|
- Scriptorium integration docs include the structured-output command actually
|
||||||
|
used by the adapter.
|
||||||
|
- State docs list generated-text artifacts and metadata links.
|
||||||
|
- `docs/roadmap/near-term.md` is deleted or marked superseded.
|
||||||
|
- Deferred migrations for other report types stay under roadmap docs only.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- No non-roadmap docs describe unimplemented generated-text migrations.
|
||||||
|
- No active docs describe `near_term` as the target public report.
|
||||||
|
- Maintained examples remain valid.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/config
|
||||||
|
rg -n "near_term|near-term|NearTerm" docs internal examples
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 13: Final Validation
|
||||||
|
|
||||||
|
Goal: run full validation and perform targeted manual checks.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual checks:
|
||||||
|
|
||||||
|
- `weatherreporter --help` lists `generate hourly`.
|
||||||
|
- `weatherreporter --help` does not list `generate near-term`.
|
||||||
|
- generated hourly Markdown has deterministic headings and section order.
|
||||||
|
- GeneratedText JSON is persisted separately from deterministic facts.
|
||||||
|
- render context JSON is persisted and readable.
|
||||||
|
- embedded hourly template is readable and easy to edit.
|
||||||
|
- embedded hourly schema is readable and easy to edit.
|
||||||
|
- existing Markdown-generation reports still run through the previous
|
||||||
|
Scriptorium `run --out <markdown>` path.
|
||||||
|
- distributor notification, when enabled, uploads the managed Markdown report.
|
||||||
|
|
||||||
|
This stage is small enough for one implementation prompt.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
Do not include these in the first implementation:
|
||||||
|
|
||||||
|
- migrating daily, tomorrow, three-day, weekend, or storm to GeneratedText;
|
||||||
|
- user-configurable template or local schema source overrides;
|
||||||
|
- user-defined templates or schemas;
|
||||||
|
- a generic workflow engine;
|
||||||
|
- template hot reloading;
|
||||||
|
- dynamic module selection for templates;
|
||||||
|
- prompt/schema generation from Go structs;
|
||||||
|
- scheduled hourly batch membership;
|
||||||
|
- configurable hourly report duration;
|
||||||
|
- rolling-window Recent Changes for hourly;
|
||||||
|
- distributor-specific generated-text behavior.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None block implementation.
|
||||||
363
docs/roadmap/near-term.md
Normal file
363
docs/roadmap/near-term.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# Near-Term Report Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines planned work to add a rolling `near_term` report focused
|
||||||
|
on the next several hours. The feature is not implemented yet, so this document
|
||||||
|
lives under `docs/roadmap/`.
|
||||||
|
|
||||||
|
The goal is a frequently generated report that helps readers understand what
|
||||||
|
matters in the immediate future: current conditions, hourly evolution,
|
||||||
|
precipitation timing, applicable hazards, applicable SPC risk products, and the
|
||||||
|
most relevant short-term narrative context.
|
||||||
|
|
||||||
|
## Target Behavior
|
||||||
|
|
||||||
|
Add a generated report with:
|
||||||
|
|
||||||
|
- report ID: `near_term`
|
||||||
|
- display name: `Near-Term Report`
|
||||||
|
- prompt ID: `weather.near_term_report`
|
||||||
|
- artifact group: `near-term`
|
||||||
|
- batch output name: `near-term.md`
|
||||||
|
- default valid-period length: 6 hours
|
||||||
|
- valid period: `[generation_time, generation_time + nearTermHours)`
|
||||||
|
|
||||||
|
The report should use a package-owned constant for the valid-period length, for
|
||||||
|
example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const nearTermHours = 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not make the duration configurable in the first implementation. The constant
|
||||||
|
exists so the value can be changed later to 4 or 8 hours without changing
|
||||||
|
valid-period logic in multiple places.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
- The report ID is `near_term`.
|
||||||
|
- The report is rolling and generation-time anchored, not civil-day anchored.
|
||||||
|
- The first implementation covers the next 6 hours through a constant.
|
||||||
|
- The report should be generated explicitly by CLI command before deciding
|
||||||
|
whether it belongs in scheduled batches.
|
||||||
|
- The first implementation should declare `rolling_window` comparison policy
|
||||||
|
but should not emit Recent Changes output for `near_term`.
|
||||||
|
- Alert and SPC products should be included only when their valid periods
|
||||||
|
overlap the resolved near-term report period.
|
||||||
|
- SPC discussion text should keep the existing categorical-risk threshold rule
|
||||||
|
and should still require overlap with the near-term report period.
|
||||||
|
- The AFD stanza should include key messages and short term text by default.
|
||||||
|
- Daily-only modules should not be forced into this report.
|
||||||
|
|
||||||
|
## Report Definition
|
||||||
|
|
||||||
|
Add a report definition under `internal/report`, preferably in a focused
|
||||||
|
`near_term_report.go` file.
|
||||||
|
|
||||||
|
Definition fields:
|
||||||
|
|
||||||
|
- `ID`: `NearTerm`
|
||||||
|
- `Name`: `Near-Term Report`
|
||||||
|
- `PromptID`: `weather.near_term_report`
|
||||||
|
- `ComparisonStrategy`: `rolling_window`
|
||||||
|
- `ArtifactGroup`: `near-term`
|
||||||
|
- `BatchOutputName`: `near-term.md`
|
||||||
|
- `Generated`: `true`
|
||||||
|
- `CompatiblePriorIDs`: `[]ID{NearTerm}`
|
||||||
|
- `Modules`: near-term module list below
|
||||||
|
- `resolve`: rolling near-term resolver
|
||||||
|
|
||||||
|
Valid-period resolver:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: localNow,
|
||||||
|
End: localNow.Add(nearTermHours * time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation should use idiomatic package-local constants and avoid
|
||||||
|
duplicating duration literals in tests or app code.
|
||||||
|
|
||||||
|
## Default Module Composition
|
||||||
|
|
||||||
|
Default module order should be:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `hourly_forecast`
|
||||||
|
4. `precip_timing`
|
||||||
|
5. `alert_digest`
|
||||||
|
6. `spc_convective_outlooks`
|
||||||
|
7. `area_forecast_discussion`
|
||||||
|
8. `spc_convective_discussion`
|
||||||
|
9. `weather_story`
|
||||||
|
|
||||||
|
`area_forecast_discussion` should use options equivalent to:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not include these daily/daypart-oriented modules initially:
|
||||||
|
|
||||||
|
- `derived_daily_summary`
|
||||||
|
- `derived_daypart_summaries`
|
||||||
|
- `outdoor_windows`
|
||||||
|
- `tomorrow_planning`
|
||||||
|
|
||||||
|
If the report needs deterministic summary facts later, add a purpose-built
|
||||||
|
module such as `derived_near_term_summary` rather than stretching daily modules
|
||||||
|
into a rolling sub-daily context.
|
||||||
|
|
||||||
|
## Prompt Package Shape
|
||||||
|
|
||||||
|
The existing prompt-input category layout should remain unchanged:
|
||||||
|
|
||||||
|
- `applicable_risk_products`
|
||||||
|
- `derived_summaries`
|
||||||
|
- `narrative_products`
|
||||||
|
- `raw_data`
|
||||||
|
|
||||||
|
Near-term output is expected to emphasize:
|
||||||
|
|
||||||
|
- `current_conditions` and `hourly_forecast` under `raw_data`
|
||||||
|
- `precip_timing` under `derived_summaries`
|
||||||
|
- `alert_digest` and `spc_convective_outlooks` under
|
||||||
|
`applicable_risk_products`
|
||||||
|
- `area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story` under `narrative_products`
|
||||||
|
|
||||||
|
Module interval fields should use the existing prompt-facing
|
||||||
|
`period_begins` / `period_ends` convention.
|
||||||
|
|
||||||
|
## Overlap And Filtering Rules
|
||||||
|
|
||||||
|
Hourly forecast:
|
||||||
|
|
||||||
|
- use only hourly periods overlapping the near-term valid period;
|
||||||
|
- preserve hourly period order;
|
||||||
|
- do not include the full daily forecast.
|
||||||
|
|
||||||
|
Narrative products:
|
||||||
|
|
||||||
|
- AFD key messages and short term text may be included because the AFD is an
|
||||||
|
official short-term forecast discussion product;
|
||||||
|
- narrative forecast periods are not included by default unless a later prompt
|
||||||
|
test shows they improve near-term output.
|
||||||
|
|
||||||
|
Alerts:
|
||||||
|
|
||||||
|
- include active alert overlaps only when the alert overlaps the near-term
|
||||||
|
valid period;
|
||||||
|
- if alerts were checked successfully and no alerts overlap, emit checked empty
|
||||||
|
alert context through existing module behavior.
|
||||||
|
|
||||||
|
SPC outlooks:
|
||||||
|
|
||||||
|
- include only retained outlooks whose valid periods overlap the near-term
|
||||||
|
valid period;
|
||||||
|
- do not include non-overlapping outlooks even if they are severe;
|
||||||
|
- keep current prompt-facing field exclusions for geometry, severity rank,
|
||||||
|
expiration time, and source URL.
|
||||||
|
|
||||||
|
SPC discussion:
|
||||||
|
|
||||||
|
- include discussion only for SPC days where a retained overlapping categorical
|
||||||
|
outlook has severity rank at least `3`;
|
||||||
|
- do not include discussion for low-risk, non-categorical-only, or
|
||||||
|
non-overlapping outlooks.
|
||||||
|
|
||||||
|
Weather story:
|
||||||
|
|
||||||
|
- include when available under current optional-source behavior;
|
||||||
|
- do not require the story valid period to exactly match the near-term period
|
||||||
|
unless later testing shows stale stories are a problem.
|
||||||
|
|
||||||
|
## Implementation Stages
|
||||||
|
|
||||||
|
### Stage 1: Report Registry
|
||||||
|
|
||||||
|
Goal: add the `near_term` report definition and valid-period resolver.
|
||||||
|
|
||||||
|
Files to inspect or update:
|
||||||
|
|
||||||
|
- `internal/report/definition.go`
|
||||||
|
- `internal/report/registry.go`
|
||||||
|
- `internal/report/period.go`
|
||||||
|
- new `internal/report/near_term_report.go`
|
||||||
|
- `internal/report/period_test.go`
|
||||||
|
- `docs/internal/report-registry.md` after implementation
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `report.DefaultRegistry().Lookup(report.NearTerm)` succeeds.
|
||||||
|
- `report.Registry.All()` includes the report in a stable order.
|
||||||
|
- resolving the report at a fixed generation time produces a half-open
|
||||||
|
six-hour period.
|
||||||
|
- report metadata and RunID include `near_term`.
|
||||||
|
|
||||||
|
### Stage 2: Module Compatibility And Defaults
|
||||||
|
|
||||||
|
Goal: allow existing relevant modules to build for `near_term` and define the
|
||||||
|
default near-term composition.
|
||||||
|
|
||||||
|
Files to inspect or update:
|
||||||
|
|
||||||
|
- `internal/briefing/modules.go`
|
||||||
|
- `internal/report/near_term_report.go`
|
||||||
|
- `internal/briefing/*_module_test.go`
|
||||||
|
- `internal/module/module_test.go`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- all default near-term modules validate and build from appropriate test facts;
|
||||||
|
- daily-only modules remain incompatible unless intentionally expanded;
|
||||||
|
- `area_forecast_discussion` defaults to key messages and short term only for
|
||||||
|
this report.
|
||||||
|
|
||||||
|
### Stage 3: Derived Facts
|
||||||
|
|
||||||
|
Goal: make `internal/facts` derive report-period facts for `near_term`.
|
||||||
|
|
||||||
|
Files to inspect or update:
|
||||||
|
|
||||||
|
- `internal/facts/facts.go`
|
||||||
|
- `internal/facts/facts_test.go`
|
||||||
|
- `internal/forecast` selection helpers, if needed
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- valid-period hourly periods are sliced to the six-hour window;
|
||||||
|
- precipitation timing is built from that six-hour hourly slice;
|
||||||
|
- alert overlaps use the near-term valid period;
|
||||||
|
- SPC outlook and discussion derivation use the near-term valid period;
|
||||||
|
- no daily summaries or daypart summaries are required for the default
|
||||||
|
near-term module set.
|
||||||
|
|
||||||
|
### Stage 4: CLI And App Wiring
|
||||||
|
|
||||||
|
Goal: add explicit generation support without changing existing commands.
|
||||||
|
|
||||||
|
Files to inspect or update:
|
||||||
|
|
||||||
|
- `internal/app`
|
||||||
|
- `internal/cli`
|
||||||
|
- `cmd/weatherreporter/main.go`
|
||||||
|
- `docs/cli.md` after implementation
|
||||||
|
|
||||||
|
Expected command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- command maps to `report.NearTerm`;
|
||||||
|
- no `--date`, `--start`, or `--end` flags are added for this report;
|
||||||
|
- existing `generate daily`, `generate tomorrow`, `generate three-day`,
|
||||||
|
`generate weekend`, and `generate storm` behavior is unchanged;
|
||||||
|
- CLI help lists the new command.
|
||||||
|
|
||||||
|
### Stage 5: Prompt Input And State Coverage
|
||||||
|
|
||||||
|
Goal: prove the managed module snapshot and data package contain the intended
|
||||||
|
near-term stanzas.
|
||||||
|
|
||||||
|
Files to inspect or update:
|
||||||
|
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/promptinput`
|
||||||
|
- `internal/state`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- generated module snapshot includes the near-term default module list in
|
||||||
|
order;
|
||||||
|
- data package uses existing categories;
|
||||||
|
- alert/SPC stanzas are omitted or checked-empty according to current module
|
||||||
|
behavior and valid-period overlap;
|
||||||
|
- report metadata preserves canonical `valid_period`;
|
||||||
|
- module interval labels use `period_begins` and `period_ends`.
|
||||||
|
|
||||||
|
### Stage 6: Documentation And Examples
|
||||||
|
|
||||||
|
Goal: document implemented behavior only after the code exists.
|
||||||
|
|
||||||
|
Files to inspect or update after implementation:
|
||||||
|
|
||||||
|
- `docs/cli.md`
|
||||||
|
- `docs/internal/report-registry.md`
|
||||||
|
- `docs/internal/module.md`
|
||||||
|
- `docs/internal/facts.md`
|
||||||
|
- `docs/internal/briefing.md`
|
||||||
|
- `docs/config.md` only if report module overrides need an example update
|
||||||
|
- `examples/config.yml` only if it lists report module overrides
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- non-roadmap docs describe `near_term` only after implementation;
|
||||||
|
- future or deferred near-term summary modules remain under roadmap docs;
|
||||||
|
- examples, if updated, load through existing config tests.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
Focused tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/briefing ./internal/facts
|
||||||
|
go test ./internal/app ./internal/cli ./internal/promptinput
|
||||||
|
```
|
||||||
|
|
||||||
|
Full validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Important coverage:
|
||||||
|
|
||||||
|
- six-hour valid-period resolution from a fixed generation time;
|
||||||
|
- timezone-aware period start and end;
|
||||||
|
- no civil-day truncation;
|
||||||
|
- hourly periods selected by overlap with the rolling window;
|
||||||
|
- alert overlap filtering;
|
||||||
|
- SPC outlook filtering by overlap;
|
||||||
|
- SPC discussion filtering by overlap plus categorical severity threshold;
|
||||||
|
- AFD key messages and short term included;
|
||||||
|
- daily-only modules rejected or absent from default near-term composition;
|
||||||
|
- distributor bundle path templates render sensibly for a sub-daily report,
|
||||||
|
especially valid start time variables.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
Do not include these in the first implementation:
|
||||||
|
|
||||||
|
- user-configurable near-term duration;
|
||||||
|
- adding `near_term` to scheduled morning or evening batches;
|
||||||
|
- a dedicated `derived_near_term_summary` module;
|
||||||
|
- narrative forecast periods by default;
|
||||||
|
- separate AFD section modules;
|
||||||
|
- near-term-specific Recent Changes comparison output;
|
||||||
|
- CLI flags for custom near-term duration.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None block the roadmap.
|
||||||
|
|
||||||
|
Recommendation: implement the first version as explicit `generate near-term`
|
||||||
|
only, with no scheduled batch membership. That keeps the user-visible behavior
|
||||||
|
small while the prompt and generated report are tested.
|
||||||
|
|
||||||
|
Viable alternative: add `near_term` to a frequent scheduled batch immediately.
|
||||||
|
That may be useful operationally, but it should wait until the report cadence
|
||||||
|
and downstream distributor behavior are clear.
|
||||||
@@ -21,7 +21,7 @@ notify:
|
|||||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
failure_policy: error
|
failure_policy: error
|
||||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
pipeline_id_template: "weatherreporter.{report_id}"
|
||||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||||
report_path_templates:
|
report_path_templates:
|
||||||
@@ -77,6 +77,7 @@ reports:
|
|||||||
- derived_daypart_summaries
|
- derived_daypart_summaries
|
||||||
- precip_timing
|
- precip_timing
|
||||||
- alert_digest
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
@@ -84,6 +85,7 @@ reports:
|
|||||||
- key_messages
|
- key_messages
|
||||||
- short_term
|
- short_term
|
||||||
- long_term
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
|||||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
}
|
}
|
||||||
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
if required {
|
if required {
|
||||||
|
|||||||
@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.WeatherStory.UpdatedAt == nil {
|
if bundle.WeatherStory.UpdatedAt == nil {
|
||||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||||
}
|
}
|
||||||
if len(bundle.Sources) != 7 {
|
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
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 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
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") {
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
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) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
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") {
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
t.Fatalf("request %q missing units=us", rawURL)
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
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) {
|
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) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -370,6 +460,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ type ReportKind string
|
|||||||
const (
|
const (
|
||||||
ReportDaily ReportKind = "daily"
|
ReportDaily ReportKind = "daily"
|
||||||
ReportTomorrow ReportKind = "tomorrow"
|
ReportTomorrow ReportKind = "tomorrow"
|
||||||
|
ReportNearTerm ReportKind = "near-term"
|
||||||
ReportThreeDay ReportKind = "three-day"
|
ReportThreeDay ReportKind = "three-day"
|
||||||
ReportWeekend ReportKind = "weekend"
|
ReportWeekend ReportKind = "weekend"
|
||||||
ReportStorm ReportKind = "storm"
|
ReportStorm ReportKind = "storm"
|
||||||
@@ -386,6 +387,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
|||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case ReportTomorrow:
|
case ReportTomorrow:
|
||||||
return report.DailyTomorrow, nil
|
return report.DailyTomorrow, nil
|
||||||
|
case ReportNearTerm:
|
||||||
|
return report.NearTerm, nil
|
||||||
case ReportThreeDay:
|
case ReportThreeDay:
|
||||||
return report.ThreeDay, nil
|
return report.ThreeDay, nil
|
||||||
case ReportWeekend:
|
case ReportWeekend:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -39,6 +40,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."}}}`))
|
_, _ = 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":
|
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"}}`))
|
_, _ = 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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -167,9 +170,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
!strings.Contains(string(data), "current_conditions:") ||
|
!strings.Contains(string(data), "current_conditions:") ||
|
||||||
!strings.Contains(string(data), "narrative_forecast:") ||
|
!strings.Contains(string(data), "narrative_forecast:") ||
|
||||||
!strings.Contains(string(data), "hourly_forecast:") ||
|
!strings.Contains(string(data), "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))
|
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:") {
|
if strings.Contains(string(data), "source_warnings:") {
|
||||||
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
|
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
|
||||||
}
|
}
|
||||||
@@ -182,9 +189,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
storyIndex := strings.Index(string(data), " weather_story:")
|
storyIndex := strings.Index(string(data), " weather_story:")
|
||||||
currentIndex := strings.Index(string(data), " current_conditions:")
|
currentIndex := strings.Index(string(data), " current_conditions:")
|
||||||
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
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 < 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))
|
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
|
||||||
}
|
}
|
||||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||||
@@ -197,6 +205,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
||||||
t.Fatal("data package metadata stanza missing")
|
t.Fatal("data package metadata stanza missing")
|
||||||
}
|
}
|
||||||
|
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
|
||||||
|
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)
|
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
|
||||||
if !ok || current["condition_text"] != "Clear" {
|
if !ok || current["condition_text"] != "Clear" {
|
||||||
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
||||||
@@ -244,6 +257,279 @@ 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: categorical severity_rank >= 3",
|
||||||
|
" label_text: Slight Risk",
|
||||||
|
" period_begins:",
|
||||||
|
" period_ends:",
|
||||||
|
" 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, 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)
|
||||||
|
}
|
||||||
|
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
||||||
|
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") {
|
||||||
|
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, 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 TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
|
||||||
|
server := nearTermBundleServer(t)
|
||||||
|
cfg := dailyTestConfig(t, server)
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportNearTerm,
|
||||||
|
}, mustParse("2026-05-29T08:30:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "near-term.md")
|
||||||
|
renderer := successfulRenderer("# Near-Term Report\n")
|
||||||
|
|
||||||
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: resolved,
|
||||||
|
OutputPath: outputPath,
|
||||||
|
Renderer: renderer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
|
||||||
|
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
|
||||||
|
}
|
||||||
|
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
|
||||||
|
t.Fatalf("valid period start = %s, want rolling window start", got)
|
||||||
|
}
|
||||||
|
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
|
||||||
|
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
|
||||||
|
}
|
||||||
|
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
|
||||||
|
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
|
||||||
|
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
|
||||||
|
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
|
||||||
|
}
|
||||||
|
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||||
|
if result.OutputPath != outputPath {
|
||||||
|
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
|
||||||
|
}
|
||||||
|
copiedReport, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output copy: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(copiedReport), "# Near-Term Report") {
|
||||||
|
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantModules := []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
}
|
||||||
|
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
|
||||||
|
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
|
||||||
|
}
|
||||||
|
|
||||||
|
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode hourly forecast: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || len(hourly.Periods) != 5 {
|
||||||
|
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly)
|
||||||
|
}
|
||||||
|
hourlyJSON := mustMarshalString(t, hourly)
|
||||||
|
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") {
|
||||||
|
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON)
|
||||||
|
}
|
||||||
|
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
|
||||||
|
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode precip timing: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip)
|
||||||
|
}
|
||||||
|
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||||
|
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0])
|
||||||
|
}
|
||||||
|
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode alert digest: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode SPC outlooks: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" {
|
||||||
|
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks)
|
||||||
|
}
|
||||||
|
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode SPC discussion: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
|
||||||
|
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode AFD: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." {
|
||||||
|
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
|
||||||
|
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items)
|
||||||
|
}
|
||||||
|
data := readDataPackageForTest(t, result)
|
||||||
|
text := string(data)
|
||||||
|
for _, want := range []string{
|
||||||
|
"id: near_term",
|
||||||
|
"prompt_id: weather.near_term_report",
|
||||||
|
"valid_period:",
|
||||||
|
"recent_changes:",
|
||||||
|
" items: []",
|
||||||
|
" alert_digest:",
|
||||||
|
" spc_convective_outlooks:",
|
||||||
|
" precip_timing:",
|
||||||
|
" area_forecast_discussion:",
|
||||||
|
" spc_convective_discussion:",
|
||||||
|
" weather_story:",
|
||||||
|
" current_conditions:",
|
||||||
|
" hourly_forecast:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("data package missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
riskIndex := strings.Index(text, " applicable_risk_products:")
|
||||||
|
derivedIndex := strings.Index(text, " derived_summaries:")
|
||||||
|
narrativeIndex := strings.Index(text, " narrative_products:")
|
||||||
|
rawIndex := strings.Index(text, " raw_data:")
|
||||||
|
alertIndex := strings.Index(text, " alert_digest:")
|
||||||
|
precipIndex := strings.Index(text, " precip_timing:")
|
||||||
|
afdIndex := strings.Index(text, " area_forecast_discussion:")
|
||||||
|
currentIndex := strings.Index(text, " current_conditions:")
|
||||||
|
hourlyIndex := strings.Index(text, " hourly_forecast:")
|
||||||
|
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||||
|
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := dailyTestConfig(t, server)
|
cfg := dailyTestConfig(t, server)
|
||||||
@@ -1057,23 +1343,59 @@ func TestInspectMissingMetadata(t *testing.T) {
|
|||||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
now := mustParse("2026-05-29T18:00:00-05:00")
|
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
wantID report.ID
|
||||||
|
wantPrompt string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
requestDate time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tomorrow",
|
||||||
|
kind: ReportTomorrow,
|
||||||
|
wantID: report.DailyTomorrow,
|
||||||
|
wantPrompt: "weather.daily_report",
|
||||||
|
wantStart: "2026-05-30T00:00:00-05:00",
|
||||||
|
wantEnd: "2026-05-31T00:00:00-05:00",
|
||||||
|
requestDate: time.Time{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "near-term",
|
||||||
|
kind: ReportNearTerm,
|
||||||
|
wantID: report.NearTerm,
|
||||||
|
wantPrompt: "weather.near_term_report",
|
||||||
|
wantStart: "2026-05-29T08:00:00-05:00",
|
||||||
|
wantEnd: "2026-05-29T14:00:00-05:00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportTomorrow,
|
Report: tt.kind,
|
||||||
|
Date: tt.requestDate,
|
||||||
}, now)
|
}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
if resolved.Definition.ID != report.DailyTomorrow {
|
if resolved.Definition.ID != tt.wantID {
|
||||||
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
|
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
|
||||||
}
|
}
|
||||||
if resolved.Definition.PromptID != "weather.daily_report" {
|
if resolved.Definition.PromptID != tt.wantPrompt {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
|
||||||
}
|
}
|
||||||
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
|
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
|
||||||
t.Fatalf("valid start date = %s, want 2026-05-30", got)
|
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
|
||||||
|
}
|
||||||
|
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
|
||||||
|
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1438,29 @@ func moduleIDsForTest(ids []module.ID) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
|
||||||
|
ids := make([]module.ID, 0, len(snapshot.Outputs))
|
||||||
|
for _, output := range snapshot.Outputs {
|
||||||
|
ids = append(ids, output.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalString(t *testing.T, value any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal value: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func dailyBundleServer(t *testing.T) *httptest.Server {
|
func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -1134,6 +1478,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."}}}`))
|
_, _ = 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":
|
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"}}`))
|
_, _ = 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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -1142,6 +1488,40 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nearTermBundleServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/observations":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T13:20:00Z","conditionCode":3}}`))
|
||||||
|
case "/conditions/current":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
|
||||||
|
case "/forecast/hourly":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
|
||||||
|
case "/forecast/narrative":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
|
||||||
|
case "/alerts/active":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
|
||||||
|
case "/discussion":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
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) {
|
func TestResolveGenerateStorm(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
@@ -1335,6 +1715,65 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
|||||||
return cfg
|
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 assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
for name, value := range values {
|
||||||
|
if name == "metadata" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assertNoStaleIntervalKeys(t, "briefing."+name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
|
||||||
|
t.Helper()
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range typed {
|
||||||
|
switch key {
|
||||||
|
case "start_time", "end_time", "period", "start", "end":
|
||||||
|
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
|
||||||
|
}
|
||||||
|
assertNoStaleIntervalKeys(t, path+"."+key, child)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for i, child := range typed {
|
||||||
|
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertPathsExist(t *testing.T, paths ...string) {
|
func assertPathsExist(t *testing.T, paths ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
@@ -1381,7 +1820,8 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
|||||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||||
"morning": map[string]any{
|
"morning": map[string]any{
|
||||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||||
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"),
|
"period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||||
|
"period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||||
"temp_range_f": "50-58",
|
"temp_range_f": "50-58",
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
@@ -1404,7 +1844,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
|||||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||||
date + "_morning": map[string]any{
|
date + "_morning": map[string]any{
|
||||||
"date": date,
|
"date": date,
|
||||||
"period": date + " at 6:00 AM to " + date + " at 10:00 AM",
|
"period_begins": date + " at 6:00 AM",
|
||||||
|
"period_ends": date + " at 10:00 AM",
|
||||||
"temp_range_f": "50-58",
|
"temp_range_f": "50-58",
|
||||||
"max_pop_percent": precip,
|
"max_pop_percent": precip,
|
||||||
"max_pop_time": "6 AM",
|
"max_pop_time": "6 AM",
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
@@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -113,8 +128,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||||
@@ -127,7 +142,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -135,8 +150,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, "Tomorrow night") {
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||||
@@ -285,13 +300,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
|||||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||||
}
|
}
|
||||||
|
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||||
|
}
|
||||||
data, err := json.Marshal(storyOutput.Value)
|
data, err := json.Marshal(storyOutput.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal weather story: %v", err)
|
t.Fatalf("Marshal weather story: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "download_url") {
|
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||||
|
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||||
@@ -314,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||||
|
var item module.ConfigItem
|
||||||
|
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||||
|
if candidate.ID == module.AreaForecastDiscussion {
|
||||||
|
item = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.ID == "" {
|
||||||
|
t.Fatal("near-term default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testModuleContext() ModuleContext {
|
func testModuleContext() ModuleContext {
|
||||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
@@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
DominantCondition: daypart.DominantCondition,
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
if len(rainy.PrecipitationWindows) != 2 {
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
||||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
||||||
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
@@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
||||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||||
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||||
|
}
|
||||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.NearTerm)
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[PrecipTimingModule](t, output)
|
||||||
|
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
@@ -150,8 +169,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||||
}
|
}
|
||||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||||
}
|
}
|
||||||
afternoon := value["afternoon"]
|
afternoon := value["afternoon"]
|
||||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
@@ -166,12 +185,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
t.Fatalf("marshal daypart summaries: %v", err)
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +210,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||||
}
|
}
|
||||||
|
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||||
|
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||||
|
}
|
||||||
|
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||||
|
}
|
||||||
|
|
||||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
StartTime string `json:"start_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
ConditionCode *int `json:"condition_code,omitempty"`
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
@@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
out = append(out, HourlyForecastPeriod{
|
out = append(out, HourlyForecastPeriod{
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
ConditionCode: copyInt(period.ConditionCode),
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
|||||||
@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
|||||||
return clockLabel(value.Time, timezone)
|
return clockLabel(value.Time, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.Start, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.End, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
|||||||
return ctx.Collected.Discussion != nil
|
return ctx.Collected.Discussion != nil
|
||||||
case module.CollectedWeatherStory:
|
case module.CollectedWeatherStory:
|
||||||
return ctx.Collected.WeatherStory != nil
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
case module.CollectedSourceMetadata:
|
case module.CollectedSourceMetadata:
|
||||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
default:
|
default:
|
||||||
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
|||||||
return len(ctx.Derived.DaypartSummaries) > 0
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
case module.RequiresDerivedPrecipTiming:
|
case module.RequiresDerivedPrecipTiming:
|
||||||
return true
|
return true
|
||||||
|
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -245,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
@@ -282,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
},
|
},
|
||||||
@@ -308,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
DefaultOptions: module.PrecipTimingOptions{},
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildPrecipTimingModule,
|
Builder: buildPrecipTimingModule,
|
||||||
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildAlertDigestModule,
|
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,
|
ID: module.AreaForecastDiscussion,
|
||||||
StanzaName: "area_forecast_discussion",
|
StanzaName: "area_forecast_discussion",
|
||||||
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
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,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||||
@@ -52,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNearTermDefaultModuleOptions(t *testing.T) {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||||
|
var found bool
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
if item.ID != module.AreaForecastDiscussion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||||
|
}
|
||||||
|
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
||||||
|
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("near-term default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
@@ -89,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []module.ID{
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) {
|
||||||
|
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||||
@@ -128,6 +169,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) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
|
|||||||
|
|
||||||
type NarrativeForecastPeriod struct {
|
type NarrativeForecastPeriod struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
TextDescription string `json:"text_description,omitempty"`
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
out = append(out, NarrativeForecastPeriod{
|
out = append(out, NarrativeForecastPeriod{
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
TextDescription: period.TextDescription,
|
TextDescription: period.TextDescription,
|
||||||
TemperatureC: copyFloat(period.TemperatureC),
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ type OutdoorWindowsModule struct {
|
|||||||
|
|
||||||
type OutdoorWindowModule struct {
|
type OutdoorWindowModule struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string `json:"daypart"`
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
End string `json:"end"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,20 @@ type OutdoorWindowModule struct {
|
|||||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
value := OutdoorWindowsModule{
|
value := OutdoorWindowsModule{
|
||||||
Best: outdoorWindowValue(windows.Best),
|
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||||
Worst: outdoorWindowValue(windows.Worst),
|
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||||
if window == nil {
|
if window == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &OutdoorWindowModule{
|
return &OutdoorWindowModule{
|
||||||
Daypart: window.Daypart,
|
Daypart: window.Daypart,
|
||||||
Start: window.Start,
|
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||||
End: window.End,
|
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||||
Reasons: append([]string(nil), window.Reasons...),
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
Score: window.Score,
|
Score: window.Score,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrecipitationWindowModule struct {
|
type PrecipitationWindowModule struct {
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins"`
|
||||||
End string `json:"end,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
|||||||
}
|
}
|
||||||
for _, window := range timing.PrecipitationWindows {
|
for _, window := range timing.PrecipitationWindows {
|
||||||
item := PrecipitationWindowModule{
|
item := PrecipitationWindowModule{
|
||||||
Start: clockLabel(window.Start, timezone),
|
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||||
}
|
}
|
||||||
if window.End != nil {
|
if window.End != nil {
|
||||||
item.End = clockLabel(*window.End, timezone)
|
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||||
}
|
}
|
||||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
|||||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||||
|
const spcCategoricalOutlookType = "categorical"
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionModule struct {
|
||||||
|
IncludedBecause string `json:"included_because"`
|
||||||
|
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionRecord struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,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) {
|
||||||
|
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||||
|
if len(qualifyingPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||||
|
Discussions: records,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||||
|
periods := map[int]timeutil.Period{}
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := periods[outlook.Day]; ok {
|
||||||
|
if outlookPeriod.Start.Before(existing.Start) {
|
||||||
|
existing.Start = outlookPeriod.Start
|
||||||
|
}
|
||||||
|
if outlookPeriod.End.After(existing.End) {
|
||||||
|
existing.End = outlookPeriod.End
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = existing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = outlookPeriod
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||||
|
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
period, ok := qualifyingPeriods[discussion.Day]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if discussion.Discussion == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveDiscussionRecord{
|
||||||
|
Day: discussion.Day,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||||
|
Headline: discussion.Headline,
|
||||||
|
Summary: discussion.Summary,
|
||||||
|
Discussion: discussion.Discussion,
|
||||||
|
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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 != "categorical 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.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||||
|
}
|
||||||
|
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", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, `"period":`) {
|
||||||
|
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind 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{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "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 non-categorical outlook ignored for discussion threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank30 := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
categorical := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
wind := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind Risk",
|
||||||
|
SeverityRank: &rank30,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "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 mixed day omitted when categorical outlook is below threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank4 := 4
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||||
|
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||||
|
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank4,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30: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 {
|
||||||
|
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||||
|
}
|
||||||
|
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||||
|
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||||
|
}
|
||||||
|
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 5
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08: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 non-overlapping discussion omitted", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"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"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
ContainsLocation bool `json:"contains_location"`
|
||||||
|
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.Resolved.ValidPeriod, 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, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||||
|
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveOutlookRecord{
|
||||||
|
Day: outlook.Day,
|
||||||
|
OutlookType: outlook.OutlookType,
|
||||||
|
Label: outlook.Label,
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||||
|
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||||
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
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
|
||||||
|
}
|
||||||
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
|
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||||
|
}
|
||||||
|
if !got.ContainsLocation || got.ImageURL == "" {
|
||||||
|
t.Fatalf("outlook = %#v, want location flag and image URL", 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", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
rank := 5
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "tomorrow-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
ContainsLocation: true,
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
|
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OutdoorWindows struct {
|
type OutdoorWindows struct {
|
||||||
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
|
|||||||
|
|
||||||
type OutdoorWindow struct {
|
type OutdoorWindow struct {
|
||||||
Daypart string
|
Daypart string
|
||||||
Start string
|
Period timeutil.Period
|
||||||
End string
|
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Score float64
|
Score float64
|
||||||
}
|
}
|
||||||
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
}
|
}
|
||||||
return OutdoorWindow{
|
return OutdoorWindow{
|
||||||
Daypart: daypart.Name,
|
Daypart: daypart.Name,
|
||||||
Start: daypart.Period.Start.Format("15:04"),
|
Period: daypart.Period,
|
||||||
End: daypart.Period.End.Format("15:04"),
|
|
||||||
Reasons: dedupe(reasons),
|
Reasons: dedupe(reasons),
|
||||||
Score: math.Round(score*10) / 10,
|
Score: math.Round(score*10) / 10,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
type WeatherStoryModule struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
StartTime time.Time `json:"start_time"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime time.Time `json:"end_time"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@@ -25,11 +26,12 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
if story == nil {
|
if story == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
value := WeatherStoryModule{
|
value := WeatherStoryModule{
|
||||||
Available: true,
|
Available: true,
|
||||||
OfficeID: story.OfficeID,
|
OfficeID: story.OfficeID,
|
||||||
StartTime: story.StartTime,
|
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||||
EndTime: story.EndTime,
|
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
Title: story.Title,
|
Title: story.Title,
|
||||||
Description: story.Description,
|
Description: story.Description,
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
|
|||||||
|
|
||||||
type daypartSummaryStanza struct {
|
type daypartSummaryStanza struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
|||||||
@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
DailyPrecipitationProbability: &precip,
|
DailyPrecipitationProbability: &precip,
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
|
"morning": {
|
||||||
|
Date: "2026-05-29",
|
||||||
|
PeriodBegins: "2026-05-29 at 6:00 AM",
|
||||||
|
PeriodEnds: "2026-05-29 at 10:00 AM",
|
||||||
|
TempRangeF: "60-70",
|
||||||
|
Snow: snow,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
|||||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
date + "_morning": {
|
date + "_morning": {
|
||||||
Date: date,
|
Date: date,
|
||||||
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
PeriodBegins: date + " at 6:00 AM",
|
||||||
|
PeriodEnds: date + " at 10:00 AM",
|
||||||
TempRangeF: tempRange,
|
TempRangeF: tempRange,
|
||||||
MaxPopPercent: &precip,
|
MaxPopPercent: &precip,
|
||||||
MaxPopTime: precipTime,
|
MaxPopTime: precipTime,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
|
|||||||
return app.ReportDaily, true
|
return app.ReportDaily, true
|
||||||
case string(app.ReportTomorrow):
|
case string(app.ReportTomorrow):
|
||||||
return app.ReportTomorrow, true
|
return app.ReportTomorrow, true
|
||||||
|
case string(app.ReportNearTerm):
|
||||||
|
return app.ReportNearTerm, true
|
||||||
case string(app.ReportThreeDay):
|
case string(app.ReportThreeDay):
|
||||||
return app.ReportThreeDay, true
|
return app.ReportThreeDay, true
|
||||||
case string(app.ReportWeekend):
|
case string(app.ReportWeekend):
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func TestRunHelpLongFlag(t *testing.T) {
|
|||||||
if !strings.Contains(stdout.String(), "generate daily") {
|
if !strings.Contains(stdout.String(), "generate daily") {
|
||||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "weatherreporter generate near-term") {
|
||||||
|
t.Fatalf("help output missing near-term generate command:\n%s", stdout.String())
|
||||||
|
}
|
||||||
removedInspectCommand := "inspect " + "briefing"
|
removedInspectCommand := "inspect " + "briefing"
|
||||||
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||||
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||||
@@ -809,6 +812,7 @@ func TestResolveGenerateCommands(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||||
|
{name: "near-term", args: []string{"near-term"}, want: app.ReportNearTerm},
|
||||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||||
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
||||||
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
||||||
@@ -827,6 +831,53 @@ func TestResolveGenerateCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := runner.resolveGenerate([]string{"near-term", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./near-term.md"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Report != app.ReportNearTerm {
|
||||||
|
t.Fatalf("Report = %q, want near-term", req.Report)
|
||||||
|
}
|
||||||
|
if req.Config.WeatherAPI.Units != "us" {
|
||||||
|
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
|
||||||
|
}
|
||||||
|
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
|
||||||
|
}
|
||||||
|
if req.OutputPath != "./near-term.md" {
|
||||||
|
t.Fatalf("OutputPath = %q, want ./near-term.md", req.OutputPath)
|
||||||
|
}
|
||||||
|
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
|
||||||
|
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for near-term", req.Date, req.StormStart, req.StormEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateNearTermRejectsDateAndStormBounds(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"near-term", "--date", "2026-05-29"},
|
||||||
|
{"near-term", "--start", "2026-05-29T18:00"},
|
||||||
|
{"near-term", "--end", "2026-05-29T20:00"},
|
||||||
|
} {
|
||||||
|
_, err := runner.resolveGenerate(args)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||||
|
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -996,6 +1047,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."]}}`))
|
_, _ = 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":
|
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"}}`))
|
_, _ = 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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
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)
|
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||||
}
|
}
|
||||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||||
@@ -126,10 +126,13 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := LoadFile(path)
|
cfg, err := LoadFile(path)
|
||||||
@@ -138,21 +141,65 @@ reports:
|
|||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides := cfg.ReportModuleOverrides()
|
||||||
items := overrides[report.DailyToday]
|
items := overrides[report.DailyToday]
|
||||||
if len(items) != 3 {
|
if len(items) != 6 {
|
||||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
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)
|
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 {
|
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" {
|
if strings.Join(options.Sections, ",") != "short_term" {
|
||||||
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadNearTermReportModuleOverrides(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{name: "UnderscoreAlias", key: "near_term"},
|
||||||
|
{name: "HyphenAlias", key: "near-term"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
`+tt.key+`:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides := cfg.ReportModuleOverrides()
|
||||||
|
items := overrides[report.NearTerm]
|
||||||
|
if len(items) != 4 {
|
||||||
|
t.Fatalf("near-term override length = %d, want 4", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.HourlyForecast ||
|
||||||
|
items[2].ID != module.PrecipTiming ||
|
||||||
|
items[3].ID != module.AlertDigest {
|
||||||
|
t.Fatalf("near-term override = %#v, want configured module order", items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -236,6 +283,29 @@ reports:
|
|||||||
`,
|
`,
|
||||||
wantErr: "duplicates report override",
|
wantErr: "duplicates report override",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "DuplicateNearTermAlias",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
near_term:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
near-term:
|
||||||
|
deterministic_modules:
|
||||||
|
- current_conditions
|
||||||
|
`,
|
||||||
|
wantErr: "duplicates report override",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NearTermIncompatibleDailyModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
near_term:
|
||||||
|
deterministic_modules:
|
||||||
|
- derived_daily_summary
|
||||||
|
`,
|
||||||
|
wantErr: `not compatible with report "near_term"`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "UnknownReportField",
|
name: "UnknownReportField",
|
||||||
yaml: `
|
yaml: `
|
||||||
@@ -599,7 +669,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
|||||||
"notify:\n" +
|
"notify:\n" +
|
||||||
" distributor:\n" +
|
" distributor:\n" +
|
||||||
" enabled: true\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 {
|
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
||||||
t.Fatalf("write config fixture: %v", err)
|
t.Fatalf("write config fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
|
|||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case "tomorrow", "daily_tomorrow":
|
case "tomorrow", "daily_tomorrow":
|
||||||
return report.DailyTomorrow, nil
|
return report.DailyTomorrow, nil
|
||||||
|
case "near_term":
|
||||||
|
return report.NearTerm, nil
|
||||||
case "three_day", "three_day_outlook":
|
case "three_day", "three_day_outlook":
|
||||||
return report.ThreeDay, nil
|
return report.ThreeDay, nil
|
||||||
case "weekend", "weekend_outlook":
|
case "weekend", "weekend_outlook":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
@@ -21,6 +22,7 @@ type CollectedFacts struct {
|
|||||||
Discussion *weatherdata.Discussion
|
Discussion *weatherdata.Discussion
|
||||||
Daily *weatherdata.ForecastRun
|
Daily *weatherdata.ForecastRun
|
||||||
WeatherStory *weatherdata.WeatherStory
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
SourceProvenance []weatherdata.Source
|
SourceProvenance []weatherdata.Source
|
||||||
SourceWarnings []weatherdata.SourceWarning
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
@@ -40,6 +42,7 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
|||||||
Discussion: bundle.Discussion,
|
Discussion: bundle.Discussion,
|
||||||
Daily: bundle.Daily,
|
Daily: bundle.Daily,
|
||||||
WeatherStory: bundle.WeatherStory,
|
WeatherStory: bundle.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
|||||||
Discussion: f.Discussion,
|
Discussion: f.Discussion,
|
||||||
Daily: f.Daily,
|
Daily: f.Daily,
|
||||||
WeatherStory: f.WeatherStory,
|
WeatherStory: f.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
|||||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||||
AlertOverlaps []forecast.AlertOverlap
|
AlertOverlaps []forecast.AlertOverlap
|
||||||
|
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||||
|
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||||
DailySummaries []forecast.DailySummary
|
DailySummaries []forecast.DailySummary
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
PrecipTiming forecast.PrecipTiming
|
PrecipTiming forecast.PrecipTiming
|
||||||
@@ -96,15 +102,19 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
}
|
}
|
||||||
bundle := req.Collected.Bundle()
|
bundle := req.Collected.Bundle()
|
||||||
period := req.Resolved.ValidPeriod
|
period := req.Resolved.ValidPeriod
|
||||||
|
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||||
derived := DerivedFacts{
|
derived := DerivedFacts{
|
||||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||||
|
SPCConvectiveOutlooks: spcOutlooks,
|
||||||
|
SPCConvectiveDiscussions: spcDiscussions,
|
||||||
}
|
}
|
||||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||||
|
|
||||||
switch req.Resolved.Definition.ID {
|
switch req.Resolved.Definition.ID {
|
||||||
|
case report.NearTerm:
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.DailyToday, report.DailyTomorrow:
|
||||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Label: "SLGT",
|
||||||
|
}},
|
||||||
|
},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||||
}
|
}
|
||||||
@@ -24,6 +32,9 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
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" {
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
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" {
|
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)
|
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) {
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
|
||||||
|
}}
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||||
|
}
|
||||||
|
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
|
||||||
|
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||||
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
||||||
|
}
|
||||||
|
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
|
||||||
|
t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps)
|
||||||
|
}
|
||||||
|
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
|
||||||
|
t.Fatalf("Alert overlap end = %s, want clipped to near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveStormForTest(t, location)
|
resolved := resolveStormForTest(t, location)
|
||||||
@@ -166,6 +239,124 @@ 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 TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
resolved.Definition.ID = report.ID("future_report")
|
||||||
|
|
||||||
|
_, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(testBundle(location)),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
|
||||||
|
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||||
return &weatherdata.Bundle{
|
return &weatherdata.Bundle{
|
||||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||||
@@ -193,6 +384,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 {
|
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
temperature := 70.0
|
temperature := 70.0
|
||||||
return weatherdata.ForecastPeriod{
|
return weatherdata.ForecastPeriod{
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
AlertDigest ID = "alert_digest"
|
AlertDigest ID = "alert_digest"
|
||||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||||
WeatherStory ID = "weather_story"
|
WeatherStory ID = "weather_story"
|
||||||
|
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||||
|
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
)
|
)
|
||||||
@@ -111,6 +113,7 @@ const (
|
|||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
|
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
@@ -118,6 +121,7 @@ const (
|
|||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
|
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
|
|||||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||||
}
|
}
|
||||||
type WeatherStoryOptions struct{}
|
type WeatherStoryOptions struct{}
|
||||||
|
type SPCConvectiveOutlooksOptions struct{}
|
||||||
|
type SPCConvectiveDiscussionOptions struct{}
|
||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
|||||||
@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
|||||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
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{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var briefingCategoryOrder = []string{
|
|||||||
|
|
||||||
var briefingStanzaCategories = map[string]string{
|
var briefingStanzaCategories = map[string]string{
|
||||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||||
|
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
|
||||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||||
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
|
|||||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||||
|
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
|
||||||
string(module.WeatherStory): categoryNarrativeProducts,
|
string(module.WeatherStory): categoryNarrativeProducts,
|
||||||
string(module.CurrentConditions): categoryRawData,
|
string(module.CurrentConditions): categoryRawData,
|
||||||
string(module.HourlyForecast): categoryRawData,
|
string(module.HourlyForecast): categoryRawData,
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||||
pkg, err := Build(validBuildRequest(t))
|
pkg, err := Build(validBuildRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
@@ -62,7 +64,9 @@ func dailyTomorrowModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ID string
|
|||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
DailyToday ID = "daily_today"
|
||||||
DailyTomorrow ID = "daily_tomorrow"
|
DailyTomorrow ID = "daily_tomorrow"
|
||||||
|
NearTerm ID = "near_term"
|
||||||
ThreeDay ID = "three_day"
|
ThreeDay ID = "three_day"
|
||||||
Weekend ID = "weekend"
|
Weekend ID = "weekend"
|
||||||
Storm ID = "storm"
|
Storm ID = "storm"
|
||||||
@@ -25,6 +26,7 @@ const (
|
|||||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||||
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
||||||
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
||||||
|
CompareRollingWindow ComparisonStrategy = "rolling_window"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Batch string
|
type Batch string
|
||||||
|
|||||||
52
internal/report/near_term_report.go
Normal file
52
internal/report/near_term_report.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const nearTermHours = 6
|
||||||
|
|
||||||
|
func nearTermDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: NearTerm,
|
||||||
|
Name: "Near-Term Report",
|
||||||
|
PromptID: "weather.near_term_report",
|
||||||
|
ComparisonStrategy: CompareRollingWindow,
|
||||||
|
ArtifactGroup: "near-term",
|
||||||
|
BatchOutputName: "near-term.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{NearTerm},
|
||||||
|
Modules: nearTermModules(),
|
||||||
|
resolve: resolveNearTerm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearTermModules() []module.ConfigItem {
|
||||||
|
return []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: module.AreaForecastDiscussionOptions{
|
||||||
|
Sections: []string{"key_messages", "short_term"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: localNow,
|
||||||
|
End: localNow.Add(nearTermHours * time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -58,6 +58,51 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
|
|||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T05:15:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ID != NearTerm {
|
||||||
|
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.PromptID != "weather.near_term_report" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
now := mustParse("2026-05-29T10:15:00Z")
|
||||||
|
|
||||||
|
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T22:30:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeekendPeriodCalculation(t *testing.T) {
|
func TestWeekendPeriodCalculation(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -158,6 +203,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
||||||
|
|
||||||
|
morning, err := DefaultRegistry().BatchReports(Morning, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BatchReports(morning) error = %v", err)
|
||||||
|
}
|
||||||
|
evening, err := DefaultRegistry().BatchReports(Evening, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BatchReports(evening) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, resolved := range append(morning, evening...) {
|
||||||
|
if resolved.Definition.ID == NearTerm {
|
||||||
|
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||||
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -168,6 +232,14 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) {
|
||||||
|
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||||
|
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"}
|
||||||
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||||
for _, definition := range DefaultRegistry().All() {
|
for _, definition := range DefaultRegistry().All() {
|
||||||
if definition.PromptID == "" {
|
if definition.PromptID == "" {
|
||||||
@@ -186,6 +258,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName string
|
batchOutputName string
|
||||||
generated bool
|
generated bool
|
||||||
compatiblePriorIDs []ID
|
compatiblePriorIDs []ID
|
||||||
|
comparisonStrategy ComparisonStrategy
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: DailyToday,
|
||||||
@@ -193,6 +266,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "daily.md",
|
batchOutputName: "daily.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: DailyTomorrow,
|
||||||
@@ -200,6 +274,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "tomorrow.md",
|
batchOutputName: "tomorrow.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: NearTerm,
|
||||||
|
artifactGroup: "near-term",
|
||||||
|
batchOutputName: "near-term.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{NearTerm},
|
||||||
|
comparisonStrategy: CompareRollingWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
@@ -207,6 +290,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "three-day.md",
|
batchOutputName: "three-day.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{ThreeDay},
|
compatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Weekend,
|
id: Weekend,
|
||||||
@@ -214,6 +298,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "weekend.md",
|
batchOutputName: "weekend.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Weekend},
|
compatiblePriorIDs: []ID{Weekend},
|
||||||
|
comparisonStrategy: CompareWeekendWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Storm,
|
id: Storm,
|
||||||
@@ -221,6 +306,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "storm.md",
|
batchOutputName: "storm.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Storm},
|
compatiblePriorIDs: []ID{Storm},
|
||||||
|
comparisonStrategy: CompareExplicitWindow,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +326,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
if definition.Generated != tt.generated {
|
if definition.Generated != tt.generated {
|
||||||
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||||
}
|
}
|
||||||
|
if definition.ComparisonStrategy != tt.comparisonStrategy {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
|
||||||
|
}
|
||||||
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||||
}
|
}
|
||||||
@@ -267,7 +356,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
@@ -283,13 +374,29 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: NearTerm,
|
||||||
|
want: []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
@@ -298,7 +405,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -311,7 +420,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -323,7 +434,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -402,6 +515,24 @@ func TestResolvedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
metadata := resolved.Metadata()
|
||||||
|
if metadata.ReportID != NearTerm {
|
||||||
|
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID)
|
||||||
|
}
|
||||||
|
if metadata.PromptID != "weather.near_term_report" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(metadata.RunID, "near_term") {
|
||||||
|
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
@@ -423,6 +554,14 @@ func resolvedIDs(resolved []Resolved) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvedDefinitionIDs(definitions []Definition) []string {
|
||||||
|
ids := make([]string, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
ids = append(ids, string(definition.ID))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func mustLoadLocation(t *testing.T) *time.Location {
|
func mustLoadLocation(t *testing.T) *time.Location {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func DefaultRegistry() Registry {
|
|||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
dailyTodayDefinition(),
|
dailyTodayDefinition(),
|
||||||
dailyTomorrowDefinition(),
|
dailyTomorrowDefinition(),
|
||||||
|
nearTermDefinition(),
|
||||||
threeDayDefinition(),
|
threeDayDefinition(),
|
||||||
weekendDefinition(),
|
weekendDefinition(),
|
||||||
stormDefinition(),
|
stormDefinition(),
|
||||||
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) All() []Definition {
|
func (r Registry) All() []Definition {
|
||||||
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm}
|
||||||
out := make([]Definition, 0, len(ids))
|
out := make([]Definition, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if definition, ok := r.definitions[id]; ok {
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ func stormModules() []module.ConfigItem {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ func threeDayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ func weekendModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -305,6 +305,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00")
|
||||||
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
|
|
||||||
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
if prior != nil {
|
||||||
|
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||||
cfg := config.Defaults().Workspace
|
cfg := config.Defaults().Workspace
|
||||||
cfg.Root = t.TempDir()
|
cfg.Root = t.TempDir()
|
||||||
@@ -390,6 +405,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveNearTermAt(t *testing.T, value string) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
location, err := timeutil.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation() error = %v", err)
|
||||||
|
}
|
||||||
|
now, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse time: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||||
return briefing.Metadata{
|
return briefing.Metadata{
|
||||||
RunID: resolved.Metadata().RunID,
|
RunID: resolved.Metadata().RunID,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Bundle struct {
|
|||||||
Discussion *Discussion `json:"discussion,omitempty"`
|
Discussion *Discussion `json:"discussion,omitempty"`
|
||||||
Daily *ForecastRun `json:"daily,omitempty"`
|
Daily *ForecastRun `json:"daily,omitempty"`
|
||||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||||
|
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
|
||||||
Sources []Source `json:"sources"`
|
Sources []Source `json:"sources"`
|
||||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -166,3 +167,42 @@ type WeatherStory struct {
|
|||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
78
internal/weatherdata/bundle_test.go
Normal file
78
internal/weatherdata/bundle_test.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user