Compare commits
8 Commits
1ddd88231a
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f149563c68 | |||
| 276e4f1189 | |||
| cb42cad6a6 | |||
| ef044327c6 | |||
| d1d0df11a8 | |||
| c3da3af2f4 | |||
| 7b760a0823 | |||
| 1e9c29aa55 |
@@ -129,8 +129,8 @@ name the variable only; they should not contain the token value.
|
|||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||||
- `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 and
|
Hourly forecast data is required for generated reports. Optional sources use
|
||||||
stub source slots use the missing-source policy.
|
the missing-source policy.
|
||||||
|
|
||||||
### `scriptorium`
|
### `scriptorium`
|
||||||
|
|
||||||
@@ -196,10 +196,12 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- hourly_forecast
|
||||||
```
|
```
|
||||||
|
|
||||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||||
@@ -209,8 +211,8 @@ combinations, duplicate stanza names, and invalid options fail config loading.
|
|||||||
includes all available AFD sections.
|
includes all available AFD sections.
|
||||||
|
|
||||||
The module registry accepts all module IDs documented in
|
The module registry accepts all module IDs documented in
|
||||||
[Module Contract Internals](internal/module.md). Modules without builders are
|
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||||
valid in composition but do not emit YAML stanzas.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,6 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
- `weather_story` for `/weatherstories/latest`
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
|
||||||
The adapter also creates a missing stub source record for `daily` because that
|
|
||||||
source slot exists in the internal bundle but is not fetched from the Weather
|
|
||||||
API.
|
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
- `error`: fail the fetch for that source
|
- `error`: fail the fetch for that source
|
||||||
|
|||||||
@@ -27,15 +27,18 @@ Outputs:
|
|||||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||||
supported reports, fact requirements, missing-data behavior, and builder
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `alert_digest`,
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
`area_forecast_discussion`, and `weather_story`
|
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
|
|
||||||
The registry also contains accepted composition entries for modules that do not
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
emit stanzas until a builder exists. App orchestration skips those entries when
|
module IDs fail validation instead of being skipped.
|
||||||
constructing snapshots.
|
|
||||||
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
|
remain in report metadata, source provenance, and integration artifacts.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -31,23 +31,19 @@ The registry recognizes these IDs:
|
|||||||
|
|
||||||
- `metadata`
|
- `metadata`
|
||||||
- `current_conditions`
|
- `current_conditions`
|
||||||
|
- `narrative_forecast`
|
||||||
|
- `hourly_forecast`
|
||||||
- `derived_daily_summary`
|
- `derived_daily_summary`
|
||||||
- `derived_daypart_summaries`
|
- `derived_daypart_summaries`
|
||||||
- `hourly_table`
|
|
||||||
- `precip_timing`
|
- `precip_timing`
|
||||||
- `alert_digest`
|
- `alert_digest`
|
||||||
- `area_forecast_discussion`
|
- `area_forecast_discussion`
|
||||||
- `weather_story`
|
- `weather_story`
|
||||||
- `forecast_delta`
|
|
||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
- `weekend_planning`
|
|
||||||
- `storm_window_summary`
|
|
||||||
|
|
||||||
Modules with builders emit stanzas into module snapshots. Registered modules
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
without builders are valid composition entries but do not emit snapshot stanzas.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
That keeps report composition declarations centralized while limiting prompt
|
|
||||||
packages to data the application builds.
|
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `promptinput.Package` with schema version, RunID, report metadata, named
|
- `promptinput.Package` with schema version, RunID, report metadata, named
|
||||||
module stanzas, Recent Changes, and source warnings
|
module stanzas grouped for prompt presentation, Recent Changes, and source
|
||||||
|
warnings
|
||||||
- YAML bytes from `promptinput.MarshalYAML`
|
- YAML bytes from `promptinput.MarshalYAML`
|
||||||
- YAML file written atomically by `promptinput.Save`
|
- YAML file written atomically by `promptinput.Save`
|
||||||
|
|
||||||
@@ -38,13 +39,37 @@ report:
|
|||||||
prompt_id: <prompt_id>
|
prompt_id: <prompt_id>
|
||||||
briefing:
|
briefing:
|
||||||
metadata: {}
|
metadata: {}
|
||||||
current_conditions: {}
|
applicable_risk_products:
|
||||||
|
alert_digest: {}
|
||||||
|
derived_summaries:
|
||||||
|
derived_daily_summary: {}
|
||||||
|
derived_daypart_summaries: {}
|
||||||
|
precip_timing: {}
|
||||||
|
outdoor_windows: {}
|
||||||
|
narrative_products:
|
||||||
|
narrative_forecast: {}
|
||||||
|
area_forecast_discussion: {}
|
||||||
|
weather_story: {}
|
||||||
|
raw_data:
|
||||||
|
current_conditions: {}
|
||||||
|
hourly_forecast: {}
|
||||||
recent_changes:
|
recent_changes:
|
||||||
items: []
|
items: []
|
||||||
```
|
```
|
||||||
|
|
||||||
The `briefing` mapping contains named module stanzas. Stanza order follows the
|
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
||||||
module snapshot output order.
|
weather module stanzas under prompt-facing categories. This grouping is a YAML
|
||||||
|
presentation concern only: module snapshots remain flat, and loaded
|
||||||
|
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||||
|
Within each category, stanza order follows the module snapshot output order.
|
||||||
|
|
||||||
|
Current categories are:
|
||||||
|
|
||||||
|
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||||
|
discussions, and similar risk products.
|
||||||
|
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||||
|
- `narrative_products`: official narrative text products and forecast stories.
|
||||||
|
- `raw_data`: minimally transformed underlying weather data.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -90,6 +115,7 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||||
- Module stanza order is deterministic for generated snapshots.
|
- Module stanza order is deterministic within each prompt-facing category.
|
||||||
|
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||||
- Recent Changes are provided by `internal/changes`; this package does not
|
- Recent Changes are provided by `internal/changes`; this package does not
|
||||||
infer changes from rendered report text.
|
infer changes from rendered report text.
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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, and source warnings
|
||||||
- stub source record for the daily forecast source slot
|
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -70,7 +69,7 @@ data is required and cannot be skipped.
|
|||||||
- HTTP errors, response read failures, and envelope decode failures include
|
- HTTP errors, response read failures, and envelope decode failures include
|
||||||
endpoint context.
|
endpoint context.
|
||||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||||
- Optional and stub 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Distributor Roadmap
|
|
||||||
|
|
||||||
Current distributor notification behavior is documented outside the roadmap:
|
|
||||||
|
|
||||||
- [Configuration reference](../config.md)
|
|
||||||
- [Operations guide](../operations.md)
|
|
||||||
- [Troubleshooting](../troubleshooting.md)
|
|
||||||
- [Distributor adapter internals](../internal/distributor-adapter.md)
|
|
||||||
|
|
||||||
This file tracks future distributor-related work only.
|
|
||||||
|
|
||||||
## Deferred Enhancements
|
|
||||||
|
|
||||||
- Add a supported warning-only notification policy.
|
|
||||||
- Include selected non-report artifacts in uploaded bundles.
|
|
||||||
- Poll distributor run status after upload acceptance.
|
|
||||||
- Persist upload retry state across process restarts.
|
|
||||||
- Add explicit CLI controls for distributor behavior.
|
|
||||||
|
|
||||||
## Non-Goals Without A Separate Design
|
|
||||||
|
|
||||||
- Do not make distributor scan the weatherreporter workspace.
|
|
||||||
- Do not move destination routing into weatherreporter.
|
|
||||||
- Do not move Markdown-to-HTML transformation into weatherreporter.
|
|
||||||
- Do not store raw bearer tokens in configuration files.
|
|
||||||
|
|
||||||
## Required Constraints For Future Work
|
|
||||||
|
|
||||||
- Distributor package types stay inside `internal/adapters/distributor`.
|
|
||||||
- Weatherreporter submits explicit source bundles built from generated files.
|
|
||||||
- Optional `--out` and `--out-dir` copies remain operator conveniences, not
|
|
||||||
canonical upload sources.
|
|
||||||
- Secret values stay out of errors, logs, CLI output, metadata, examples, and
|
|
||||||
documentation.
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
This roadmap contains project work that is not implemented. Current behavior is
|
This roadmap contains project work that is not implemented. Current behavior is
|
||||||
documented outside `docs/roadmap/`.
|
documented outside `docs/roadmap/`.
|
||||||
|
|
||||||
## Deferred: Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Manual Storm Report generation is implemented through
|
Manual Storm Report generation is implemented through
|
||||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
||||||
@@ -34,30 +34,56 @@ Acceptance criteria before implementation:
|
|||||||
- evaluator failures are inspectable and do not create noisy report output;
|
- evaluator failures are inspectable and do not create noisy report output;
|
||||||
- manual Storm Report generation remains available.
|
- manual Storm Report generation remains available.
|
||||||
|
|
||||||
## Deferred: Alternate Runtime Integrations
|
## Future Report Types And Modules
|
||||||
|
|
||||||
These ideas are not current behavior:
|
The module-based prompt package architecture is implemented. Future work should
|
||||||
|
add only modules backed by implemented upstream facts and clear report needs.
|
||||||
|
|
||||||
- native LLM client inside `weatherreporter`;
|
Possible future report types:
|
||||||
- database-backed state;
|
|
||||||
- public HTTP API;
|
|
||||||
- multi-location selection;
|
|
||||||
- daemon mode;
|
|
||||||
- multi-user authorization;
|
|
||||||
- plugin system.
|
|
||||||
|
|
||||||
Each item needs its own design note before implementation. Non-roadmap docs
|
- `next_6_hours` or another short-fuse planning report;
|
||||||
must not describe these as available behavior.
|
- event-specific reports with stable event IDs;
|
||||||
|
- storm review or yesterday-style reports using historical observations;
|
||||||
|
- archive-focused report variants if generated report history becomes a
|
||||||
|
first-class product.
|
||||||
|
|
||||||
## Deferred: Distributor Notification Enhancements
|
Possible future modules:
|
||||||
|
|
||||||
|
- `hourly_table` for compact valid-period hourly facts;
|
||||||
|
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||||
|
Changes;
|
||||||
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
|
deterministic stanza;
|
||||||
|
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
||||||
|
prompt-facing storm-window module;
|
||||||
|
- separate AFD section aliases, such as `afd_key_messages`,
|
||||||
|
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||||
|
more useful than `area_forecast_discussion.options.sections`;
|
||||||
|
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||||
|
upstream sources and report requirements exist.
|
||||||
|
|
||||||
|
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||||
|
remain omitted until a real upstream quantitative precipitation source is
|
||||||
|
represented in `CollectedFacts`.
|
||||||
|
|
||||||
|
Future module work should preserve these boundaries:
|
||||||
|
|
||||||
|
- collect upstream facts once per report run;
|
||||||
|
- keep upstream fetching out of modules;
|
||||||
|
- keep broad reusable calculations in `DerivedFacts`;
|
||||||
|
- keep prompt-facing field shape inside module builders;
|
||||||
|
- use typed options for configurable module behavior;
|
||||||
|
- keep module snapshots structured and deterministic for Recent Changes.
|
||||||
|
|
||||||
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
Distributor notification currently uploads one managed Markdown report per
|
Distributor notification currently uploads one managed Markdown report per
|
||||||
successful generated report through the configured HTTP upload endpoint.
|
successful generated report through the configured HTTP upload pipeline.
|
||||||
|
|
||||||
These enhancements are not current behavior:
|
These enhancements are not current behavior:
|
||||||
|
|
||||||
- `failure_policy: warn`;
|
- `failure_policy: warn`;
|
||||||
- uploading metadata, briefing snapshots, data packages, or preflight artifacts;
|
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
||||||
- polling distributor status after upload acceptance;
|
- polling distributor status after upload acceptance;
|
||||||
- durable upload retry queues;
|
- durable upload retry queues;
|
||||||
- distributor-specific CLI flags;
|
- distributor-specific CLI flags;
|
||||||
@@ -69,7 +95,26 @@ Any distributor enhancement should preserve the existing adapter boundary:
|
|||||||
weatherreporter selects explicit generated files and submits source bundles,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
## Deferred: Cleanup Refactors
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
|
These ideas are not current behavior:
|
||||||
|
|
||||||
|
- native LLM client inside `weatherreporter`;
|
||||||
|
- database-backed state;
|
||||||
|
- public HTTP API;
|
||||||
|
- multi-location selection;
|
||||||
|
- daemon mode;
|
||||||
|
- multi-user authorization;
|
||||||
|
- plugin system;
|
||||||
|
- dynamic module loading;
|
||||||
|
- user-defined module code;
|
||||||
|
- YAML-defined module schemas;
|
||||||
|
- module-owned Weather API fetching.
|
||||||
|
|
||||||
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
|
must not describe these as available behavior.
|
||||||
|
|
||||||
|
## Cleanup Refactors
|
||||||
|
|
||||||
The initial cleanup pass intentionally left these refactors out because the
|
The initial cleanup pass intentionally left these refactors out because the
|
||||||
current implementation does not yet make them worth the added abstraction.
|
current implementation does not yet make them worth the added abstraction.
|
||||||
@@ -81,14 +126,12 @@ more expensive:
|
|||||||
- Weather API optional-source specification/helper refactor: consider when
|
- Weather API optional-source specification/helper refactor: consider when
|
||||||
additional Weather API sources make per-source fan-out, policy handling, and
|
additional Weather API sources make per-source fan-out, policy handling, and
|
||||||
provenance wiring repetitive enough to obscure adapter behavior.
|
provenance wiring repetitive enough to obscure adapter behavior.
|
||||||
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
- Broad briefing weather-signal consolidation: consider when multiple module
|
||||||
builders repeatedly derive the same weather signals and tests begin to need
|
builders repeatedly derive the same weather signals and tests begin to need
|
||||||
coordinated fixture updates.
|
coordinated fixture updates.
|
||||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||||
future background workflows gain enough shared step semantics to justify a
|
future background workflows gain enough shared step semantics to justify a
|
||||||
declared execution model.
|
declared execution model.
|
||||||
- Plugin architecture: defer until there is a concrete external extension
|
|
||||||
contract and at least one implemented extension point.
|
|
||||||
- Cobra migration: defer while the standard-library CLI remains small,
|
- Cobra migration: defer while the standard-library CLI remains small,
|
||||||
explicit, and covered by parser tests.
|
explicit, and covered by parser tests.
|
||||||
- Manifest, resume, or progress system: defer until operators need resumable
|
- Manifest, resume, or progress system: defer until operators need resumable
|
||||||
@@ -101,5 +144,5 @@ more expensive:
|
|||||||
artifact output.
|
artifact output.
|
||||||
|
|
||||||
Any future implementation should preserve the existing public CLI, artifact
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
paths, report identities, and adapter boundaries unless a separate roadmap
|
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||||
explicitly changes them.
|
separate roadmap explicitly changes them.
|
||||||
|
|||||||
@@ -1,791 +0,0 @@
|
|||||||
# Modular Data Package Implementation Roadmap
|
|
||||||
|
|
||||||
This roadmap is a staged implementation plan for
|
|
||||||
[`docs/roadmap/modules.md`](modules.md). It is future-work planning only. The
|
|
||||||
target audience is an LLM coding agent implementing each stage in order.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Implement a pre-release hard cutover from report-shaped briefing packages to a
|
|
||||||
module-oriented prompt package architecture:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CollectedFacts -> DerivedFacts -> ModuleOutput
|
|
||||||
```
|
|
||||||
|
|
||||||
The implementation should produce YAML `data_package` artifacts with named
|
|
||||||
stanzas for Scriptorium prompts, persist JSON module snapshots for inspection
|
|
||||||
and Recent Changes, and make report composition configurable through ordered
|
|
||||||
module IDs plus typed module options.
|
|
||||||
|
|
||||||
## Source Roadmap
|
|
||||||
|
|
||||||
[`docs/roadmap/modules.md`](modules.md) is authoritative for the conceptual
|
|
||||||
policy, user intent, target prompt shape, boundaries, and acceptance criteria
|
|
||||||
for this refactor. This document is authoritative for implementation order,
|
|
||||||
stage scope, file/package guidance, and validation commands.
|
|
||||||
|
|
||||||
If this implementation plan appears to conflict with `modules.md`, stop and
|
|
||||||
reconcile the roadmap before changing code. Do not infer a different policy
|
|
||||||
from stage sequencing.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Do a clean break. Do not preserve old report-shaped briefing JSON as a
|
|
||||||
compatibility layer.
|
|
||||||
- Introduce `internal/weatherdata` for normalized collected source types.
|
|
||||||
- Keep `internal/forecast` for forecast-specific derivation algorithms such as
|
|
||||||
period selection, daily summaries, daypart grouping, and precipitation
|
|
||||||
timing.
|
|
||||||
- Introduce an internal fact contract for `CollectedFacts` and `DerivedFacts`.
|
|
||||||
- Introduce a narrow module contract for module IDs, typed options, outputs,
|
|
||||||
and snapshots.
|
|
||||||
- Persist both artifacts:
|
|
||||||
- JSON module snapshots for state, inspection, and Recent Changes;
|
|
||||||
- YAML prompt data packages passed to Scriptorium as `data_package`.
|
|
||||||
- Replace `weatherreporter inspect briefing` with
|
|
||||||
`weatherreporter inspect modules`.
|
|
||||||
- Support typed module options from the first configurable composition pass.
|
|
||||||
- Use named YAML stanzas, not a generic array of module objects.
|
|
||||||
- Omit QPF fields until a real upstream QPF source is represented in
|
|
||||||
`CollectedFacts`.
|
|
||||||
- Keep public generate/run command names, report IDs, prompt IDs, RunID format,
|
|
||||||
managed Markdown report paths, and distributor upload source stable.
|
|
||||||
- Do not introduce plugins, dynamic loading, generic workflow engines, or
|
|
||||||
module-owned upstream fetching.
|
|
||||||
|
|
||||||
## Target Packages
|
|
||||||
|
|
||||||
Implementation should converge on this package ownership:
|
|
||||||
|
|
||||||
- `internal/weatherdata`: normalized collected source facts, source metadata,
|
|
||||||
source warnings, and broad weather data types.
|
|
||||||
- `internal/forecast`: deterministic forecast-specific algorithms over
|
|
||||||
`weatherdata` types.
|
|
||||||
- `internal/facts`: `CollectedFacts`, `DerivedFacts`, and their builders.
|
|
||||||
- `internal/module`: stable module IDs, module output envelope, module snapshot
|
|
||||||
shape, module config item shape, and shared option/output contracts that must
|
|
||||||
be imported by both `internal/report` and `internal/briefing`.
|
|
||||||
- `internal/briefing`: module registry and module builders.
|
|
||||||
- `internal/report`: report definitions, valid periods, output identity,
|
|
||||||
comparison strategy, and default module composition.
|
|
||||||
- `internal/config`: YAML config structs, defaults, loading, and validation for
|
|
||||||
module composition overrides.
|
|
||||||
- `internal/promptinput`: YAML prompt package assembly, validation, and save
|
|
||||||
behavior.
|
|
||||||
- `internal/changes`: structured comparison over module snapshots.
|
|
||||||
- `internal/state`: module snapshot paths, YAML data package paths, metadata
|
|
||||||
links, and inspection loads.
|
|
||||||
- `internal/app`: orchestration only.
|
|
||||||
|
|
||||||
Avoid import cycles. In particular, `internal/report` may import
|
|
||||||
`internal/module` for module IDs, but `internal/module` must not import
|
|
||||||
`internal/report`.
|
|
||||||
|
|
||||||
## Target Artifacts
|
|
||||||
|
|
||||||
Use explicit schema versions:
|
|
||||||
|
|
||||||
- Module snapshot JSON: `weatherreporter.modules.v1`
|
|
||||||
- YAML prompt data package: `weatherreporter.data_package.v2`
|
|
||||||
|
|
||||||
Target workspace paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
workspace/
|
|
||||||
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
|
|
||||||
snapshots/<artifact_group>/<valid_date>/<run_id>.metadata.json
|
|
||||||
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Metadata should link the module snapshot and YAML data package paths. Existing
|
|
||||||
metadata links for preflight, rendered report, source warnings, source hashes,
|
|
||||||
and distributor notification artifacts should remain.
|
|
||||||
|
|
||||||
## Target Module Defaults
|
|
||||||
|
|
||||||
Initial implemented default module IDs should cover current behavior without
|
|
||||||
QPF-specific fields:
|
|
||||||
|
|
||||||
- `metadata`
|
|
||||||
- `current_conditions`
|
|
||||||
- `derived_daily_summary`
|
|
||||||
- `derived_daypart_summaries`
|
|
||||||
- `precip_timing`
|
|
||||||
- `alert_digest`
|
|
||||||
- `area_forecast_discussion`
|
|
||||||
- `weather_story`
|
|
||||||
- `forecast_delta`
|
|
||||||
- `outdoor_windows`
|
|
||||||
- `weekend_planning`
|
|
||||||
- `storm_window_summary`
|
|
||||||
|
|
||||||
Default report composition should be declared in `internal/report`:
|
|
||||||
|
|
||||||
- Daily Today:
|
|
||||||
`metadata`, `current_conditions`, `derived_daily_summary`,
|
|
||||||
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
|
|
||||||
`forecast_delta`, `area_forecast_discussion`, `weather_story`,
|
|
||||||
`outdoor_windows`
|
|
||||||
- Daily Tomorrow:
|
|
||||||
same as Daily Today, plus any tomorrow-planning module needed to preserve
|
|
||||||
current tomorrow behavior.
|
|
||||||
- 3-Day:
|
|
||||||
`metadata`, `current_conditions`, `derived_daypart_summaries`,
|
|
||||||
`precip_timing`, `alert_digest`, `forecast_delta`,
|
|
||||||
`area_forecast_discussion`, `weather_story`, `outdoor_windows`
|
|
||||||
- Weekend:
|
|
||||||
`metadata`, `current_conditions`, `derived_daypart_summaries`,
|
|
||||||
`precip_timing`, `alert_digest`, `area_forecast_discussion`,
|
|
||||||
`weather_story`, `outdoor_windows`, `weekend_planning`
|
|
||||||
- Storm:
|
|
||||||
`metadata`, `current_conditions`, `hourly_table`, `precip_timing`,
|
|
||||||
`alert_digest`, `area_forecast_discussion`, `weather_story`,
|
|
||||||
`storm_window_summary`
|
|
||||||
|
|
||||||
If preserving a current report behavior requires a narrower module, add a
|
|
||||||
specific module rather than keeping old report-shaped containers.
|
|
||||||
|
|
||||||
## Stage 1: Weatherdata Package Split
|
|
||||||
|
|
||||||
Goal: separate broad normalized weather data from forecast-specific derivation.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- create `internal/weatherdata`;
|
|
||||||
- update `internal/forecast`;
|
|
||||||
- update `internal/adapters/weatherapi`;
|
|
||||||
- update packages that currently import normalized source types from
|
|
||||||
`internal/forecast`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Move normalized source/domain types out of `internal/forecast` when they are
|
|
||||||
not forecast algorithms:
|
|
||||||
- bundle/source metadata/warnings;
|
|
||||||
- current conditions;
|
|
||||||
- observation run types if present;
|
|
||||||
- alert run and alert overlap source types;
|
|
||||||
- forecast run and forecast period source types;
|
|
||||||
- discussion and discussion section types;
|
|
||||||
- weather story types.
|
|
||||||
- Keep deterministic derivation functions in `internal/forecast`.
|
|
||||||
- Update Weather API adapter return types to use `weatherdata.Bundle`.
|
|
||||||
- Keep JSON field names and Weather API fixture behavior unchanged.
|
|
||||||
- Do not change CLI behavior, artifact paths, or prompt input yet.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Weather API adapter tests pass with `weatherdata` types.
|
|
||||||
- Forecast derivation tests pass using `weatherdata` inputs.
|
|
||||||
- No external adapter dependency types leak into `weatherdata`.
|
|
||||||
- Existing generated report behavior is unchanged at this stage.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/weatherdata ./internal/forecast ./internal/adapters/weatherapi
|
|
||||||
go test ./internal/app ./internal/briefing ./internal/promptinput
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt if kept mechanical.
|
|
||||||
|
|
||||||
## Stage 2: Fact Contracts
|
|
||||||
|
|
||||||
Goal: add explicit `CollectedFacts` and `DerivedFacts` contracts.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- create `internal/facts`;
|
|
||||||
- update `internal/app`;
|
|
||||||
- update `internal/forecast` tests as needed.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Define `CollectedFacts` as normalized upstream facts collected once per
|
|
||||||
report run.
|
|
||||||
- Define `DerivedFacts` as conservative, reusable, report-scoped
|
|
||||||
transformations.
|
|
||||||
- Add builders:
|
|
||||||
- `BuildCollected(bundle *weatherdata.Bundle) CollectedFacts`
|
|
||||||
- `BuildDerived(req BuildDerivedRequest) (DerivedFacts, error)`
|
|
||||||
- `BuildDerivedRequest` should include the resolved report, timezone,
|
|
||||||
configured dayparts, and `CollectedFacts`.
|
|
||||||
- `DerivedFacts` may include:
|
|
||||||
- valid-period hourly periods;
|
|
||||||
- valid-period narrative periods;
|
|
||||||
- alert overlaps;
|
|
||||||
- daily summaries;
|
|
||||||
- daypart summaries where reusable;
|
|
||||||
- precipitation timing if reused by multiple modules.
|
|
||||||
- Do not put prompt wording, prose strings, module-specific ranking, or
|
|
||||||
one-off presentation decisions in `DerivedFacts`.
|
|
||||||
- Keep source provenance and warnings separate from ordinary fact access.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- `CollectedFacts` can be built once from a fetched bundle.
|
|
||||||
- `DerivedFacts` can be built for Daily, Tomorrow, 3-Day, Weekend, and Storm.
|
|
||||||
- Derived fact builders have tests for valid-period slicing, daypart grouping,
|
|
||||||
alert overlaps, and missing optional sources.
|
|
||||||
- No module or prompt code exists yet that fetches upstream data.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/facts ./internal/forecast ./internal/app
|
|
||||||
go test ./internal/...
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 3: Module Core Contracts
|
|
||||||
|
|
||||||
Goal: define module IDs, options, outputs, snapshots, and registry mechanics.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- create `internal/module`;
|
|
||||||
- update `internal/report`;
|
|
||||||
- update `internal/briefing`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Define:
|
|
||||||
- `module.ID`;
|
|
||||||
- module ID constants;
|
|
||||||
- `module.ConfigItem`;
|
|
||||||
- `module.Output`;
|
|
||||||
- `module.Snapshot`;
|
|
||||||
- shared schema version constants.
|
|
||||||
- `module.Output` should contain module ID, stanza name, and typed value.
|
|
||||||
- `module.Snapshot` should preserve ordered outputs and support typed stanza
|
|
||||||
lookup for comparison code.
|
|
||||||
- Add duplicate module and duplicate stanza-name validation.
|
|
||||||
- Add typed option structs for initial modules. Empty option structs are fine
|
|
||||||
for modules without options.
|
|
||||||
- Add a module registry in `internal/briefing` that maps module IDs to builder
|
|
||||||
definitions.
|
|
||||||
- Module definitions should declare:
|
|
||||||
- ID;
|
|
||||||
- stanza name;
|
|
||||||
- option type;
|
|
||||||
- default options;
|
|
||||||
- required collected facts;
|
|
||||||
- required derived facts;
|
|
||||||
- supported report IDs or report categories;
|
|
||||||
- missing-data behavior.
|
|
||||||
- Do not execute modules from app orchestration yet unless needed for tests.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Report definitions can refer to `module.ID` without import cycles.
|
|
||||||
- Module registry tests reject unknown modules, duplicate module IDs, duplicate
|
|
||||||
stanza names, incompatible reports, and invalid option shapes.
|
|
||||||
- Module output and snapshot JSON marshal deterministically enough for tests.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/module ./internal/briefing ./internal/report
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 4: Base Modules
|
|
||||||
|
|
||||||
Goal: implement source-oriented modules that mostly pass through normalized or
|
|
||||||
lightly selected facts.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/briefing`;
|
|
||||||
- `internal/module`;
|
|
||||||
- tests under `internal/briefing`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Implement these modules:
|
|
||||||
- `metadata`;
|
|
||||||
- `current_conditions`;
|
|
||||||
- `alert_digest`;
|
|
||||||
- `area_forecast_discussion`;
|
|
||||||
- `weather_story`.
|
|
||||||
- The `metadata` module should expose report metadata, configured location,
|
|
||||||
units, timezone, valid period, source warnings summary, and alert checked
|
|
||||||
status where appropriate.
|
|
||||||
- `area_forecast_discussion` should expose key messages, short-term text, and
|
|
||||||
long-term text when present.
|
|
||||||
- `weather_story` should expose structured story fields when present and omit
|
|
||||||
the stanza when missing/suppressed by missing-source policy.
|
|
||||||
- `alert_digest` should distinguish checked/no-active-alerts from missing alert
|
|
||||||
source data.
|
|
||||||
- Ordinary modules should not expose endpoint, hash, or transport provenance;
|
|
||||||
provenance should remain metadata/source-warning oriented.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Each module has focused tests for available data, missing optional data, and
|
|
||||||
empty output omission.
|
|
||||||
- No module fetches upstream data or reads/writes durable state.
|
|
||||||
- Output field names use YAML-friendly snake_case and unit suffixes where
|
|
||||||
needed.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/briefing ./internal/module ./internal/facts
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 5: Derived Fact Modules
|
|
||||||
|
|
||||||
Goal: implement deterministic modules that package reusable forecast
|
|
||||||
derivations for the LLM.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/forecast`;
|
|
||||||
- `internal/facts`;
|
|
||||||
- `internal/briefing`;
|
|
||||||
- `internal/module`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Implement:
|
|
||||||
- `derived_daily_summary`;
|
|
||||||
- `derived_daypart_summaries`;
|
|
||||||
- `precip_timing`;
|
|
||||||
- `outdoor_windows`;
|
|
||||||
- any tomorrow-planning module needed to preserve Tomorrow output quality.
|
|
||||||
- `derived_daily_summary` should include current implementable fields:
|
|
||||||
- `high_temp_f`;
|
|
||||||
- `low_temp_f`;
|
|
||||||
- `max_pop_percent`;
|
|
||||||
- `max_pop_window`;
|
|
||||||
- `first_precip_hour`;
|
|
||||||
- `last_precip_hour`;
|
|
||||||
- `thunder_mentioned`;
|
|
||||||
- `max_wind_gust_mph`;
|
|
||||||
- `heat_index_max_f` when source data supports it.
|
|
||||||
- Do not implement `measurable_qpf_total_in` or `max_hourly_qpf_in` until QPF
|
|
||||||
exists in `CollectedFacts`.
|
|
||||||
- `derived_daypart_summaries` should expose daypart keyed values using the
|
|
||||||
configured daypart definitions.
|
|
||||||
- Keep broad reusable calculations in `DerivedFacts`; keep prompt-shape
|
|
||||||
packaging inside modules.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Derived modules have fixture coverage across ordinary, dry, rainy, windy,
|
|
||||||
cold/heat, and missing-data scenarios.
|
|
||||||
- QPF fields are absent unless an upstream QPF source exists.
|
|
||||||
- Daily and Tomorrow module outputs contain enough data to replace current
|
|
||||||
report-shaped daily briefing content.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/forecast ./internal/facts ./internal/briefing ./internal/module
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage may be too large for one prompt if all modules are implemented at
|
|
||||||
once. Split into Daily-derived modules first, then outlook/storm derived
|
|
||||||
modules if needed.
|
|
||||||
|
|
||||||
## Stage 6: Report Composition And Config Overrides
|
|
||||||
|
|
||||||
Goal: make report definitions and config the source of module composition.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/report`;
|
|
||||||
- `internal/config`;
|
|
||||||
- `examples/config.yml`;
|
|
||||||
- config tests.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Extend `report.Definition` with default ordered module IDs.
|
|
||||||
- Keep valid-period resolution, prompt IDs, output naming, generated flag, and
|
|
||||||
comparison strategy in `internal/report`.
|
|
||||||
- Add config support:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
daily:
|
|
||||||
deterministic_modules:
|
|
||||||
- current_conditions
|
|
||||||
- id: area_forecast_discussion
|
|
||||||
options:
|
|
||||||
sections:
|
|
||||||
- short_term
|
|
||||||
```
|
|
||||||
|
|
||||||
- Support both string shorthand and object form for module entries.
|
|
||||||
- Normalize config into typed `module.ConfigItem` values.
|
|
||||||
- Decode module options into typed option structs during validation or before
|
|
||||||
module execution.
|
|
||||||
- Reject:
|
|
||||||
- unknown report IDs;
|
|
||||||
- unknown module IDs;
|
|
||||||
- duplicate modules unless explicitly allowed by that module;
|
|
||||||
- duplicate stanza names;
|
|
||||||
- incompatible report/module combinations;
|
|
||||||
- invalid options.
|
|
||||||
- Built-in defaults should work when no report module config is present.
|
|
||||||
- Example config may omit module overrides unless an example is needed.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Defaults reproduce intended module composition for all implemented reports.
|
|
||||||
- A config edit can add/remove an implemented module for a report.
|
|
||||||
- Invalid module config errors are actionable and do not mention raw internal
|
|
||||||
panic/details.
|
|
||||||
- Config examples load.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/report ./internal/config ./internal/briefing
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 7: Module Snapshot State
|
|
||||||
|
|
||||||
Goal: persist and inspect JSON module snapshots without changing Scriptorium
|
|
||||||
input yet.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/state`;
|
|
||||||
- `internal/app`;
|
|
||||||
- `internal/cli`;
|
|
||||||
- app/state/CLI tests.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Add state paths for `<run_id>.modules.json`.
|
|
||||||
- Add save/load methods for module snapshots.
|
|
||||||
- Update metadata to include `ModuleSnapshotPath`.
|
|
||||||
- Add `weatherreporter inspect modules [--config PATH] RUN_ID`.
|
|
||||||
- Remove `inspect briefing` from parser support and help text in this stage.
|
|
||||||
- Keep old data package generation in place only until Stage 8, but do not
|
|
||||||
leave generation without a module snapshot.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Generated runs persist module snapshots before prompt package construction.
|
|
||||||
- `inspect modules` returns the module snapshot.
|
|
||||||
- `inspect briefing` is gone from help text and parser tests.
|
|
||||||
- Metadata links the module snapshot path.
|
|
||||||
- Existing report generation still succeeds with fake Scriptorium.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/state ./internal/cli ./internal/app
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 8: YAML Prompt Package Cutover
|
|
||||||
|
|
||||||
Goal: replace JSON prompt data packages with YAML named-stanza data packages.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/promptinput`;
|
|
||||||
- `internal/state`;
|
|
||||||
- `internal/adapters/scriptorium` tests;
|
|
||||||
- `internal/app`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Set prompt package schema version to `weatherreporter.data_package.v2`.
|
|
||||||
- Build prompt package content from module snapshots, report metadata, recent
|
|
||||||
changes, and source warnings.
|
|
||||||
- Save prompt packages as `.data_package.yaml`.
|
|
||||||
- Continue passing Scriptorium input as `--input data_package=<path>`.
|
|
||||||
- Update render/run tests to avoid assuming `.json` filenames.
|
|
||||||
- Ensure YAML uses named stanzas under `briefing`.
|
|
||||||
- Omit empty optional fields.
|
|
||||||
- Keep module snapshot JSON as the comparison/inspection source.
|
|
||||||
- Update metadata `DataPackagePath` to point to YAML.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Scriptorium render and run receive a YAML `data_package` path.
|
|
||||||
- YAML output is deterministic enough for tests.
|
|
||||||
- `inspect data-package` returns YAML content or a parsed representation
|
|
||||||
chosen consistently in CLI tests.
|
|
||||||
- No code assumes data package paths end in `.json`.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/promptinput ./internal/adapters/scriptorium ./internal/state ./internal/app ./internal/cli
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 9: App Orchestration Cutover
|
|
||||||
|
|
||||||
Goal: make module execution the only generation path for all implemented
|
|
||||||
reports.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/app`;
|
|
||||||
- `internal/briefing`;
|
|
||||||
- `internal/facts`;
|
|
||||||
- app workflow tests.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- In `GenerateReport`, fetch Weather API data once, build `CollectedFacts`,
|
|
||||||
build `DerivedFacts`, execute configured modules, save module snapshot, build
|
|
||||||
YAML prompt package, then continue preflight/run/metadata/distributor flow.
|
|
||||||
- Preserve ordering:
|
|
||||||
1. resolve prior comparable metadata;
|
|
||||||
2. fetch bundle;
|
|
||||||
3. build facts;
|
|
||||||
4. execute modules;
|
|
||||||
5. save module snapshot;
|
|
||||||
6. compute Recent Changes;
|
|
||||||
7. save YAML data package;
|
|
||||||
8. run render preflight;
|
|
||||||
9. save metadata;
|
|
||||||
10. run Scriptorium;
|
|
||||||
11. copy optional output;
|
|
||||||
12. save final metadata;
|
|
||||||
13. notify distributor if enabled.
|
|
||||||
- Do not use `--out` or `--out-dir` copies for distributor notification.
|
|
||||||
- Do not invoke modules after Scriptorium failures.
|
|
||||||
- Keep batch behavior unchanged: continue independent reports, return nonzero
|
|
||||||
aggregate status if any report fails.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Daily, Tomorrow, 3-Day, Weekend, and Storm generation all use module
|
|
||||||
snapshots and YAML data packages.
|
|
||||||
- Existing public CLI syntax remains stable except `inspect modules` replacing
|
|
||||||
`inspect briefing`.
|
|
||||||
- Managed Markdown report paths and distributor upload source remain stable.
|
|
||||||
- App tests assert generated module snapshots and YAML data packages.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/app ./internal/cli ./internal/state ./internal/briefing ./internal/promptinput
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage may be large. Split by report family if needed: Daily/Tomorrow,
|
|
||||||
Outlooks, then Storm.
|
|
||||||
|
|
||||||
## Stage 10: Recent Changes Migration
|
|
||||||
|
|
||||||
Goal: compare structured module snapshots instead of report-shaped briefing
|
|
||||||
packages.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/changes`;
|
|
||||||
- `internal/state`;
|
|
||||||
- `internal/app`;
|
|
||||||
- changes tests.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Define which module stanzas each comparison strategy consumes.
|
|
||||||
- Daily comparison should use `derived_daily_summary`,
|
|
||||||
`derived_daypart_summaries`, `alert_digest`, and `precip_timing` where
|
|
||||||
present.
|
|
||||||
- 3-Day and Weekend comparisons should use module snapshot outputs that replace
|
|
||||||
current outlook day comparisons.
|
|
||||||
- Storm comparison should remain explicit-window based and consume storm
|
|
||||||
module outputs when implemented.
|
|
||||||
- Do not compare rendered Markdown or rendered YAML text.
|
|
||||||
- If a comparison-required module is missing, return an actionable error or an
|
|
||||||
inspectable warning according to the report policy chosen in code. Prefer an
|
|
||||||
error for required comparison modules and no-op only for optional comparison
|
|
||||||
stanzas.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Prior snapshot lookup still uses report compatibility and valid-period rules.
|
|
||||||
- Recent Changes output remains deterministic.
|
|
||||||
- Tests cover unchanged forecasts, threshold-crossing changes, alert changes,
|
|
||||||
precip timing changes, and missing comparison stanzas.
|
|
||||||
- Old `briefing.Package` comparison code is removed.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/changes ./internal/state ./internal/app
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt if module snapshots are
|
|
||||||
already available.
|
|
||||||
|
|
||||||
## Stage 11: Remove Old Briefing Shapes
|
|
||||||
|
|
||||||
Goal: remove obsolete report-shaped briefing containers and stale JSON package
|
|
||||||
assumptions.
|
|
||||||
|
|
||||||
Files/packages to change:
|
|
||||||
|
|
||||||
- `internal/briefing`;
|
|
||||||
- `internal/promptinput`;
|
|
||||||
- `internal/state`;
|
|
||||||
- `internal/app`;
|
|
||||||
- tests throughout `internal`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Remove old `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing container
|
|
||||||
structs when no longer used.
|
|
||||||
- Remove old `briefing.Package` if it no longer represents the module
|
|
||||||
snapshot. If the package keeps a `Package` type, it must be module-oriented.
|
|
||||||
- Remove tests that construct old report-shaped briefing fixtures.
|
|
||||||
- Remove stale `.data_package.json` assumptions.
|
|
||||||
- Remove dead helper functions that only supported old report-shaped output.
|
|
||||||
- Keep generated report Markdown behavior stable.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- `rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'`
|
|
||||||
has no production-code matches, except deliberate roadmap/history references
|
|
||||||
where appropriate.
|
|
||||||
- No old report-shaped content structs remain on the generation path.
|
|
||||||
- All tests pass.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 12: Documentation And Example Alignment
|
|
||||||
|
|
||||||
Goal: align non-roadmap docs with implemented module behavior.
|
|
||||||
|
|
||||||
Files to inspect/update:
|
|
||||||
|
|
||||||
- `README.md`, only if the orientation or quickstart changed;
|
|
||||||
- `docs/cli.md`;
|
|
||||||
- `docs/config.md`;
|
|
||||||
- `docs/operations.md`;
|
|
||||||
- `docs/troubleshooting.md`;
|
|
||||||
- `docs/internal/app-orchestration.md`;
|
|
||||||
- `docs/internal/briefing.md` or replacement module internals doc;
|
|
||||||
- `docs/internal/changes.md`;
|
|
||||||
- `docs/internal/forecast-derivation.md`;
|
|
||||||
- `docs/internal/prompt-input.md`;
|
|
||||||
- `docs/internal/state.md`;
|
|
||||||
- `docs/internal/weather-data.md`;
|
|
||||||
- `docs/integrations/scriptorium.md`;
|
|
||||||
- `examples/config.yml`.
|
|
||||||
|
|
||||||
Implementation guidance:
|
|
||||||
|
|
||||||
- Document only implemented behavior outside `docs/roadmap/`.
|
|
||||||
- Add or update an internal module contract document if module behavior is now
|
|
||||||
implemented.
|
|
||||||
- Document `inspect modules` and remove `inspect briefing`.
|
|
||||||
- Document YAML data packages and JSON module snapshots.
|
|
||||||
- Document report module overrides and typed options only if implemented.
|
|
||||||
- Keep QPF as future-only unless upstream support was added.
|
|
||||||
- Keep Scriptorium contract focused on `--input data_package=<path>` and the
|
|
||||||
actual file format now passed.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Non-roadmap docs no longer describe old report-shaped briefing packages.
|
|
||||||
- Config examples load.
|
|
||||||
- CLI examples match `weatherreporter --help`.
|
|
||||||
- Docs clearly distinguish module snapshots from prompt data packages.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
rg -n "inspect briefing|data_package\\.json|report-shaped|vars-file|promptvars" README.md docs examples internal -g '!docs/roadmap/**'
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 13: Final Validation
|
|
||||||
|
|
||||||
Goal: run full validation and catch stale assumptions after the cutover.
|
|
||||||
|
|
||||||
Required commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Required grep checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "inspect briefing|data_package\\.json|briefing\\.Package|Daily struct|ThreeDay struct|Weekend struct|Storm struct" internal docs examples -g '!docs/roadmap/**'
|
|
||||||
rg -n "measurable_qpf_total_in|max_hourly_qpf_in" internal docs examples -g '!docs/roadmap/**'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected grep results:
|
|
||||||
|
|
||||||
- no production-code references to `inspect briefing`;
|
|
||||||
- no production-code assumption that prompt packages are JSON;
|
|
||||||
- no production-code dependence on old report-shaped briefing containers;
|
|
||||||
- QPF references appear only as future-target docs or omitted-field tests until
|
|
||||||
upstream QPF exists.
|
|
||||||
|
|
||||||
Manual review:
|
|
||||||
|
|
||||||
- Generate command output still writes managed Markdown reports.
|
|
||||||
- Batch behavior still continues independent reports and returns nonzero on
|
|
||||||
aggregate failure.
|
|
||||||
- Distributor notification still uploads the managed Markdown report, not
|
|
||||||
module snapshots or YAML prompt packages.
|
|
||||||
- Secrets are not printed or persisted.
|
|
||||||
- YAML prompt package is readable and contains named stanzas.
|
|
||||||
|
|
||||||
## Deferred Work
|
|
||||||
|
|
||||||
Do not include these in the initial module cutover:
|
|
||||||
|
|
||||||
- plugin architecture;
|
|
||||||
- dynamic module loading;
|
|
||||||
- YAML-defined module schemas;
|
|
||||||
- user-authored module code;
|
|
||||||
- module-owned Weather API fetching;
|
|
||||||
- QPF fields before upstream QPF exists;
|
|
||||||
- SPC modules before upstream SPC data exists;
|
|
||||||
- radar modules before upstream radar data exists;
|
|
||||||
- event/storm-review reports unless a separate roadmap implements them.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
No blocking open questions remain for this implementation plan. The previously
|
|
||||||
identified choices are locked above:
|
|
||||||
|
|
||||||
- persist JSON module snapshots and YAML prompt data packages;
|
|
||||||
- split broad normalized source types into `internal/weatherdata`;
|
|
||||||
- replace `inspect briefing` with `inspect modules`;
|
|
||||||
- support typed module options from the first config implementation.
|
|
||||||
@@ -1,677 +0,0 @@
|
|||||||
# Modular Data Package Roadmap
|
|
||||||
|
|
||||||
This roadmap describes planned refactoring work that is not implemented.
|
|
||||||
Current behavior is documented outside `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Move weatherreporter toward deterministic, reusable briefing modules that can
|
|
||||||
be composed per report type. The goal is to make prompt input easier for the
|
|
||||||
LLM to understand, easier for operators to inspect, and easier for developers
|
|
||||||
to change without touching a cross-cutting set of report-builder files.
|
|
||||||
|
|
||||||
The target outcome is a prompt-facing YAML data package with named stanzas. Each
|
|
||||||
stanza should be built by a self-contained module that derives clear,
|
|
||||||
deterministic facts from normalized forecast inputs. Reports should choose
|
|
||||||
modules by ordered module IDs, so experimenting with a report can be as small
|
|
||||||
as changing one configuration line, plus any matching prompt change outside
|
|
||||||
weatherreporter.
|
|
||||||
|
|
||||||
The internal target shape is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CollectedFacts -> DerivedFacts -> ModuleOutput
|
|
||||||
```
|
|
||||||
|
|
||||||
Each arrow should be a stable internal contract. `DerivedFacts` should not need
|
|
||||||
to know how `CollectedFacts` were collected. Modules should not need to know
|
|
||||||
the provenance of any collected or derived fact they consume. Report building
|
|
||||||
should not need to know how a module sourced its underlying facts or calculated
|
|
||||||
its output.
|
|
||||||
|
|
||||||
## Intent And Context
|
|
||||||
|
|
||||||
The current application already curates source data before passing it to the
|
|
||||||
LLM. This refactor should strengthen that design. Modules should not expose raw
|
|
||||||
source complexity merely because it is available. They should compute and
|
|
||||||
package the facts the LLM should not have to infer from raw hourly periods,
|
|
||||||
alerts, narrative periods, forecast discussions, or weather stories.
|
|
||||||
|
|
||||||
The desired module behavior is deterministic. A module should answer a narrow
|
|
||||||
question such as:
|
|
||||||
|
|
||||||
- what are the current conditions;
|
|
||||||
- what are the key daily forecast facts;
|
|
||||||
- what are the daypart summaries;
|
|
||||||
- when is precipitation most likely;
|
|
||||||
- which alerts overlap the report period;
|
|
||||||
- what short-term AFD text is relevant;
|
|
||||||
- what weather story text is relevant.
|
|
||||||
|
|
||||||
The primary maintainability goal is local reasoning. For example, updating the
|
|
||||||
derived daily summary should mostly involve one module implementation and its
|
|
||||||
tests. Adding AFD short-term text to a future `next_six_hours` report should be
|
|
||||||
a report composition change, not a copy/paste change across multiple builders.
|
|
||||||
|
|
||||||
## Target Prompt Shape
|
|
||||||
|
|
||||||
The target prompt-facing data package should be YAML with named stanzas under
|
|
||||||
`briefing`. Named stanzas are preferred over an array of generic module objects
|
|
||||||
because they are easier to read, inspect, and reference in prompts.
|
|
||||||
|
|
||||||
Example target shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
report:
|
|
||||||
id: daily_today
|
|
||||||
prompt_id: weather.daily_report
|
|
||||||
generated_at: 2026-06-09T07:15:00-05:00
|
|
||||||
timezone: America/Chicago
|
|
||||||
current_local_date: 2026-06-09
|
|
||||||
valid_period:
|
|
||||||
start: 2026-06-09T00:00:00-05:00
|
|
||||||
end: 2026-06-10T00:00:00-05:00
|
|
||||||
|
|
||||||
briefing:
|
|
||||||
metadata:
|
|
||||||
location:
|
|
||||||
id: home
|
|
||||||
name: Brentwood
|
|
||||||
region: St. Louis Metro
|
|
||||||
timezone: America/Chicago
|
|
||||||
|
|
||||||
current_conditions:
|
|
||||||
condition_text: Partly cloudy
|
|
||||||
temperature_f: 74
|
|
||||||
apparent_temperature_f: 76
|
|
||||||
dewpoint_f: 66
|
|
||||||
relative_humidity_percent: 71
|
|
||||||
wind_speed_mph: 8
|
|
||||||
wind_direction_degrees: 190
|
|
||||||
|
|
||||||
derived_daily_summary:
|
|
||||||
high_temp_f: 86
|
|
||||||
low_temp_f: 68
|
|
||||||
max_pop_percent: 70
|
|
||||||
max_pop_window: "2 PM-6 PM"
|
|
||||||
measurable_qpf_total_in: 0.35
|
|
||||||
max_hourly_qpf_in: 0.12
|
|
||||||
first_precip_hour: "1 PM"
|
|
||||||
last_precip_hour: "8 PM"
|
|
||||||
thunder_mentioned: true
|
|
||||||
max_wind_gust_mph: 28
|
|
||||||
heat_index_max_f: 91
|
|
||||||
|
|
||||||
derived_daypart_summaries:
|
|
||||||
morning:
|
|
||||||
temp_range_f: "70-78"
|
|
||||||
max_pop_percent: 20
|
|
||||||
dominant_condition: Partly sunny
|
|
||||||
afternoon:
|
|
||||||
temp_range_f: "82-86"
|
|
||||||
max_pop_percent: 70
|
|
||||||
dominant_condition: Showers and thunderstorms likely
|
|
||||||
|
|
||||||
alert_digest:
|
|
||||||
checked: true
|
|
||||||
active_count: 0
|
|
||||||
relevant_count: 0
|
|
||||||
|
|
||||||
area_forecast_discussion:
|
|
||||||
key_messages:
|
|
||||||
- Scattered storms are possible this afternoon.
|
|
||||||
short_term: Showers and storms increase during the afternoon.
|
|
||||||
long_term: Periodic rain chances continue into the weekend.
|
|
||||||
|
|
||||||
weather_story:
|
|
||||||
available: true
|
|
||||||
title: Several Chances for Rain Through Monday
|
|
||||||
description: Scattered showers and thunderstorms remain possible.
|
|
||||||
|
|
||||||
recent_changes:
|
|
||||||
items: []
|
|
||||||
```
|
|
||||||
|
|
||||||
Field names should include units where the unit is not obvious:
|
|
||||||
`high_temp_f`, `max_pop_percent`, `measurable_qpf_total_in`,
|
|
||||||
`max_wind_gust_mph`, and similar names are preferred over ambiguous generic
|
|
||||||
names. Time and range strings should be formatted for prompt readability, while
|
|
||||||
machine-oriented timestamps should remain available in report metadata.
|
|
||||||
|
|
||||||
The QPF fields in the example are target output fields for a future upstream
|
|
||||||
source. They should not be treated as immediately implementable from the
|
|
||||||
current weatherfeeder-backed `CollectedFacts` sources. Until an upstream QPF
|
|
||||||
source exists, QPF fields should be omitted rather than fabricated from
|
|
||||||
precipitation probability or narrative text.
|
|
||||||
|
|
||||||
## Architecture Target
|
|
||||||
|
|
||||||
Keep the current package boundaries:
|
|
||||||
|
|
||||||
- `internal/forecast` owns normalized source data, deterministic forecast
|
|
||||||
derivation, period slicing, daypart grouping, and weather-signal calculations.
|
|
||||||
- `internal/briefing` owns prompt-facing module builders and module output
|
|
||||||
schemas.
|
|
||||||
- `internal/report` owns report identity, prompt ID, valid-period resolution,
|
|
||||||
output naming, comparison strategy, and default module composition.
|
|
||||||
- `internal/config` owns optional report module composition overrides.
|
|
||||||
- `internal/promptinput` owns final data-package assembly, validation, and
|
|
||||||
prompt-facing serialization.
|
|
||||||
- `internal/app` remains orchestration: resolve report, fetch sources, build
|
|
||||||
module context, execute configured modules, persist artifacts, run
|
|
||||||
Scriptorium, and notify distributor.
|
|
||||||
|
|
||||||
Do not move source fetching, subprocess execution, distributor upload behavior,
|
|
||||||
or raw external dependency types into module code.
|
|
||||||
|
|
||||||
## Layered Fact Contracts
|
|
||||||
|
|
||||||
Introduce explicit internal contracts for three layers:
|
|
||||||
|
|
||||||
1. `CollectedFacts`
|
|
||||||
2. `DerivedFacts`
|
|
||||||
3. `ModuleOutput`
|
|
||||||
|
|
||||||
`CollectedFacts` are normalized upstream inputs collected once per report run.
|
|
||||||
They should be broad and source-oriented, but not tied to Weather API transport
|
|
||||||
details. Examples include current conditions, observations, hourly forecast
|
|
||||||
runs, narrative forecast runs, active alerts, AFD discussion, weather story,
|
|
||||||
future radar inputs, and future historical observation totals.
|
|
||||||
|
|
||||||
`DerivedFacts` are reusable, report-scoped deterministic products calculated
|
|
||||||
from `CollectedFacts`. They may slice, combine, group, or summarize collected
|
|
||||||
facts when the result is broadly useful to more than one module or needed for
|
|
||||||
consistent behavior across modules. Examples include valid-period hourly
|
|
||||||
periods, valid-period narrative periods, alert overlaps, daily summaries,
|
|
||||||
configured daypart summaries, and reusable precipitation timing windows.
|
|
||||||
|
|
||||||
`ModuleOutput` is the prompt-facing output contract produced by one module. A
|
|
||||||
module may pass through raw-ish facts, such as AFD text, or expose derived
|
|
||||||
facts, such as daily summary fields. In both cases, the module owns the named
|
|
||||||
stanza shape and should produce stable, readable, unit-explicit prompt fields.
|
|
||||||
|
|
||||||
Unless implementation discovers a strong reason otherwise, the internal
|
|
||||||
contract for accessing `CollectedFacts` and `DerivedFacts` should have the same
|
|
||||||
shape:
|
|
||||||
|
|
||||||
- typed Go structs with named fields;
|
|
||||||
- nil pointers, empty slices, or zero values to represent absent facts;
|
|
||||||
- no `map[string]any` or string-keyed fact lookup as the primary API;
|
|
||||||
- immutable-by-convention values once passed to modules;
|
|
||||||
- helper methods only for repeated access patterns that would otherwise be
|
|
||||||
error-prone;
|
|
||||||
- source provenance and warnings stored separately from the primary fact
|
|
||||||
values, available to metadata/source-warning modules but not required by
|
|
||||||
ordinary modules.
|
|
||||||
|
|
||||||
Illustrative shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type CollectedFacts struct {
|
|
||||||
Current *weatherdata.CurrentConditions
|
|
||||||
Observations *weatherdata.ObservationRun
|
|
||||||
Alerts *weatherdata.AlertRun
|
|
||||||
Hourly *weatherdata.ForecastRun
|
|
||||||
Narrative *weatherdata.ForecastRun
|
|
||||||
Discussion *weatherdata.Discussion
|
|
||||||
WeatherStory *weatherdata.WeatherStory
|
|
||||||
// Future: radar, historical observations, snow/rain totals, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
type DerivedFacts struct {
|
|
||||||
HourlyPeriods []weatherdata.ForecastPeriod
|
|
||||||
NarrativePeriods []weatherdata.ForecastPeriod
|
|
||||||
AlertOverlaps []weatherdata.AlertOverlap
|
|
||||||
DailySummaries []forecast.DailySummary
|
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
|
||||||
PrecipTiming *forecast.PrecipTiming
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModuleContext struct {
|
|
||||||
Report report.Resolved
|
|
||||||
Collected CollectedFacts
|
|
||||||
Derived DerivedFacts
|
|
||||||
Units string
|
|
||||||
Timezone string
|
|
||||||
Location *LocationContext
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact package names may differ during implementation. The important
|
|
||||||
boundary is semantic: `CollectedFacts` represent upstream facts after
|
|
||||||
normalization; `DerivedFacts` represent reusable report-scoped transformations;
|
|
||||||
modules represent prompt-facing stanza construction.
|
|
||||||
|
|
||||||
### DerivedFacts Boundary
|
|
||||||
|
|
||||||
Be conservative about what belongs in `DerivedFacts`. Add a value to this layer
|
|
||||||
only when it is:
|
|
||||||
|
|
||||||
- deterministic;
|
|
||||||
- report-scoped;
|
|
||||||
- reusable by multiple modules or needed to keep modules consistent;
|
|
||||||
- independent of prompt wording and presentation decisions.
|
|
||||||
|
|
||||||
`DerivedFacts` may:
|
|
||||||
|
|
||||||
- slice source periods to the report valid period;
|
|
||||||
- group hourly data into configured dayparts;
|
|
||||||
- compute reusable summaries;
|
|
||||||
- compute alert overlaps;
|
|
||||||
- normalize repeated time-window selections.
|
|
||||||
|
|
||||||
`DerivedFacts` should not:
|
|
||||||
|
|
||||||
- decide prompt-facing wording;
|
|
||||||
- decide which facts are important for one module only;
|
|
||||||
- format prose-like strings for the LLM;
|
|
||||||
- fetch upstream data;
|
|
||||||
- write artifacts;
|
|
||||||
- depend on Scriptorium or distributor.
|
|
||||||
|
|
||||||
Module-specific calculations should remain inside the module when they are
|
|
||||||
presentation-specific, used by only one module, or likely to change while
|
|
||||||
tuning prompt behavior.
|
|
||||||
|
|
||||||
## Module Model
|
|
||||||
|
|
||||||
Introduce a typed module model rather than generic maps. A module should have:
|
|
||||||
|
|
||||||
- stable module ID;
|
|
||||||
- self-contained output struct;
|
|
||||||
- one focused builder function;
|
|
||||||
- fixture or unit tests near the module;
|
|
||||||
- declared input requirements, such as hourly forecast, alerts, discussion, or
|
|
||||||
weather story;
|
|
||||||
- deterministic handling for missing optional source data;
|
|
||||||
- prompt-facing field names that are stable and unit-explicit.
|
|
||||||
|
|
||||||
The implementation may use a simple function registry rather than a broad
|
|
||||||
interface if that is enough:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ModuleID string
|
|
||||||
|
|
||||||
type ModuleBuilder func(ModuleContext) (ModuleOutput, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
`ModuleOutput` should include the stable module ID, the YAML stanza name, and a
|
|
||||||
typed value owned by the module:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ModuleOutput struct {
|
|
||||||
ID ModuleID
|
|
||||||
StanzaName string
|
|
||||||
Value any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The module registry should preserve output order from report composition, but
|
|
||||||
the serialized YAML should use named stanzas for clarity.
|
|
||||||
|
|
||||||
Each module should be able to produce exactly one named stanza. If one source
|
|
||||||
can usefully feed multiple stanzas, split that into multiple modules rather than
|
|
||||||
making one module produce unrelated output.
|
|
||||||
|
|
||||||
## Report Composition Target
|
|
||||||
|
|
||||||
Report definitions should declare default ordered module IDs. Configuration may
|
|
||||||
override the ordered module list for implemented reports.
|
|
||||||
|
|
||||||
Illustrative future config shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
next_6_hours:
|
|
||||||
deterministic_modules:
|
|
||||||
- hourly_table
|
|
||||||
- precip_timing
|
|
||||||
- alert_digest
|
|
||||||
- afd_short_term_text
|
|
||||||
- weather_story_text
|
|
||||||
- spc_products
|
|
||||||
|
|
||||||
daily:
|
|
||||||
deterministic_modules:
|
|
||||||
- current_conditions
|
|
||||||
- derived_daily_summary
|
|
||||||
- derived_daypart_summaries
|
|
||||||
- precip_timing
|
|
||||||
- alert_digest
|
|
||||||
- forecast_delta
|
|
||||||
- afd_short_term_text
|
|
||||||
- weather_story_text
|
|
||||||
- spc_products
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuration should validate unknown module IDs, duplicate module IDs when
|
|
||||||
duplicates are not meaningful, and modules that are incompatible with the
|
|
||||||
selected report period. Defaults should remain in Go so the application works
|
|
||||||
without report composition config.
|
|
||||||
|
|
||||||
## Clean-Break Cutover Policy
|
|
||||||
|
|
||||||
This project is still pre-release. Prefer a direct cutover to the new internal
|
|
||||||
shape instead of preserving transitional report-shaped briefing structures.
|
|
||||||
|
|
||||||
Implementation should:
|
|
||||||
|
|
||||||
- replace report-shaped briefing containers with module-oriented snapshots;
|
|
||||||
- replace JSON prompt package output with YAML prompt package output;
|
|
||||||
- update inspect commands, Recent Changes, tests, and docs in the same cutover;
|
|
||||||
- remove obsolete `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing
|
|
||||||
container shapes when no longer needed;
|
|
||||||
- avoid compatibility aliases unless they materially reduce implementation
|
|
||||||
risk inside one stage.
|
|
||||||
|
|
||||||
The public CLI command names, report IDs, prompt IDs, RunID format, managed
|
|
||||||
Markdown report paths, and distributor upload source should remain stable unless
|
|
||||||
a separate roadmap explicitly changes them.
|
|
||||||
|
|
||||||
## Artifact And State Target
|
|
||||||
|
|
||||||
The durable artifacts should reflect the new module-oriented model.
|
|
||||||
|
|
||||||
Recommended target:
|
|
||||||
|
|
||||||
- module snapshot artifact: structured JSON for stable inspection, state
|
|
||||||
lookup, and Recent Changes comparisons;
|
|
||||||
- prompt data package artifact: YAML with named stanzas, passed to Scriptorium
|
|
||||||
as `data_package`;
|
|
||||||
- metadata artifact: JSON linking the module snapshot, YAML data package,
|
|
||||||
preflight output, rendered report, source warnings, source hashes, and
|
|
||||||
distributor notification artifact when present.
|
|
||||||
|
|
||||||
The workspace path names should make the artifact type clear. A future
|
|
||||||
implementation may keep the existing `data-packages/` directory, but file
|
|
||||||
extensions and metadata fields should reflect the real format, such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
workspace/
|
|
||||||
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
|
|
||||||
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `inspect briefing` with `inspect modules` during the cutover.
|
|
||||||
`inspect modules` should return the module snapshot. `inspect data-package`
|
|
||||||
should return the YAML artifact or a parsed representation of the YAML artifact.
|
|
||||||
Do not leave inspect commands pointed at obsolete report-shaped data.
|
|
||||||
|
|
||||||
## Module Options And Compatibility
|
|
||||||
|
|
||||||
Each module should have a typed options struct, even when initially empty.
|
|
||||||
Configuration may decode module options from YAML, but internal module builders
|
|
||||||
should receive typed options rather than `map[string]any`.
|
|
||||||
|
|
||||||
Illustrative config shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
next_6_hours:
|
|
||||||
deterministic_modules:
|
|
||||||
- id: hourly_table
|
|
||||||
options:
|
|
||||||
range: valid_period
|
|
||||||
fields:
|
|
||||||
- time
|
|
||||||
- temperature_f
|
|
||||||
- pop_percent
|
|
||||||
- wind_gust_mph
|
|
||||||
- id: afd_short_term_text
|
|
||||||
```
|
|
||||||
|
|
||||||
Module definitions should declare:
|
|
||||||
|
|
||||||
- module ID;
|
|
||||||
- stanza name;
|
|
||||||
- typed options schema;
|
|
||||||
- supported report IDs or report categories;
|
|
||||||
- required collected facts;
|
|
||||||
- required derived facts;
|
|
||||||
- whether missing optional facts omit the stanza, emit an empty stanza, or
|
|
||||||
produce a warning;
|
|
||||||
- whether duplicate use of the module is allowed.
|
|
||||||
|
|
||||||
Configuration validation should reject:
|
|
||||||
|
|
||||||
- unknown report IDs;
|
|
||||||
- unknown module IDs;
|
|
||||||
- duplicate module IDs unless explicitly allowed;
|
|
||||||
- two modules that render the same stanza name;
|
|
||||||
- module options that do not match the module's typed option schema;
|
|
||||||
- modules that are incompatible with the report's valid-period strategy or
|
|
||||||
available facts.
|
|
||||||
|
|
||||||
## Recent Changes Target
|
|
||||||
|
|
||||||
Recent Changes must remain structured and deterministic. During the clean-break
|
|
||||||
cutover, move comparison inputs away from report-shaped `briefing.Package`
|
|
||||||
values and toward module-oriented snapshots.
|
|
||||||
|
|
||||||
Recommended target:
|
|
||||||
|
|
||||||
- compare `ModuleOutput` values or typed module snapshot stanzas, not rendered
|
|
||||||
YAML and not rendered Markdown;
|
|
||||||
- keep report-compatible matching policy in `internal/report`;
|
|
||||||
- keep threshold configuration in `internal/config`;
|
|
||||||
- keep comparison algorithms in `internal/changes`;
|
|
||||||
- make each comparison explicit about which module stanzas it needs.
|
|
||||||
|
|
||||||
For example, Daily comparison should primarily consume
|
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `alert_digest`, and
|
|
||||||
`precip_timing` if present. If a required stanza is missing, the comparison
|
|
||||||
should return no change with an inspectable warning or an actionable error,
|
|
||||||
depending on the report's configured missing-data policy.
|
|
||||||
|
|
||||||
## Package Naming Target
|
|
||||||
|
|
||||||
The current `internal/forecast` package owns both forecast-specific derivation
|
|
||||||
and broader normalized weather data. Because planned sources include current
|
|
||||||
observations, radar, and historical review inputs, implementation should
|
|
||||||
consider splitting names during the clean-break refactor:
|
|
||||||
|
|
||||||
- `internal/weatherdata`: normalized collected source facts, source metadata,
|
|
||||||
warnings, and broad weather data types;
|
|
||||||
- `internal/forecast`: forecast-specific algorithms such as period slicing,
|
|
||||||
daily summaries, daypart grouping, and precipitation timing.
|
|
||||||
|
|
||||||
If this split is too large for the first cutover, introduce `CollectedFacts` in
|
|
||||||
the package that minimizes churn, but avoid expanding the meaning of
|
|
||||||
`internal/forecast` further in new module contracts.
|
|
||||||
|
|
||||||
## Initial Module Candidates
|
|
||||||
|
|
||||||
The first module catalog should start with the modules needed to replace current
|
|
||||||
report-shaped briefing output and should clearly distinguish implemented
|
|
||||||
modules from future-only candidates.
|
|
||||||
|
|
||||||
Initial candidates:
|
|
||||||
|
|
||||||
- `metadata`
|
|
||||||
- `current_conditions`
|
|
||||||
- `derived_daily_summary`
|
|
||||||
- `derived_daypart_summaries`
|
|
||||||
- `hourly_table`
|
|
||||||
- `precip_timing`
|
|
||||||
- `alert_digest`
|
|
||||||
- `area_forecast_discussion`
|
|
||||||
- `afd_key_messages`
|
|
||||||
- `afd_short_term_text`
|
|
||||||
- `afd_long_term_text`
|
|
||||||
- `weather_story`
|
|
||||||
- `forecast_delta`
|
|
||||||
- `outdoor_windows`
|
|
||||||
- `weekend_planning`
|
|
||||||
- `storm_window_summary`
|
|
||||||
|
|
||||||
Each module should declare inputs, outputs, report applicability,
|
|
||||||
missing-data behavior, compatibility behavior, and options.
|
|
||||||
|
|
||||||
## Target Derived Daily Summary
|
|
||||||
|
|
||||||
The intended `derived_daily_summary` shape is:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
derived_daily_summary:
|
|
||||||
high_temp_f: 86
|
|
||||||
low_temp_f: 68
|
|
||||||
max_pop_percent: 70
|
|
||||||
max_pop_window: "2 PM-6 PM"
|
|
||||||
measurable_qpf_total_in: 0.35
|
|
||||||
max_hourly_qpf_in: 0.12
|
|
||||||
first_precip_hour: "1 PM"
|
|
||||||
last_precip_hour: "8 PM"
|
|
||||||
thunder_mentioned: true
|
|
||||||
max_wind_gust_mph: 28
|
|
||||||
heat_index_max_f: 91
|
|
||||||
```
|
|
||||||
|
|
||||||
`measurable_qpf_total_in` and `max_hourly_qpf_in` are future target fields.
|
|
||||||
They require a real upstream quantitative precipitation source and should be
|
|
||||||
omitted until such a source is represented in `CollectedFacts`.
|
|
||||||
|
|
||||||
## Target Derived Daypart Summaries
|
|
||||||
|
|
||||||
The intended `derived_daypart_summaries` shape is:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
derived_daypart_summaries:
|
|
||||||
morning:
|
|
||||||
temp_range_f: "70-78"
|
|
||||||
max_pop_percent: 20
|
|
||||||
dominant_condition: Partly sunny
|
|
||||||
afternoon:
|
|
||||||
temp_range_f: "82-86"
|
|
||||||
max_pop_percent: 70
|
|
||||||
dominant_condition: Showers and thunderstorms likely
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configurable Composition Target
|
|
||||||
|
|
||||||
The target configuration model should allow report module composition to be
|
|
||||||
changed without editing cross-cutting report-builder code. Built-in defaults
|
|
||||||
should remain in Go so the application works with no module override config.
|
|
||||||
|
|
||||||
Illustrative config:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
daily:
|
|
||||||
deterministic_modules:
|
|
||||||
- current_conditions
|
|
||||||
- derived_daily_summary
|
|
||||||
- derived_daypart_summaries
|
|
||||||
- precip_timing
|
|
||||||
- alert_digest
|
|
||||||
- afd_short_term_text
|
|
||||||
- weather_story_text
|
|
||||||
```
|
|
||||||
|
|
||||||
Adding or removing an implemented module from an implemented report should be a
|
|
||||||
single config edit. Unknown modules, invalid options, duplicate stanzas, and
|
|
||||||
incompatible report/module combinations should fail with actionable errors.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
The refactor is complete when:
|
|
||||||
|
|
||||||
- current implemented reports generate successfully from named-stanza YAML
|
|
||||||
prompt packages;
|
|
||||||
- module snapshots are persisted as structured JSON and linked from metadata;
|
|
||||||
- `inspect modules` returns module snapshots;
|
|
||||||
- `inspect briefing` is removed from CLI help, parser support, and
|
|
||||||
non-roadmap docs;
|
|
||||||
- Recent Changes compares structured module snapshots, not rendered Markdown or
|
|
||||||
YAML text;
|
|
||||||
- report definitions declare default module order in one place;
|
|
||||||
- implemented report module composition can be overridden by config;
|
|
||||||
- implemented modules have typed options and compatibility contracts;
|
|
||||||
- `CollectedFacts` are built once per report run and reused by all modules;
|
|
||||||
- `DerivedFacts` are built from `CollectedFacts` and do not depend on adapter
|
|
||||||
transport details;
|
|
||||||
- module builders do not call Weather API, Scriptorium, distributor, or
|
|
||||||
filesystem state directly;
|
|
||||||
- stale report-shaped briefing containers are removed from the generation path;
|
|
||||||
- QPF output fields remain omitted until upstream QPF exists.
|
|
||||||
|
|
||||||
## Design Rules
|
|
||||||
|
|
||||||
- Keep modules deterministic.
|
|
||||||
- Keep modules self-contained where practical.
|
|
||||||
- Preserve the `CollectedFacts -> DerivedFacts -> ModuleOutput` boundary.
|
|
||||||
- Build `CollectedFacts` once per report run.
|
|
||||||
- Build `DerivedFacts` from `CollectedFacts`, not from adapter-specific
|
|
||||||
transport details.
|
|
||||||
- Keep `DerivedFacts` conservative and reusable.
|
|
||||||
- Keep raw external source details behind adapters and forecast normalization.
|
|
||||||
- Keep report composition centralized and ordered.
|
|
||||||
- Prefer typed outputs over generic maps.
|
|
||||||
- Prefer typed fact contracts over string-keyed fact registries.
|
|
||||||
- Prefer named YAML stanzas over generic module arrays.
|
|
||||||
- Use unit-explicit field names.
|
|
||||||
- Do not require the LLM to calculate obvious derived facts.
|
|
||||||
- Do not let module builders call external services or write durable state.
|
|
||||||
- Do not introduce plugins, dynamic loading, or a generic workflow engine.
|
|
||||||
|
|
||||||
## Risks And Mitigations
|
|
||||||
|
|
||||||
- Prompt contract churn: stage YAML introduction after module outputs are
|
|
||||||
tested and inspectable.
|
|
||||||
- Recent Changes drift: compare stable module outputs and keep snapshot tests.
|
|
||||||
- Over-abstraction: start with simple builders and a registry, not a framework.
|
|
||||||
- Config complexity: expose ordered module selection and typed options only for
|
|
||||||
implemented modules; defer broad parameterization.
|
|
||||||
- Loss of useful context: preserve focused source excerpts and source warnings,
|
|
||||||
but avoid reintroducing raw unbounded payloads.
|
|
||||||
|
|
||||||
## Deferred Work
|
|
||||||
|
|
||||||
These are out of scope for the initial module refactor:
|
|
||||||
|
|
||||||
- dynamic plugin loading;
|
|
||||||
- user-defined module code;
|
|
||||||
- YAML-defined module schemas;
|
|
||||||
- module parameterization beyond implemented typed options;
|
|
||||||
- replacing Weather API source fetching with module-owned fetches;
|
|
||||||
- moving prompt authoring or Scriptorium prompt changes into weatherreporter;
|
|
||||||
- adding future source modules, such as SPC products, before upstream data and
|
|
||||||
report requirements exist.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
### Should module snapshots and prompt packages both be persisted?
|
|
||||||
|
|
||||||
Recommended approach: persist module snapshots as JSON and prompt packages as
|
|
||||||
YAML. JSON module snapshots are better for structured Recent Changes, state
|
|
||||||
lookup, and tests. YAML prompt packages are better for prompt readability and
|
|
||||||
LLM consumption. Keeping both artifacts gives each use case the right format
|
|
||||||
without asking comparison code to parse prompt-oriented YAML.
|
|
||||||
|
|
||||||
Viable alternative: persist only the YAML prompt package and parse it for
|
|
||||||
inspection and Recent Changes. This reduces artifact count, but it couples
|
|
||||||
machine comparison to prompt formatting and makes future prompt-oriented
|
|
||||||
formatting changes riskier.
|
|
||||||
|
|
||||||
### Should `internal/forecast` be split during the first cutover?
|
|
||||||
|
|
||||||
Recommended approach: split broad normalized source types into
|
|
||||||
`internal/weatherdata` during the clean-break cutover if the implementation
|
|
||||||
scope remains manageable. This name fits current conditions, alerts,
|
|
||||||
discussion, weather story, future radar, and future historical data better than
|
|
||||||
`forecast`.
|
|
||||||
|
|
||||||
Viable alternative: keep existing `internal/forecast` types for the first
|
|
||||||
module implementation and introduce `CollectedFacts` as a wrapper. This reduces
|
|
||||||
short-term churn, but it leaves a package name that will become increasingly
|
|
||||||
misleading as non-forecast sources grow.
|
|
||||||
|
|
||||||
### Should module config support typed options immediately?
|
|
||||||
|
|
||||||
Recommended approach: support typed options for implemented modules from the
|
|
||||||
start, even if most modules use empty options. This establishes the extension
|
|
||||||
point needed for hourly ranges, field selection, and AFD section choices
|
|
||||||
without adding dynamic maps to module builders.
|
|
||||||
|
|
||||||
Viable alternative: initially support only ordered module IDs and add options
|
|
||||||
later. This is simpler, but it may force another config shape change as soon as
|
|
||||||
hourly range or section-selection experiments begin.
|
|
||||||
@@ -72,11 +72,11 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
- derived_daily_summary
|
- derived_daily_summary
|
||||||
- derived_daypart_summaries
|
- derived_daypart_summaries
|
||||||
- precip_timing
|
- precip_timing
|
||||||
- alert_digest
|
- alert_digest
|
||||||
- forecast_delta
|
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
@@ -86,3 +86,4 @@ reports:
|
|||||||
- long_term
|
- long_term
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
|
- hourly_forecast
|
||||||
|
|||||||
@@ -112,9 +112,6 @@ 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.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
}
|
}
|
||||||
@@ -267,15 +264,6 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
|
||||||
source := weatherdata.Source{
|
|
||||||
Name: sourceName,
|
|
||||||
FetchedAt: b.fetchedAt,
|
|
||||||
Missing: true,
|
|
||||||
}
|
|
||||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,11 +55,11 @@ 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) != 8 {
|
if len(bundle.Sources) != 7 {
|
||||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 1 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
@@ -197,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
|||||||
wantWarns int
|
wantWarns int
|
||||||
wantSource bool
|
wantSource bool
|
||||||
}{
|
}{
|
||||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
|
||||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -827,13 +827,6 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
|
|||||||
}
|
}
|
||||||
var outputs []module.Output
|
var outputs []module.Output
|
||||||
for _, item := range req.Resolved.Definition.Modules {
|
for _, item := range req.Resolved.Definition.Modules {
|
||||||
definition, err := registry.Lookup(item.ID)
|
|
||||||
if err != nil {
|
|
||||||
return module.Snapshot{}, err
|
|
||||||
}
|
|
||||||
if definition.Builder == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
output, err := registry.BuildModule(moduleContext, item)
|
output, err := registry.BuildModule(moduleContext, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return module.Snapshot{}, err
|
return module.Snapshot{}, err
|
||||||
|
|||||||
@@ -160,10 +160,33 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
||||||
!strings.Contains(string(data), "recent_changes:") ||
|
!strings.Contains(string(data), "recent_changes:") ||
|
||||||
|
!strings.Contains(string(data), "applicable_risk_products:") ||
|
||||||
|
!strings.Contains(string(data), "derived_summaries:") ||
|
||||||
|
!strings.Contains(string(data), "narrative_products:") ||
|
||||||
|
!strings.Contains(string(data), "raw_data:") ||
|
||||||
!strings.Contains(string(data), "current_conditions:") ||
|
!strings.Contains(string(data), "current_conditions:") ||
|
||||||
|
!strings.Contains(string(data), "narrative_forecast:") ||
|
||||||
|
!strings.Contains(string(data), "hourly_forecast:") ||
|
||||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||||
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), "source_warnings:") {
|
||||||
|
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
|
||||||
|
}
|
||||||
|
riskIndex := strings.Index(string(data), " applicable_risk_products:")
|
||||||
|
derivedIndex := strings.Index(string(data), " derived_summaries:")
|
||||||
|
narrativeIndex := strings.Index(string(data), " narrative_products:")
|
||||||
|
rawIndex := strings.Index(string(data), " raw_data:")
|
||||||
|
alertIndex := strings.Index(string(data), " alert_digest:")
|
||||||
|
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
|
||||||
|
storyIndex := strings.Index(string(data), " weather_story:")
|
||||||
|
currentIndex := strings.Index(string(data), " current_conditions:")
|
||||||
|
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
||||||
|
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||||
|
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
|
||||||
|
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||||
|
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
|
||||||
|
}
|
||||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode data package: %v", err)
|
t.Fatalf("decode data package: %v", err)
|
||||||
@@ -178,6 +201,14 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
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"])
|
||||||
}
|
}
|
||||||
|
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
|
||||||
|
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
||||||
|
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
||||||
|
}
|
||||||
|
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
|
||||||
|
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
||||||
|
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
||||||
|
}
|
||||||
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
||||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||||
@@ -665,7 +696,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode daily summary: %v", err)
|
t.Fatalf("decode daily summary: %v", err)
|
||||||
}
|
}
|
||||||
if !ok || dailySummary["date"] != "2026-05-30" {
|
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
|
||||||
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
||||||
}
|
}
|
||||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||||
@@ -954,8 +985,11 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InspectSources() error = %v", err)
|
t.Fatalf("InspectSources() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(sources.Sources) == 0 || len(sources.Warnings) == 0 {
|
if len(sources.Sources) == 0 {
|
||||||
t.Fatalf("sources = %#v, want provenance and warnings", sources)
|
t.Fatalf("sources = %#v, want provenance", sources)
|
||||||
|
}
|
||||||
|
if len(sources.Warnings) != 0 {
|
||||||
|
t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1339,14 +1373,15 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
|||||||
precip := 10
|
precip := 10
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
|
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
|
||||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||||
"low_temp_f": low,
|
"low_temp_f": low,
|
||||||
"high_temp_f": high,
|
"high_temp_f": high,
|
||||||
"max_pop_percent": precip,
|
"daily_precipitation_probability": precip,
|
||||||
}},
|
}},
|
||||||
{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{
|
||||||
"period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)},
|
"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"),
|
||||||
"temp_range_f": "50-58",
|
"temp_range_f": "50-58",
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
@@ -1368,7 +1403,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
|||||||
snapshot, err := module.NewSnapshot([]module.Output{
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
{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{
|
||||||
"period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")},
|
"date": date,
|
||||||
|
"period": date + " at 6:00 AM to " + 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",
|
||||||
|
|||||||
60
internal/briefing/alert_digest_module.go
Normal file
60
internal/briefing/alert_digest_module.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertDigestModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
RelevantCount int `json:"relevant_count"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertSummary struct {
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
||||||
|
if value == nil {
|
||||||
|
value = &AlertDigestModule{}
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
||||||
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
|
if collected.Alerts == nil && !missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := &AlertDigestModule{Missing: missing}
|
||||||
|
if collected.Alerts != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
value.RelevantCount = len(overlaps)
|
||||||
|
for _, overlap := range overlaps {
|
||||||
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
|
Event: overlap.Event,
|
||||||
|
Headline: overlap.Headline,
|
||||||
|
Severity: overlap.Severity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name && source.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
67
internal/briefing/area_forecast_discussion_module.go
Normal file
67
internal/briefing/area_forecast_discussion_module.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AreaForecastDiscussionModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
KeyMessages []string `json:"key_messages,omitempty"`
|
||||||
|
ShortTerm string `json:"short_term,omitempty"`
|
||||||
|
LongTerm string `json:"long_term,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||||
|
discussion := ctx.Collected.Discussion
|
||||||
|
if discussion == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||||
|
}
|
||||||
|
sections, err := areaForecastDiscussionSections(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value := AreaForecastDiscussionModule{}
|
||||||
|
if sections["product"] {
|
||||||
|
value.Product = discussion.Product
|
||||||
|
}
|
||||||
|
if sections["key_messages"] {
|
||||||
|
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||||
|
}
|
||||||
|
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||||
|
value.ShortTerm = discussion.ShortTerm.Text
|
||||||
|
}
|
||||||
|
if sections["long_term"] && discussion.LongTerm != nil {
|
||||||
|
value.LongTerm = discussion.LongTerm.Text
|
||||||
|
}
|
||||||
|
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||||
|
if len(options.Sections) == 0 {
|
||||||
|
return map[string]bool{
|
||||||
|
"product": true,
|
||||||
|
"key_messages": true,
|
||||||
|
"short_term": true,
|
||||||
|
"long_term": true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
sections := map[string]bool{}
|
||||||
|
for _, section := range options.Sections {
|
||||||
|
switch section {
|
||||||
|
case "product", "key_messages", "short_term", "long_term":
|
||||||
|
sections[section] = true
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MetadataModule struct {
|
|
||||||
RunID string `json:"run_id"`
|
|
||||||
ReportID report.ID `json:"report_id"`
|
|
||||||
Variant string `json:"variant,omitempty"`
|
|
||||||
PromptID string `json:"prompt_id"`
|
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
|
||||||
Units string `json:"units"`
|
|
||||||
Timezone string `json:"timezone"`
|
|
||||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
|
||||||
Location *LocationContext `json:"location,omitempty"`
|
|
||||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
|
||||||
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourceWarningSummary struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CurrentConditionsModule struct {
|
|
||||||
ConditionText string `json:"condition_text,omitempty"`
|
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
|
||||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
|
||||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
|
||||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
|
||||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
|
||||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
|
||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
|
||||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
|
||||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
|
||||||
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertDigestModule struct {
|
|
||||||
Checked bool `json:"checked"`
|
|
||||||
ActiveCount int `json:"active_count"`
|
|
||||||
RelevantCount int `json:"relevant_count"`
|
|
||||||
Missing bool `json:"missing,omitempty"`
|
|
||||||
Relevant []AlertSummary `json:"relevant,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertSummary struct {
|
|
||||||
Event string `json:"event,omitempty"`
|
|
||||||
Headline string `json:"headline,omitempty"`
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AreaForecastDiscussionModule struct {
|
|
||||||
Product string `json:"product,omitempty"`
|
|
||||||
KeyMessages []string `json:"key_messages,omitempty"`
|
|
||||||
ShortTerm string `json:"short_term,omitempty"`
|
|
||||||
LongTerm string `json:"long_term,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
|
||||||
StartTime time.Time `json:"start_time"`
|
|
||||||
EndTime time.Time `json:"end_time"`
|
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
AltText string `json:"alt_text,omitempty"`
|
|
||||||
Priority bool `json:"priority"`
|
|
||||||
Order int `json:"order"`
|
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
metadata := ctx.Resolved.Metadata()
|
|
||||||
value := MetadataModule{
|
|
||||||
RunID: metadata.RunID,
|
|
||||||
ReportID: metadata.ReportID,
|
|
||||||
Variant: variantForReport(metadata.ReportID),
|
|
||||||
PromptID: metadata.PromptID,
|
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
|
||||||
Units: ctx.Units,
|
|
||||||
Timezone: ctx.Timezone,
|
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
|
||||||
Location: copyLocation(ctx.Location),
|
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
|
||||||
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
current := ctx.Collected.Current
|
|
||||||
if current == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value := CurrentConditionsModule{
|
|
||||||
ConditionText: current.ConditionText,
|
|
||||||
IsDay: copyBool(current.IsDay),
|
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
|
||||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
|
||||||
}
|
|
||||||
if value.isEmpty() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
|
||||||
if value == nil {
|
|
||||||
value = &AlertDigestModule{}
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
|
||||||
discussion := ctx.Collected.Discussion
|
|
||||||
if discussion == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
|
||||||
}
|
|
||||||
sections, err := areaForecastDiscussionSections(opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
value := AreaForecastDiscussionModule{}
|
|
||||||
if sections["product"] {
|
|
||||||
value.Product = discussion.Product
|
|
||||||
}
|
|
||||||
if sections["key_messages"] {
|
|
||||||
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
|
||||||
}
|
|
||||||
if sections["short_term"] && discussion.ShortTerm != nil {
|
|
||||||
value.ShortTerm = discussion.ShortTerm.Text
|
|
||||||
}
|
|
||||||
if sections["long_term"] && discussion.LongTerm != nil {
|
|
||||||
value.LongTerm = discussion.LongTerm.Text
|
|
||||||
}
|
|
||||||
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
|
||||||
if len(options.Sections) == 0 {
|
|
||||||
return map[string]bool{
|
|
||||||
"product": true,
|
|
||||||
"key_messages": true,
|
|
||||||
"short_term": true,
|
|
||||||
"long_term": true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
sections := map[string]bool{}
|
|
||||||
for _, section := range options.Sections {
|
|
||||||
switch section {
|
|
||||||
case "product", "key_messages", "short_term", "long_term":
|
|
||||||
sections[section] = true
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sections, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
story := ctx.Collected.WeatherStory
|
|
||||||
if story == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value := WeatherStoryModule{
|
|
||||||
Available: true,
|
|
||||||
OfficeID: story.OfficeID,
|
|
||||||
StartTime: story.StartTime,
|
|
||||||
EndTime: story.EndTime,
|
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
|
||||||
Title: story.Title,
|
|
||||||
Description: story.Description,
|
|
||||||
AltText: story.AltText,
|
|
||||||
Priority: story.Priority,
|
|
||||||
Order: story.Order,
|
|
||||||
DownloadURL: story.DownloadURL,
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
|
||||||
out := make([]SourceWarningSummary, 0, len(warnings))
|
|
||||||
for _, warning := range warnings {
|
|
||||||
out = append(out, SourceWarningSummary{
|
|
||||||
Source: warning.Source,
|
|
||||||
Code: warning.Code,
|
|
||||||
Severity: warning.Severity,
|
|
||||||
Message: warning.Message,
|
|
||||||
CompletenessImpact: warning.CompletenessImpact,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
|
||||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
|
||||||
if collected.Alerts == nil && !missing {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
value := &AlertDigestModule{Missing: missing}
|
|
||||||
if collected.Alerts != nil {
|
|
||||||
value.Checked = true
|
|
||||||
value.ActiveCount = len(collected.Alerts.Alerts)
|
|
||||||
}
|
|
||||||
value.RelevantCount = len(overlaps)
|
|
||||||
for _, overlap := range overlaps {
|
|
||||||
value.Relevant = append(value.Relevant, AlertSummary{
|
|
||||||
Event: overlap.Event,
|
|
||||||
Headline: overlap.Headline,
|
|
||||||
Severity: overlap.Severity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
|
||||||
for _, source := range sources {
|
|
||||||
if source.Name == name && source.Missing {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
|
||||||
return v.ConditionText == "" &&
|
|
||||||
v.IsDay == nil &&
|
|
||||||
v.TemperatureC == nil &&
|
|
||||||
v.TemperatureF == nil &&
|
|
||||||
v.ApparentTemperatureC == nil &&
|
|
||||||
v.ApparentTemperatureF == nil &&
|
|
||||||
v.DewpointC == nil &&
|
|
||||||
v.DewpointF == nil &&
|
|
||||||
v.RelativeHumidityPercent == nil &&
|
|
||||||
v.WindSpeedKmh == nil &&
|
|
||||||
v.WindSpeedMph == nil &&
|
|
||||||
v.WindDirectionDegrees == nil
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,8 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{id: module.Metadata, stanza: "metadata"},
|
{id: module.Metadata, stanza: "metadata"},
|
||||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||||
|
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||||
|
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||||
{id: module.WeatherStory, stanza: "weather_story"},
|
{id: module.WeatherStory, stanza: "weather_story"},
|
||||||
@@ -44,6 +46,114 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||||
|
}
|
||||||
|
period := value.Periods[0]
|
||||||
|
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||||
|
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||||
|
}
|
||||||
|
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
||||||
|
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||||
|
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[NarrativeForecastModule](t, output)
|
||||||
|
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||||
|
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
|
||||||
|
}
|
||||||
|
period := value.Periods[0]
|
||||||
|
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||||
|
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" {
|
||||||
|
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "NE" {
|
||||||
|
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
|
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`) {
|
||||||
|
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
|
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -87,16 +197,22 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
||||||
}
|
}
|
||||||
|
if value.WindDirection != "S" {
|
||||||
|
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal current conditions: %v", err)
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} {
|
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
|
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
@@ -130,10 +246,14 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
|||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
ctx.Collected.Current = nil
|
ctx.Collected.Current = nil
|
||||||
|
ctx.Collected.Narrative = nil
|
||||||
|
ctx.Collected.Hourly = nil
|
||||||
|
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||||
|
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||||
ctx.Collected.Discussion = nil
|
ctx.Collected.Discussion = nil
|
||||||
ctx.Collected.WeatherStory = nil
|
ctx.Collected.WeatherStory = nil
|
||||||
|
|
||||||
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
|
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||||
@@ -212,6 +332,14 @@ func testModuleContext() ModuleContext {
|
|||||||
humidity := 71.0
|
humidity := 71.0
|
||||||
windMph := 8.0
|
windMph := 8.0
|
||||||
windDirection := 190.0
|
windDirection := 190.0
|
||||||
|
narrativeTempF := 81.0
|
||||||
|
narrativePop := 60.0
|
||||||
|
narrativeWind := 12.0
|
||||||
|
narrativeWindDirection := 45.0
|
||||||
|
hourlyTempF := 76.0
|
||||||
|
hourlyPop := 70.0
|
||||||
|
hourlyHumidity := 66.0
|
||||||
|
hourlyWindMph := 14.0
|
||||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
return ModuleContext{
|
return ModuleContext{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -225,6 +353,50 @@ func testModuleContext() ModuleContext {
|
|||||||
WindSpeedMph: &windMph,
|
WindSpeedMph: &windMph,
|
||||||
WindDirectionDegrees: &windDirection,
|
WindDirectionDegrees: &windDirection,
|
||||||
},
|
},
|
||||||
|
Narrative: &weatherdata.ForecastRun{
|
||||||
|
LocationID: "test-grid",
|
||||||
|
LocationName: "Testville",
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Product: "narrative",
|
||||||
|
Periods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: &isDay,
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hourly: &weatherdata.ForecastRun{
|
||||||
|
LocationID: "test-grid",
|
||||||
|
LocationName: "Testville",
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Product: "hourly",
|
||||||
|
Periods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||||
|
TextDescription: "Tomorrow showers.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||||
}},
|
}},
|
||||||
@@ -257,6 +429,31 @@ func testModuleContext() ModuleContext {
|
|||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
Derived: facts.DerivedFacts{
|
Derived: facts.DerivedFacts{
|
||||||
|
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: &isDay,
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
|
},
|
||||||
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{
|
AlertOverlaps: []forecast.AlertOverlap{{
|
||||||
Event: "Flood Watch",
|
Event: "Flood Watch",
|
||||||
Headline: "Flooding possible",
|
Headline: "Flooding possible",
|
||||||
|
|||||||
58
internal/briefing/current_conditions_module.go
Normal file
58
internal/briefing/current_conditions_module.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type CurrentConditionsModule struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
current := ctx.Collected.Current
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := CurrentConditionsModule{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: copyFloat(current.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||||
|
DewpointC: copyFloat(current.DewpointC),
|
||||||
|
DewpointF: copyFloat(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
|
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
|
return v.ConditionText == "" &&
|
||||||
|
v.IsDay == nil &&
|
||||||
|
v.TemperatureC == nil &&
|
||||||
|
v.TemperatureF == nil &&
|
||||||
|
v.ApparentTemperatureC == nil &&
|
||||||
|
v.ApparentTemperatureF == nil &&
|
||||||
|
v.DewpointC == nil &&
|
||||||
|
v.DewpointF == nil &&
|
||||||
|
v.RelativeHumidityPercent == nil &&
|
||||||
|
v.WindSpeedKmh == nil &&
|
||||||
|
v.WindSpeedMph == nil &&
|
||||||
|
v.WindDirection == ""
|
||||||
|
}
|
||||||
153
internal/briefing/derived_daily_summary_module.go
Normal file
153
internal/briefing/derived_daily_summary_module.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DerivedDailySummaryModule struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
|
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||||
|
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||||
|
Hazards []string `json:"hazards,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return nil, fmt.Errorf("daily summary facts are required")
|
||||||
|
}
|
||||||
|
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||||
|
value := DerivedDailySummaryModule{
|
||||||
|
Date: friendlyDateLabel(summary.Date, timezone),
|
||||||
|
ThunderMentioned: timing.ThunderMentioned,
|
||||||
|
}
|
||||||
|
conditions := map[string]struct{}{}
|
||||||
|
hazards := map[string]struct{}{}
|
||||||
|
var temperature forecast.Range
|
||||||
|
var apparent forecast.Range
|
||||||
|
var maxPop *forecast.TimedValue
|
||||||
|
var maxGust *forecast.TimedValue
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
addRange(&temperature, daypart.Temperature)
|
||||||
|
addRange(&apparent, daypart.ApparentTemperature)
|
||||||
|
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
|
||||||
|
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||||
|
if daypart.DominantCondition != "" {
|
||||||
|
conditions[daypart.DominantCondition] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||||
|
hazards[hazard] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
hazards[alert.Event] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||||
|
if narrativeTemperature.Max != nil {
|
||||||
|
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||||
|
} else {
|
||||||
|
value.HighTempF = roundedInt(temperature.Max)
|
||||||
|
}
|
||||||
|
if narrativeTemperature.Min != nil {
|
||||||
|
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||||
|
} else {
|
||||||
|
value.LowTempF = roundedInt(temperature.Min)
|
||||||
|
}
|
||||||
|
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||||
|
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||||
|
if narrativePrecipitation != nil {
|
||||||
|
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||||
|
} else if maxPop != nil {
|
||||||
|
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
|
||||||
|
}
|
||||||
|
if maxPop != nil {
|
||||||
|
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
|
||||||
|
}
|
||||||
|
if maxGust != nil {
|
||||||
|
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||||
|
}
|
||||||
|
value.DominantConditions = sortedSet(conditions)
|
||||||
|
value.Hazards = sortedSet(hazards)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
|
||||||
|
var out forecast.Range
|
||||||
|
for _, period := range periods {
|
||||||
|
addNarrativeHigh(&out, period.TemperatureFMax)
|
||||||
|
addNarrativeLow(&out, period.TemperatureFMin)
|
||||||
|
if period.TemperatureF != nil && period.IsDay != nil {
|
||||||
|
if *period.IsDay {
|
||||||
|
addNarrativeHigh(&out, period.TemperatureF)
|
||||||
|
} else {
|
||||||
|
addNarrativeLow(&out, period.TemperatureF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNarrativeHigh(target *forecast.Range, value *float64) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Max == nil || *value > *target.Max {
|
||||||
|
copied := *value
|
||||||
|
target.Max = &copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNarrativeLow(target *forecast.Range, value *float64) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Min == nil || *value < *target.Min {
|
||||||
|
copied := *value
|
||||||
|
target.Min = &copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
|
||||||
|
var maxPop *forecast.TimedValue
|
||||||
|
for _, period := range periods {
|
||||||
|
if period.ProbabilityOfPrecipitationPercent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := forecast.TimedValue{
|
||||||
|
Value: *period.ProbabilityOfPrecipitationPercent,
|
||||||
|
Time: period.StartTime,
|
||||||
|
}
|
||||||
|
maxTimedValue(&maxPop, &value)
|
||||||
|
}
|
||||||
|
return maxPop
|
||||||
|
}
|
||||||
|
|
||||||
|
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||||
|
if maxPop == nil || maxPop.Value <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
percent := roundedInt(&maxPop.Value)
|
||||||
|
if percent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
|
||||||
|
}
|
||||||
108
internal/briefing/derived_daypart_summaries_module.go
Normal file
108
internal/briefing/derived_daypart_summaries_module.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DerivedDaypartSummaryModule struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Period string `json:"period,omitempty"`
|
||||||
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
Fog bool `json:"fog,omitempty"`
|
||||||
|
Heat bool `json:"heat,omitempty"`
|
||||||
|
Cold bool `json:"cold,omitempty"`
|
||||||
|
Wind bool `json:"wind,omitempty"`
|
||||||
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||||
|
return nil, fmt.Errorf("daypart summary facts are required")
|
||||||
|
}
|
||||||
|
value := map[string]DerivedDaypartSummaryModule{}
|
||||||
|
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||||
|
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||||
|
key := daypartKey(daypart, prefixDates)
|
||||||
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
|
value := DerivedDaypartSummaryModule{
|
||||||
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
|
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
||||||
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
|
DominantCondition: daypart.DominantCondition,
|
||||||
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
|
Snow: daypart.Indicators.Snow,
|
||||||
|
Ice: daypart.Indicators.Ice,
|
||||||
|
Fog: daypart.Indicators.Fog,
|
||||||
|
Heat: daypart.Indicators.Heat,
|
||||||
|
Cold: daypart.Indicators.Cold,
|
||||||
|
Wind: daypart.Indicators.Wind,
|
||||||
|
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||||
|
}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil {
|
||||||
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil {
|
||||||
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
|
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, summary := range summaries {
|
||||||
|
seen[summary.Date] = struct{}{}
|
||||||
|
}
|
||||||
|
return len(seen) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||||
|
key := normalizedKey(daypart.Name)
|
||||||
|
if key == "" {
|
||||||
|
key = "unnamed"
|
||||||
|
}
|
||||||
|
if !prefixDate {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedKey(value string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var out strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
out.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastUnderscore {
|
||||||
|
out.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(out.String(), "_")
|
||||||
|
}
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DerivedDailySummaryModule struct {
|
|
||||||
Date string `json:"date,omitempty"`
|
|
||||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
|
||||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
||||||
MaxPopWindow string `json:"max_pop_window,omitempty"`
|
|
||||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
|
||||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
||||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
|
||||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
|
||||||
Period timeutil.Period `json:"period"`
|
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
|
||||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
|
||||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
|
||||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
|
||||||
Snow bool `json:"snow,omitempty"`
|
|
||||||
Ice bool `json:"ice,omitempty"`
|
|
||||||
Fog bool `json:"fog,omitempty"`
|
|
||||||
Heat bool `json:"heat,omitempty"`
|
|
||||||
Cold bool `json:"cold,omitempty"`
|
|
||||||
Wind bool `json:"wind,omitempty"`
|
|
||||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrecipTimingModule struct {
|
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
|
||||||
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
|
|
||||||
LastPrecipHour string `json:"last_precip_hour,omitempty"`
|
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutdoorWindowsModule struct {
|
|
||||||
Best *OutdoorWindowModule `json:"best,omitempty"`
|
|
||||||
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutdoorWindowModule struct {
|
|
||||||
Daypart string `json:"daypart"`
|
|
||||||
Start string `json:"start"`
|
|
||||||
End string `json:"end"`
|
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TomorrowPlanningModule struct {
|
|
||||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
|
||||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
|
||||||
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
summary := ctx.Derived.FirstDailySummary()
|
|
||||||
if summary == nil {
|
|
||||||
return nil, fmt.Errorf("daily summary facts are required")
|
|
||||||
}
|
|
||||||
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
|
||||||
return nil, fmt.Errorf("daypart summary facts are required")
|
|
||||||
}
|
|
||||||
value := map[string]DerivedDaypartSummaryModule{}
|
|
||||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
|
||||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
|
||||||
key := daypartKey(daypart, prefixDates)
|
|
||||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
|
||||||
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
|
||||||
value := OutdoorWindowsModule{
|
|
||||||
Best: outdoorWindowValue(windows.Best),
|
|
||||||
Worst: outdoorWindowValue(windows.Worst),
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|
||||||
summary := ctx.Derived.FirstDailySummary()
|
|
||||||
if summary == nil {
|
|
||||||
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
|
||||||
}
|
|
||||||
planning := buildTomorrowPlanning(summary)
|
|
||||||
value := TomorrowPlanningModule{}
|
|
||||||
if planning != nil {
|
|
||||||
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
|
||||||
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
|
||||||
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
|
||||||
}
|
|
||||||
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
|
||||||
value := DerivedDailySummaryModule{
|
|
||||||
Date: summary.Date,
|
|
||||||
ThunderMentioned: timing.ThunderMentioned,
|
|
||||||
}
|
|
||||||
conditions := map[string]struct{}{}
|
|
||||||
hazards := map[string]struct{}{}
|
|
||||||
var temperature forecast.Range
|
|
||||||
var apparent forecast.Range
|
|
||||||
var maxPop *forecast.TimedValue
|
|
||||||
var maxGust *forecast.TimedValue
|
|
||||||
var maxPopWindow timeutil.Period
|
|
||||||
for _, daypart := range summary.Dayparts {
|
|
||||||
addRange(&temperature, daypart.Temperature)
|
|
||||||
addRange(&apparent, daypart.ApparentTemperature)
|
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
|
||||||
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
|
|
||||||
copied := *daypart.MaxPrecipitationProbability
|
|
||||||
maxPop = &copied
|
|
||||||
maxPopWindow = daypart.Period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
|
||||||
if daypart.DominantCondition != "" {
|
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
|
||||||
hazards[hazard] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, alert := range summary.AlertOverlaps {
|
|
||||||
if alert.Event != "" {
|
|
||||||
hazards[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value.HighTempF = roundedInt(temperature.Max)
|
|
||||||
value.LowTempF = roundedInt(temperature.Min)
|
|
||||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
|
||||||
if maxPop != nil {
|
|
||||||
value.MaxPopPercent = roundedInt(&maxPop.Value)
|
|
||||||
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
|
|
||||||
}
|
|
||||||
if maxGust != nil {
|
|
||||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
|
||||||
}
|
|
||||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
|
||||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
|
||||||
value.DominantConditions = sortedSet(conditions)
|
|
||||||
value.Hazards = sortedSet(hazards)
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
|
||||||
value := DerivedDaypartSummaryModule{
|
|
||||||
Period: daypart.Period,
|
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
|
||||||
DominantCondition: daypart.DominantCondition,
|
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
|
||||||
Snow: daypart.Indicators.Snow,
|
|
||||||
Ice: daypart.Indicators.Ice,
|
|
||||||
Fog: daypart.Indicators.Fog,
|
|
||||||
Heat: daypart.Indicators.Heat,
|
|
||||||
Cold: daypart.Indicators.Cold,
|
|
||||||
Wind: daypart.Indicators.Wind,
|
|
||||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
|
||||||
}
|
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
|
||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
|
||||||
}
|
|
||||||
if daypart.PeakWindGust != nil {
|
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
|
||||||
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
|
||||||
value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned}
|
|
||||||
if timing.MaxPrecipitationProbability != nil {
|
|
||||||
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
|
||||||
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
|
||||||
}
|
|
||||||
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
|
|
||||||
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
|
||||||
if window == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &OutdoorWindowModule{
|
|
||||||
Daypart: window.Daypart,
|
|
||||||
Start: window.Start,
|
|
||||||
End: window.End,
|
|
||||||
Reasons: append([]string(nil), window.Reasons...),
|
|
||||||
Score: window.Score,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, summary := range summaries {
|
|
||||||
seen[summary.Date] = struct{}{}
|
|
||||||
}
|
|
||||||
return len(seen) > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
|
||||||
key := normalizedKey(daypart.Name)
|
|
||||||
if key == "" {
|
|
||||||
key = "unnamed"
|
|
||||||
}
|
|
||||||
if !prefixDate {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizedKey(value string) string {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
var out strings.Builder
|
|
||||||
lastUnderscore := false
|
|
||||||
for _, r := range lower {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
||||||
out.WriteRune(r)
|
|
||||||
lastUnderscore = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !lastUnderscore {
|
|
||||||
out.WriteByte('_')
|
|
||||||
lastUnderscore = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Trim(out.String(), "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rangeLabel(value forecast.Range) string {
|
|
||||||
if value.Min == nil && value.Max == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if value.Min != nil && value.Max != nil {
|
|
||||||
low := roundedInt(value.Min)
|
|
||||||
high := roundedInt(value.Max)
|
|
||||||
if low != nil && high != nil && *low == *high {
|
|
||||||
return fmt.Sprintf("%d", *low)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d-%d", *low, *high)
|
|
||||||
}
|
|
||||||
if value.Min != nil {
|
|
||||||
low := roundedInt(value.Min)
|
|
||||||
return fmt.Sprintf("%d", *low)
|
|
||||||
}
|
|
||||||
high := roundedInt(value.Max)
|
|
||||||
return fmt.Sprintf("%d", *high)
|
|
||||||
}
|
|
||||||
|
|
||||||
func daypartApparentRangeLabel(value forecast.Range) string {
|
|
||||||
if value.Min == nil && value.Max == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return rangeLabel(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundedInt(value *float64) *int {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rounded := int(*value + 0.5)
|
|
||||||
if *value < 0 {
|
|
||||||
rounded = int(*value - 0.5)
|
|
||||||
}
|
|
||||||
return &rounded
|
|
||||||
}
|
|
||||||
|
|
||||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
|
||||||
if value == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return clockLabel(value.Time, timezone)
|
|
||||||
}
|
|
||||||
|
|
||||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
|
||||||
if !period.IsValid() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clockLabel(value time.Time, timezone string) string {
|
|
||||||
location, err := timeutil.LoadLocation(timezone)
|
|
||||||
if err != nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
label := value.In(location).Format("3 PM")
|
|
||||||
if label == "12 AM" && value.In(location).Minute() == 0 {
|
|
||||||
return "12 AM"
|
|
||||||
}
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
@@ -24,14 +24,20 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||||
|
|
||||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
if value.Date != "Friday, May 29, 2026" {
|
||||||
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
|
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||||
}
|
}
|
||||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
|
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||||
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
|
||||||
}
|
}
|
||||||
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
|
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
|
||||||
|
}
|
||||||
|
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||||
|
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||||
|
}
|
||||||
|
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||||
|
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||||
}
|
}
|
||||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
@@ -44,16 +50,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
t.Fatalf("marshal daily summary: %v", err)
|
t.Fatalf("marshal daily summary: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"high_temp_f", "low_temp_f", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} {
|
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||||
|
if strings.Contains(jsonText, removed) {
|
||||||
|
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
if strings.Contains(jsonText, "qpf") {
|
if strings.Contains(jsonText, "qpf") {
|
||||||
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||||
|
|
||||||
|
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||||
|
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
|
||||||
|
}
|
||||||
|
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
|
||||||
|
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
|
||||||
|
}
|
||||||
|
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
|
||||||
|
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
@@ -63,8 +96,27 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||||
}
|
}
|
||||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
|
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
|
||||||
t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy)
|
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||||
|
}
|
||||||
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal precip timing: %v", err)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||||
@@ -73,8 +125,8 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||||
}
|
}
|
||||||
dry := moduleValue[PrecipTimingModule](t, output)
|
dry := moduleValue[PrecipTimingModule](t, output)
|
||||||
if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
|
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||||
t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry)
|
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||||
}
|
}
|
||||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||||
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||||
@@ -98,6 +150,9 @@ 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" {
|
||||||
|
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
||||||
|
}
|
||||||
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 {
|
||||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
@@ -111,11 +166,14 @@ 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{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
for _, field := range []string{"date", "period", "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`) {
|
||||||
|
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
@@ -191,10 +249,33 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
hours := []weatherdata.ForecastPeriod{
|
hours := []weatherdata.ForecastPeriod{
|
||||||
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
||||||
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
||||||
|
derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
|
||||||
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||||
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||||
|
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
|
||||||
|
}
|
||||||
|
narrative := []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: boolPtr(true),
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureFMax: floatPtr(88),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(55),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Tonight",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||||
|
IsDay: boolPtr(false),
|
||||||
|
TextDescription: "Clouds linger tonight.",
|
||||||
|
TemperatureFMin: floatPtr(64),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||||
|
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||||
return ModuleContext{
|
return ModuleContext{
|
||||||
Resolved: report.Resolved{
|
Resolved: report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
@@ -202,11 +283,19 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
ValidPeriod: summary.Period,
|
ValidPeriod: summary.Period,
|
||||||
},
|
},
|
||||||
|
Collected: facts.CollectedFacts{
|
||||||
|
Narrative: &weatherdata.ForecastRun{
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
Product: "narrative",
|
||||||
|
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
|
||||||
|
},
|
||||||
|
},
|
||||||
Derived: facts.DerivedFacts{
|
Derived: facts.DerivedFacts{
|
||||||
ValidPeriodHourlyPeriods: hours,
|
ValidPeriodHourlyPeriods: hours,
|
||||||
DailySummaries: []forecast.DailySummary{summary},
|
ValidPeriodNarrativePeriods: narrative,
|
||||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
DailySummaries: []forecast.DailySummary{summary},
|
||||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||||
|
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||||
},
|
},
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
@@ -238,3 +327,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
|
|||||||
func floatPtr(value float64) *float64 {
|
func floatPtr(value float64) *float64 {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtr(value bool) *bool {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, want string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
123
internal/briefing/hourly_forecast_module.go
Normal file
123
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HourlyForecastModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPeriod struct {
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
hourly := ctx.Collected.Hourly
|
||||||
|
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := HourlyForecastModule{
|
||||||
|
Product: hourly.Product,
|
||||||
|
IssuedAt: hourly.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||||
|
SourceLocation: hourly.LocationName,
|
||||||
|
SourceLocationID: hourly.LocationID,
|
||||||
|
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
out = append(out, HourlyForecastPeriod{
|
||||||
|
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||||
|
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v HourlyForecastModule) isEmpty() bool {
|
||||||
|
return v.Product == "" &&
|
||||||
|
v.IssuedAt.IsZero() &&
|
||||||
|
v.UpdatedAt == nil &&
|
||||||
|
v.SourceLocation == "" &&
|
||||||
|
v.SourceLocationID == "" &&
|
||||||
|
len(v.Periods) == 0
|
||||||
|
}
|
||||||
64
internal/briefing/metadata_module.go
Normal file
64
internal/briefing/metadata_module.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetadataModule struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
ReportID report.ID `json:"report_id"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Units string `json:"units"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
|
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceWarningSummary struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
metadata := ctx.Resolved.Metadata()
|
||||||
|
value := MetadataModule{
|
||||||
|
RunID: metadata.RunID,
|
||||||
|
ReportID: metadata.ReportID,
|
||||||
|
Variant: variantForReport(metadata.ReportID),
|
||||||
|
PromptID: metadata.PromptID,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
Units: ctx.Units,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
|
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||||
|
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||||
|
for _, warning := range warnings {
|
||||||
|
out = append(out, SourceWarningSummary{
|
||||||
|
Source: warning.Source,
|
||||||
|
Code: warning.Code,
|
||||||
|
Severity: warning.Severity,
|
||||||
|
Message: warning.Message,
|
||||||
|
CompletenessImpact: warning.CompletenessImpact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
128
internal/briefing/module_format_helpers.go
Normal file
128
internal/briefing/module_format_helpers.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rangeLabel(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.Min != nil && value.Max != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if low != nil && high != nil && *low == *high {
|
||||||
|
return fmt.Sprintf("%d", *low)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d-%d", *low, *high)
|
||||||
|
}
|
||||||
|
if value.Min != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
return fmt.Sprintf("%d", *low)
|
||||||
|
}
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
return fmt.Sprintf("%d", *high)
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartApparentRangeLabel(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rangeLabel(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundedInt(value *float64) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rounded := int(*value + 0.5)
|
||||||
|
if *value < 0 {
|
||||||
|
rounded = int(*value - 0.5)
|
||||||
|
}
|
||||||
|
return &rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
func windDirectionLabel(degrees *float64) string {
|
||||||
|
if degrees == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
|
||||||
|
normalized := math.Mod(*degrees, 360)
|
||||||
|
if normalized < 0 {
|
||||||
|
normalized += 360
|
||||||
|
}
|
||||||
|
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||||
|
return labels[sector]
|
||||||
|
}
|
||||||
|
|
||||||
|
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clockLabel(value.Time, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func periodClockLabel(period timeutil.Period, timezone string) string {
|
||||||
|
if !period.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
||||||
|
if !period.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyDateLabel(date string, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
|
||||||
|
if err != nil {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
return parsed.Format("Monday, January 2, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDateLabel(value time.Time, timezone string) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format(timeutil.DateLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clockLabel(value time.Time, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
label := value.In(location).Format("3 PM")
|
||||||
|
if label == "12 AM" && value.In(location).Minute() == 0 {
|
||||||
|
return "12 AM"
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
29
internal/briefing/module_format_helpers_test.go
Normal file
29
internal/briefing/module_format_helpers_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
degrees *float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil", degrees: nil, want: ""},
|
||||||
|
{name: "north", degrees: floatPtr(0), want: "N"},
|
||||||
|
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
|
||||||
|
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
|
||||||
|
{name: "northeast", degrees: floatPtr(45), want: "NE"},
|
||||||
|
{name: "south", degrees: floatPtr(180), want: "S"},
|
||||||
|
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
|
||||||
|
{name: "full rotation", degrees: floatPtr(360), want: "N"},
|
||||||
|
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := windDirectionLabel(tt.degrees); got != tt.want {
|
||||||
|
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package briefing
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -61,6 +62,12 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
|||||||
if _, ok := registry.definitions[definition.ID]; ok {
|
if _, ok := registry.definitions[definition.ID]; ok {
|
||||||
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
||||||
}
|
}
|
||||||
|
if definition.Builder == nil {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
|
||||||
|
}
|
||||||
|
if definition.MissingData == module.MissingDataWarn {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
||||||
|
}
|
||||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||||
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,20 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if definition.Builder == nil {
|
if definition.Builder == nil {
|
||||||
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||||
}
|
}
|
||||||
|
missing := missingRequirements(definition, ctx)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
switch definition.MissingData {
|
||||||
|
case module.MissingDataOmit:
|
||||||
|
return nil, nil
|
||||||
|
case module.MissingDataError:
|
||||||
|
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
|
||||||
|
case module.MissingDataEmpty:
|
||||||
|
case module.MissingDataWarn:
|
||||||
|
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||||
|
}
|
||||||
|
}
|
||||||
options := item.Options
|
options := item.Options
|
||||||
if options == nil {
|
if options == nil {
|
||||||
options = definition.DefaultOptions
|
options = definition.DefaultOptions
|
||||||
@@ -112,6 +133,61 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, requirement := range definition.RequiredCollected {
|
||||||
|
if !collectedFactAvailable(requirement, ctx) {
|
||||||
|
missing = append(missing, string(requirement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, requirement := range definition.RequiredDerived {
|
||||||
|
if !derivedFactAvailable(requirement, ctx) {
|
||||||
|
missing = append(missing, string(requirement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||||
|
switch requirement {
|
||||||
|
case module.CollectedCurrentConditions:
|
||||||
|
return ctx.Collected.Current != nil
|
||||||
|
case module.CollectedNarrativeForecast:
|
||||||
|
return ctx.Collected.Narrative != nil
|
||||||
|
case module.CollectedHourlyForecast:
|
||||||
|
return ctx.Collected.Hourly != nil
|
||||||
|
case module.CollectedAlerts:
|
||||||
|
return ctx.Collected.Alerts != nil
|
||||||
|
case module.CollectedDiscussion:
|
||||||
|
return ctx.Collected.Discussion != nil
|
||||||
|
case module.CollectedWeatherStory:
|
||||||
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSourceMetadata:
|
||||||
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||||
|
switch requirement {
|
||||||
|
case module.RequiresDerivedHourlyPeriods:
|
||||||
|
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||||
|
case module.RequiresDerivedNarrativePeriods:
|
||||||
|
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||||
|
case module.RequiresDerivedAlertOverlaps:
|
||||||
|
return true
|
||||||
|
case module.RequiresDerivedDailySummaries:
|
||||||
|
return len(ctx.Derived.DailySummaries) > 0
|
||||||
|
case module.RequiresDerivedDaypartSummaries:
|
||||||
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
|
case module.RequiresDerivedPrecipTiming:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||||
seenModules := map[module.ID]struct{}{}
|
seenModules := map[module.ID]struct{}{}
|
||||||
seenStanzas := map[string]module.ID{}
|
seenStanzas := map[string]module.ID{}
|
||||||
@@ -190,6 +266,26 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.NarrativeForecast,
|
||||||
|
StanzaName: "narrative_forecast",
|
||||||
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildNarrativeForecastModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: "hourly_forecast",
|
||||||
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildHourlyForecastModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
@@ -208,14 +304,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
ID: module.HourlyTable,
|
|
||||||
StanzaName: "hourly_table",
|
|
||||||
DefaultOptions: module.HourlyTableOptions{},
|
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
|
||||||
SupportedReports: []report.ID{report.Storm},
|
|
||||||
MissingData: module.MissingDataError,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
@@ -253,13 +341,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildWeatherStoryModule,
|
Builder: buildWeatherStoryModule,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
ID: module.ForecastDelta,
|
|
||||||
StanzaName: "forecast_delta",
|
|
||||||
DefaultOptions: module.ForecastDeltaOptions{},
|
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay},
|
|
||||||
MissingData: module.MissingDataEmpty,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
ID: module.OutdoorWindows,
|
ID: module.OutdoorWindows,
|
||||||
StanzaName: "outdoor_windows",
|
StanzaName: "outdoor_windows",
|
||||||
@@ -278,21 +359,5 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
ID: module.WeekendPlanning,
|
|
||||||
StanzaName: "weekend_planning",
|
|
||||||
DefaultOptions: module.WeekendPlanningOptions{},
|
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
||||||
SupportedReports: []report.ID{report.Weekend},
|
|
||||||
MissingData: module.MissingDataEmpty,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: module.StormWindowSummary,
|
|
||||||
StanzaName: "storm_window_summary",
|
|
||||||
DefaultOptions: module.StormWindowSummaryOptions{},
|
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedStormWindowSummary},
|
|
||||||
SupportedReports: []report.ID{report.Storm},
|
|
||||||
MissingData: module.MissingDataError,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
|||||||
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
||||||
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
||||||
}
|
}
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
moduleDefinition, err := registry.Lookup(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
|
||||||
|
}
|
||||||
|
if moduleDefinition.Builder == nil {
|
||||||
|
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
t.Run(string(definition.ID), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(definition.ID)
|
||||||
|
var outputs []module.Output
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
output, err := registry.BuildModule(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
outputs = append(outputs, *output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot(outputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Outputs) == 0 {
|
||||||
|
t.Fatal("snapshot outputs = 0, want default report modules")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,8 +73,8 @@ func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
func TestModuleRegistryRejectsDuplicateStanzaNames(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{}, Builder: noopModuleBuilder},
|
||||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||||
t.Fatalf("error = %v, want duplicate stanza name", err)
|
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||||
@@ -48,12 +83,30 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.StormWindowSummary}})
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "storm_window_summary" is not compatible with report "daily_today"`) {
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
||||||
t.Fatalf("error = %v, want incompatible report", err)
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
|
||||||
|
t.Fatalf("error = %v, want missing builder", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
|
||||||
|
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||||
@@ -74,3 +127,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
t.Fatalf("ValidateComposition() error = %v", err)
|
t.Fatalf("ValidateComposition() error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
|
}
|
||||||
|
|||||||
91
internal/briefing/narrative_forecast_module.go
Normal file
91
internal/briefing/narrative_forecast_module.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NarrativeForecastModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NarrativeForecastPeriod struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
narrative := ctx.Collected.Narrative
|
||||||
|
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := NarrativeForecastModule{
|
||||||
|
Product: narrative.Product,
|
||||||
|
IssuedAt: narrative.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||||
|
SourceLocation: narrative.LocationName,
|
||||||
|
SourceLocationID: narrative.LocationID,
|
||||||
|
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||||
|
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
out = append(out, NarrativeForecastPeriod{
|
||||||
|
Name: period.Name,
|
||||||
|
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||||
|
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v NarrativeForecastModule) isEmpty() bool {
|
||||||
|
return v.Product == "" &&
|
||||||
|
v.IssuedAt.IsZero() &&
|
||||||
|
v.UpdatedAt == nil &&
|
||||||
|
v.SourceLocation == "" &&
|
||||||
|
v.SourceLocationID == "" &&
|
||||||
|
len(v.Periods) == 0
|
||||||
|
}
|
||||||
38
internal/briefing/outdoor_windows_module.go
Normal file
38
internal/briefing/outdoor_windows_module.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type OutdoorWindowsModule struct {
|
||||||
|
Best *OutdoorWindowModule `json:"best,omitempty"`
|
||||||
|
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutdoorWindowModule struct {
|
||||||
|
Daypart string `json:"daypart"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
|
value := OutdoorWindowsModule{
|
||||||
|
Best: outdoorWindowValue(windows.Best),
|
||||||
|
Worst: outdoorWindowValue(windows.Worst),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
||||||
|
if window == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &OutdoorWindowModule{
|
||||||
|
Daypart: window.Daypart,
|
||||||
|
Start: window.Start,
|
||||||
|
End: window.End,
|
||||||
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
|
Score: window.Score,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,14 @@ func copyBool(value *bool) *bool {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyInt(value *int) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
func copyFloat(value *float64) *float64 {
|
func copyFloat(value *float64) *float64 {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
49
internal/briefing/precip_timing_module.go
Normal file
49
internal/briefing/precip_timing_module.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrecipTimingModule struct {
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
ProbabilityThreshold float64 `json:"probability_threshold"`
|
||||||
|
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrecipitationWindowModule struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||||
|
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
||||||
|
value := PrecipTimingModule{
|
||||||
|
ProbabilityThreshold: timing.ProbabilityThreshold,
|
||||||
|
ThunderMentioned: timing.ThunderMentioned,
|
||||||
|
}
|
||||||
|
if timing.MaxPrecipitationProbability != nil {
|
||||||
|
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||||
|
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
}
|
||||||
|
for _, window := range timing.PrecipitationWindows {
|
||||||
|
item := PrecipitationWindowModule{
|
||||||
|
Start: clockLabel(window.Start, timezone),
|
||||||
|
}
|
||||||
|
if window.End != nil {
|
||||||
|
item.End = clockLabel(*window.End, timezone)
|
||||||
|
}
|
||||||
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
24
internal/briefing/tomorrow_planning_module.go
Normal file
24
internal/briefing/tomorrow_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type TomorrowPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildTomorrowPlanning(summary)
|
||||||
|
value := TomorrowPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
||||||
|
}
|
||||||
42
internal/briefing/weather_story_module.go
Normal file
42
internal/briefing/weather_story_module.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherStoryModule struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"alt_text,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
story := ctx.Collected.WeatherStory
|
||||||
|
if story == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ 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 Thresholds struct {
|
type Thresholds struct {
|
||||||
@@ -64,7 +63,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
|
|||||||
var changes []Change
|
var changes []Change
|
||||||
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
||||||
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
||||||
changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
|
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
|
||||||
if previousHasTiming && currentHasTiming {
|
if previousHasTiming && currentHasTiming {
|
||||||
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||||
}
|
}
|
||||||
@@ -76,21 +75,22 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dailySummaryStanza struct {
|
type dailySummaryStanza struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type daypartSummaryStanza struct {
|
type daypartSummaryStanza struct {
|
||||||
Period timeutil.Period `json:"period"`
|
Date string `json:"date,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
Period string `json:"period,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type precipTimingStanza struct {
|
type precipTimingStanza struct {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ package changes
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||||
@@ -92,13 +90,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
}
|
}
|
||||||
return snapshot(t,
|
return snapshot(t,
|
||||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
||||||
Date: "2026-05-29",
|
Date: "2026-05-29",
|
||||||
HighTempF: &high,
|
HighTempF: &high,
|
||||||
LowTempF: &low,
|
LowTempF: &low,
|
||||||
MaxPopPercent: &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": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
|
"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},
|
||||||
}},
|
}},
|
||||||
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}},
|
||||||
@@ -114,10 +112,6 @@ func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func period(start string, end string) timeutil.Period {
|
|
||||||
return timeutil.Period{Start: at(start), End: at(end)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testThresholds() Thresholds {
|
func testThresholds() Thresholds {
|
||||||
return Thresholds{
|
return Thresholds{
|
||||||
TemperatureDegrees: 5,
|
TemperatureDegrees: 5,
|
||||||
@@ -136,11 +130,3 @@ func countType(changes []Change, changeType string) int {
|
|||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func at(value string) time.Time {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||||
@@ -113,10 +112,7 @@ func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func daypartDate(daypart daypartSummaryStanza) string {
|
func daypartDate(daypart daypartSummaryStanza) string {
|
||||||
if !daypart.Period.Start.IsZero() {
|
return daypart.Date
|
||||||
return daypart.Period.Start.Format(timeutil.DateLayout)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func minInt(a *int, b *int) *int {
|
func minInt(a *int, b *int) *int {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
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": {
|
||||||
Period: period(date+"T06:00:00Z", date+"T10:00:00Z"),
|
Date: date,
|
||||||
|
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
||||||
TempRangeF: tempRange,
|
TempRangeF: tempRange,
|
||||||
MaxPopPercent: &precip,
|
MaxPopPercent: &precip,
|
||||||
MaxPopTime: precipTime,
|
MaxPopTime: precipTime,
|
||||||
|
|||||||
@@ -736,8 +736,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|||||||
sourcesOutput = stdout.String()
|
sourcesOutput = stdout.String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(sourcesOutput, `"warnings"`) {
|
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
|
||||||
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput)
|
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,10 +196,20 @@ reports:
|
|||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- storm_window_summary
|
- tomorrow_planning
|
||||||
`,
|
`,
|
||||||
wantErr: `not compatible with report "daily_today"`,
|
wantErr: `not compatible with report "daily_today"`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "RemovedPlaceholderModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- forecast_delta
|
||||||
|
`,
|
||||||
|
wantErr: `unknown module "forecast_delta"`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "InvalidOptions",
|
name: "InvalidOptions",
|
||||||
yaml: `
|
yaml: `
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
collected := BuildCollected(bundle)
|
collected := BuildCollected(bundle)
|
||||||
@@ -27,13 +27,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
|
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" {
|
||||||
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
|
t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle.Sources[0].Name = "changed"
|
bundle.Sources[0].Name = "changed"
|
||||||
bundle.Warnings[0].Source = "changed"
|
bundle.Warnings[0].Source = "changed"
|
||||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,12 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
|||||||
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
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 valid-period rain start", derived.PrecipTiming.FirstPrecipitation)
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start", 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 closed window end", derived.PrecipTiming.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipitationWindows = %#v, want two threshold windows", derived.PrecipTiming.PrecipitationWindows)
|
||||||
|
}
|
||||||
if !derived.PrecipTiming.ThunderMentioned {
|
if !derived.PrecipTiming.ThunderMentioned {
|
||||||
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ type TimedValue struct {
|
|||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DefaultPrecipWindowProbabilityThreshold = 40
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Snow bool `json:"snow,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
@@ -66,34 +68,101 @@ type AlertOverlap struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrecipTiming struct {
|
type PrecipTiming struct {
|
||||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||||
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
||||||
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
||||||
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
|
PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrecipitationWindow struct {
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
End *time.Time `json:"end,omitempty"`
|
||||||
|
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
|
||||||
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
||||||
var timing PrecipTiming
|
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold)
|
||||||
for _, forecastPeriod := range periods {
|
}
|
||||||
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
|
||||||
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
|
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {
|
||||||
value := TimedValue{
|
timing := PrecipTiming{ProbabilityThreshold: threshold}
|
||||||
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
|
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||||
|
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||||
|
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
var active *PrecipitationWindow
|
||||||
|
var activeLastEnd time.Time
|
||||||
|
closeActive := func() {
|
||||||
|
if active == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := activeLastEnd
|
||||||
|
active.End = &end
|
||||||
|
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
|
||||||
|
active = nil
|
||||||
|
}
|
||||||
|
startActive := func(forecastPeriod weatherdata.ForecastPeriod, probability float64) {
|
||||||
|
active = &PrecipitationWindow{
|
||||||
|
Start: forecastPeriod.StartTime,
|
||||||
|
MaxPrecipitationProbability: TimedValue{
|
||||||
|
Value: probability,
|
||||||
|
Time: forecastPeriod.StartTime,
|
||||||
|
},
|
||||||
|
ProbabilityThreshold: threshold,
|
||||||
|
}
|
||||||
|
activeLastEnd = forecastPeriod.EndTime
|
||||||
|
if timing.FirstPrecipitation == nil {
|
||||||
|
timing.FirstPrecipitation = &TimedValue{
|
||||||
|
Value: probability,
|
||||||
Time: forecastPeriod.StartTime,
|
Time: forecastPeriod.StartTime,
|
||||||
}
|
}
|
||||||
if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) {
|
}
|
||||||
copied := value
|
}
|
||||||
timing.FirstPrecipitation = &copied
|
|
||||||
}
|
for _, forecastPeriod := range sorted {
|
||||||
if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) {
|
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||||
copied := value
|
if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold {
|
||||||
timing.LastPrecipitation = &copied
|
closeActive()
|
||||||
|
} else {
|
||||||
|
probability := *forecastPeriod.ProbabilityOfPrecipitationPercent
|
||||||
|
if active == nil {
|
||||||
|
startActive(forecastPeriod, probability)
|
||||||
|
} else {
|
||||||
|
if forecastPeriod.StartTime.After(activeLastEnd) {
|
||||||
|
closeActive()
|
||||||
|
startActive(forecastPeriod, probability)
|
||||||
|
}
|
||||||
|
if probability > active.MaxPrecipitationProbability.Value {
|
||||||
|
active.MaxPrecipitationProbability = TimedValue{
|
||||||
|
Value: probability,
|
||||||
|
Time: forecastPeriod.StartTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if forecastPeriod.EndTime.After(activeLastEnd) {
|
||||||
|
activeLastEnd = forecastPeriod.EndTime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mentionsThunder(forecastPeriod.TextDescription) {
|
if mentionsThunder(forecastPeriod.TextDescription) {
|
||||||
timing.ThunderMentioned = true
|
timing.ThunderMentioned = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if active != nil {
|
||||||
|
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) > 0 {
|
||||||
|
final := timing.PrecipitationWindows[len(timing.PrecipitationWindows)-1]
|
||||||
|
if final.End != nil {
|
||||||
|
timing.LastPrecipitation = &TimedValue{
|
||||||
|
Value: final.MaxPrecipitationProbability.Value,
|
||||||
|
Time: *final.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return timing
|
return timing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,31 +210,94 @@ func TestAlertOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) {
|
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
||||||
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(30), nil, nil),
|
|
||||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
|
||||||
hour(location, "2026-05-29T18:00:00-05:00", "2026-05-29T19:00:00-05:00", "Dry", 70, nil, ptr(0), nil, nil),
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Brief lull", 70, nil, ptr(39.999), nil, nil),
|
||||||
|
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Unknown rain chance", 70, nil, nil, nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(40), nil, nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
timing := BuildPrecipTiming(periods)
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
||||||
t.Fatalf("FirstPrecipitation = %#v, want 9 AM shower", timing.FirstPrecipitation)
|
t.Fatalf("FirstPrecipitation = %#v, want first threshold window start", timing.FirstPrecipitation)
|
||||||
}
|
}
|
||||||
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" {
|
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||||
t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation)
|
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
|
||||||
}
|
}
|
||||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
||||||
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
||||||
}
|
}
|
||||||
|
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
first := timing.PrecipitationWindows[0]
|
||||||
|
if first.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || first.End == nil || first.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
||||||
|
t.Fatalf("first window = %#v, want 9-11 AM", first)
|
||||||
|
}
|
||||||
|
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
||||||
|
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
|
||||||
|
}
|
||||||
|
second := timing.PrecipitationWindows[1]
|
||||||
|
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||||
|
t.Fatalf("second window = %#v, want noon-1 PM", second)
|
||||||
|
}
|
||||||
if !timing.ThunderMentioned {
|
if !timing.ThunderMentioned {
|
||||||
t.Fatal("ThunderMentioned = false, want true")
|
t.Fatal("ThunderMentioned = false, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingLeavesFinalWindowOpen(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(45), nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
|
if len(timing.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
||||||
|
}
|
||||||
|
if timing.PrecipitationWindows[0].End != nil {
|
||||||
|
t.Fatalf("open window End = %v, want nil", timing.PrecipitationWindows[0].End)
|
||||||
|
}
|
||||||
|
if timing.LastPrecipitation != nil {
|
||||||
|
t.Fatalf("LastPrecipitation = %#v, want nil for open final window", timing.LastPrecipitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingSupportsNonDefaultThreshold(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 70, nil, ptr(50), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Showers", 70, nil, ptr(55), nil, nil),
|
||||||
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Drying out", 70, nil, ptr(20), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := buildPrecipTimingWithThreshold(periods, 55)
|
||||||
|
|
||||||
|
if timing.ProbabilityThreshold != 55 {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want 55", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
||||||
|
}
|
||||||
|
window := timing.PrecipitationWindows[0]
|
||||||
|
if window.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || window.End == nil || window.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
||||||
|
t.Fatalf("window = %#v, want 9-11 AM", window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
@@ -243,9 +306,12 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
|||||||
|
|
||||||
timing := BuildPrecipTiming(periods)
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned {
|
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned {
|
||||||
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
|
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
|
||||||
}
|
}
|
||||||
|
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
||||||
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ type ID string
|
|||||||
const (
|
const (
|
||||||
Metadata ID = "metadata"
|
Metadata ID = "metadata"
|
||||||
CurrentConditions ID = "current_conditions"
|
CurrentConditions ID = "current_conditions"
|
||||||
|
NarrativeForecast ID = "narrative_forecast"
|
||||||
|
HourlyForecast ID = "hourly_forecast"
|
||||||
DerivedDailySummary ID = "derived_daily_summary"
|
DerivedDailySummary ID = "derived_daily_summary"
|
||||||
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
||||||
HourlyTable ID = "hourly_table"
|
|
||||||
PrecipTiming ID = "precip_timing"
|
PrecipTiming ID = "precip_timing"
|
||||||
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"
|
||||||
ForecastDelta ID = "forecast_delta"
|
|
||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
WeekendPlanning ID = "weekend_planning"
|
|
||||||
StormWindowSummary ID = "storm_window_summary"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigItem struct {
|
type ConfigItem struct {
|
||||||
@@ -107,18 +105,19 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
|||||||
type FactRequirement string
|
type FactRequirement string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -132,17 +131,15 @@ const (
|
|||||||
|
|
||||||
type MetadataOptions struct{}
|
type MetadataOptions struct{}
|
||||||
type CurrentConditionsOptions struct{}
|
type CurrentConditionsOptions struct{}
|
||||||
|
type NarrativeForecastOptions struct{}
|
||||||
|
type HourlyForecastOptions struct{}
|
||||||
type DerivedDailySummaryOptions struct{}
|
type DerivedDailySummaryOptions struct{}
|
||||||
type DerivedDaypartSummariesOptions struct{}
|
type DerivedDaypartSummariesOptions struct{}
|
||||||
type HourlyTableOptions struct{}
|
|
||||||
type PrecipTimingOptions struct{}
|
type PrecipTimingOptions struct{}
|
||||||
type AlertDigestOptions struct{}
|
type AlertDigestOptions struct{}
|
||||||
type AreaForecastDiscussionOptions struct {
|
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 ForecastDeltaOptions struct{}
|
|
||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
type WeekendPlanningOptions struct{}
|
|
||||||
type StormWindowSummaryOptions struct{}
|
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ import (
|
|||||||
|
|
||||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
const SchemaVersion = "weatherreporter.data_package.v2"
|
||||||
|
|
||||||
|
const (
|
||||||
|
metadataStanza = "metadata"
|
||||||
|
categoryApplicableRiskProducts = "applicable_risk_products"
|
||||||
|
categoryDerivedSummaries = "derived_summaries"
|
||||||
|
categoryNarrativeProducts = "narrative_products"
|
||||||
|
categoryRawData = "raw_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
var briefingCategoryOrder = []string{
|
||||||
|
categoryApplicableRiskProducts,
|
||||||
|
categoryDerivedSummaries,
|
||||||
|
categoryNarrativeProducts,
|
||||||
|
categoryRawData,
|
||||||
|
}
|
||||||
|
|
||||||
|
var briefingStanzaCategories = map[string]string{
|
||||||
|
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||||
|
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||||
|
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||||
|
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||||
|
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||||
|
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||||
|
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||||
|
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||||
|
string(module.WeatherStory): categoryNarrativeProducts,
|
||||||
|
string(module.CurrentConditions): categoryRawData,
|
||||||
|
string(module.HourlyForecast): categoryRawData,
|
||||||
|
}
|
||||||
|
|
||||||
type BuildRequest struct {
|
type BuildRequest struct {
|
||||||
Metadata Metadata
|
Metadata Metadata
|
||||||
Modules module.Snapshot
|
Modules module.Snapshot
|
||||||
@@ -144,13 +173,24 @@ func Validate(pkg Package) error {
|
|||||||
if len(pkg.Briefing.Order) == 0 {
|
if len(pkg.Briefing.Order) == 0 {
|
||||||
return fmt.Errorf("briefing stanzas are required")
|
return fmt.Errorf("briefing stanzas are required")
|
||||||
}
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
for _, name := range pkg.Briefing.Order {
|
for _, name := range pkg.Briefing.Order {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("briefing stanza name is required")
|
return fmt.Errorf("briefing stanza name is required")
|
||||||
}
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
return fmt.Errorf("duplicate briefing stanza %q", name)
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
if _, ok := pkg.Briefing.Values[name]; !ok {
|
if _, ok := pkg.Briefing.Values[name]; !ok {
|
||||||
return fmt.Errorf("briefing stanza %q is missing", name)
|
return fmt.Errorf("briefing stanza %q is missing", name)
|
||||||
}
|
}
|
||||||
|
if name == metadataStanza {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := briefingStanzaCategories[name]; !ok {
|
||||||
|
return fmt.Errorf("briefing stanza %q has no prompt-input category", name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -191,17 +231,40 @@ func LoadYAML(data []byte) (Package, error) {
|
|||||||
|
|
||||||
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
||||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
categoryNames := map[string][]string{}
|
||||||
for _, name := range b.Order {
|
for _, name := range b.Order {
|
||||||
value, ok := b.Values[name]
|
value, ok := b.Values[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
if name == metadataStanza {
|
||||||
valueNode, err := yamlNode(value)
|
if err := appendYAMLMappingValue(node, name, value); err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
node.Content = append(node.Content, keyNode, valueNode)
|
category, ok := briefingStanzaCategories[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("briefing stanza %q has no prompt-input category", name)
|
||||||
|
}
|
||||||
|
categoryNames[category] = append(categoryNames[category], name)
|
||||||
|
}
|
||||||
|
for _, category := range briefingCategoryOrder {
|
||||||
|
names := categoryNames[category]
|
||||||
|
if len(names) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
categoryNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
for _, name := range names {
|
||||||
|
value := b.Values[name]
|
||||||
|
if err := appendYAMLMappingValue(categoryNode, name, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content,
|
||||||
|
&yaml.Node{Kind: yaml.ScalarNode, Value: category},
|
||||||
|
categoryNode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return node, nil
|
return node, nil
|
||||||
}
|
}
|
||||||
@@ -212,14 +275,46 @@ func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
order := make([]string, 0, len(value.Content)/2)
|
order := make([]string, 0, len(value.Content)/2)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
seenCategories := map[string]struct{}{}
|
||||||
|
categoryOrder := map[string][]string{}
|
||||||
for i := 0; i < len(value.Content); i += 2 {
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
name := value.Content[i].Value
|
name := value.Content[i].Value
|
||||||
var stanza any
|
if name == metadataStanza {
|
||||||
if err := value.Content[i+1].Decode(&stanza); err != nil {
|
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
order = append(order, name)
|
if !knownBriefingCategory(name) {
|
||||||
values[name] = stanza
|
return fmt.Errorf("unknown briefing category %q", name)
|
||||||
|
}
|
||||||
|
if _, ok := seenCategories[name]; ok {
|
||||||
|
return fmt.Errorf("duplicate briefing category %q", name)
|
||||||
|
}
|
||||||
|
seenCategories[name] = struct{}{}
|
||||||
|
categoryNode := value.Content[i+1]
|
||||||
|
if categoryNode.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("briefing category %q must be a mapping", name)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for j := 0; j < len(categoryNode.Content); j += 2 {
|
||||||
|
stanzaName := categoryNode.Content[j].Value
|
||||||
|
category, ok := briefingStanzaCategories[stanzaName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName)
|
||||||
|
}
|
||||||
|
if category != name {
|
||||||
|
return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name)
|
||||||
|
}
|
||||||
|
if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categoryOrder[name] = names
|
||||||
|
}
|
||||||
|
for _, category := range briefingCategoryOrder {
|
||||||
|
order = append(order, categoryOrder[category]...)
|
||||||
}
|
}
|
||||||
b.Order = order
|
b.Order = order
|
||||||
b.Values = values
|
b.Values = values
|
||||||
@@ -250,6 +345,39 @@ func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
||||||
|
valueNode, err := yamlNode(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error {
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
return fmt.Errorf("duplicate briefing stanza %q", name)
|
||||||
|
}
|
||||||
|
var stanza any
|
||||||
|
if err := node.Decode(&stanza); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
*order = append(*order, name)
|
||||||
|
values[name] = stanza
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownBriefingCategory(name string) bool {
|
||||||
|
for _, category := range briefingCategoryOrder {
|
||||||
|
if name == category {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func yamlNode(value any) (*yaml.Node, error) {
|
func yamlNode(value any) (*yaml.Node, error) {
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
req.Metadata.PromptID = "weather.three_day_outlook"
|
req.Metadata.PromptID = "weather.three_day_outlook"
|
||||||
req.Modules = snapshotWithOutputs(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.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
module.Output{ID: module.ForecastDelta, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
pkg, err := Build(req)
|
pkg, err := Build(req)
|
||||||
@@ -117,12 +117,12 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
if pkg.Report.ID != report.ThreeDay {
|
if pkg.Report.ID != report.ThreeDay {
|
||||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
||||||
}
|
}
|
||||||
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
|
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||||
t.Fatal("Briefing.Values[three_day] missing")
|
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||||
pkg, err := Build(validBuildRequest(t))
|
pkg, err := Build(validBuildRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Build() error = %v", err)
|
t.Fatalf("Build() error = %v", err)
|
||||||
@@ -141,9 +141,26 @@ func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
||||||
!strings.Contains(string(first), "briefing:\n") ||
|
!strings.Contains(string(first), "briefing:\n") ||
|
||||||
|
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
||||||
|
!strings.Contains(string(first), " derived_summaries:\n") ||
|
||||||
|
!strings.Contains(string(first), " narrative_products:\n") ||
|
||||||
|
!strings.Contains(string(first), " raw_data:\n") ||
|
||||||
!strings.Contains(string(first), " current_conditions:\n") ||
|
!strings.Contains(string(first), " current_conditions:\n") ||
|
||||||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
|
!strings.Contains(string(first), " condition_text: Partly cloudy") {
|
||||||
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
|
t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first))
|
||||||
|
}
|
||||||
|
for _, pair := range []struct {
|
||||||
|
before string
|
||||||
|
after string
|
||||||
|
}{
|
||||||
|
{before: " metadata:\n", after: " applicable_risk_products:\n"},
|
||||||
|
{before: " applicable_risk_products:\n", after: " derived_summaries:\n"},
|
||||||
|
{before: " derived_summaries:\n", after: " narrative_products:\n"},
|
||||||
|
{before: " narrative_products:\n", after: " raw_data:\n"},
|
||||||
|
} {
|
||||||
|
if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) {
|
||||||
|
t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +182,51 @@ func TestLoadYAMLRoundTrip(t *testing.T) {
|
|||||||
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
|
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
|
||||||
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
||||||
}
|
}
|
||||||
if loaded.Briefing.Order[1] != "current_conditions" {
|
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
|
||||||
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
|
if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") {
|
||||||
|
t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder)
|
||||||
|
}
|
||||||
|
if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" {
|
||||||
|
t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}})
|
||||||
|
|
||||||
|
_, err := Build(req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) {
|
||||||
|
t.Fatalf("Build() error = %v, want uncategorized stanza error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||||
|
data := []byte(`
|
||||||
|
schema_version: weatherreporter.data_package.v2
|
||||||
|
run_id: 20260529T100000Z_daily_today
|
||||||
|
report:
|
||||||
|
id: daily_today
|
||||||
|
prompt_id: weather.daily_report
|
||||||
|
generated_at: 2026-05-29T10:00:00Z
|
||||||
|
timezone: America/Chicago
|
||||||
|
current_local_date: "2026-05-29"
|
||||||
|
valid_period:
|
||||||
|
start: 2026-05-29T05:00:00Z
|
||||||
|
end: 2026-05-30T05:00:00Z
|
||||||
|
briefing:
|
||||||
|
metadata:
|
||||||
|
run_id: 20260529T100000Z_daily_today
|
||||||
|
raw_data:
|
||||||
|
alert_digest:
|
||||||
|
checked: true
|
||||||
|
recent_changes:
|
||||||
|
items: []
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := LoadYAML(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) {
|
||||||
|
t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +250,8 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
|||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
||||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||||
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
||||||
|
module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
internal/report/daily_report.go
Normal file
82
internal/report/daily_report.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dailyTodayDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: DailyToday,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
Modules: dailyTodayModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveDailyToday,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTomorrowDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: DailyTomorrow,
|
||||||
|
Name: "Tomorrow Planning Brief",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "tomorrow.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
Modules: dailyTomorrowModules(),
|
||||||
|
Evening: true,
|
||||||
|
resolve: resolveDailyTomorrow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTodayModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTomorrowModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
module.HourlyForecast,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if !req.Date.IsZero() {
|
||||||
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
|
}
|
||||||
|
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ package report
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||||
@@ -70,75 +68,3 @@ func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (
|
|||||||
ValidPeriod: period,
|
ValidPeriod: period,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if !req.Date.IsZero() {
|
|
||||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
|
||||||
}
|
|
||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
|
|
||||||
if location == nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
startTime, err := timeutil.ParseStormTime(start, location)
|
|
||||||
if err != nil {
|
|
||||||
return timeutil.Period{}, err
|
|
||||||
}
|
|
||||||
endTime, err := timeutil.ParseStormTime(end, location)
|
|
||||||
if err != nil {
|
|
||||||
return timeutil.Period{}, err
|
|
||||||
}
|
|
||||||
period := timeutil.Period{Start: startTime, End: endTime}
|
|
||||||
if !period.IsValid() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
|
||||||
}
|
|
||||||
return period, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
localNow := req.Now.In(req.Location)
|
|
||||||
endDate := localNow.AddDate(0, 0, 3)
|
|
||||||
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
|
|
||||||
return timeutil.Period{Start: localNow, End: end}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
localNow := req.Now.In(req.Location)
|
|
||||||
weekday := localNow.Weekday()
|
|
||||||
if weekday == time.Sunday {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
|
|
||||||
}
|
|
||||||
|
|
||||||
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
|
|
||||||
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
|
|
||||||
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
|
|
||||||
if weekday == time.Friday || weekday == time.Saturday {
|
|
||||||
friday := start.AddDate(0, 0, -1)
|
|
||||||
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
|
|
||||||
start = fridayEvening
|
|
||||||
if localNow.After(start) {
|
|
||||||
start = localNow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
|
|
||||||
return timeutil.Period{Start: start, End: end}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if req.StormStart.IsZero() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
|
|
||||||
}
|
|
||||||
if req.StormEnd.IsZero() {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
|
|
||||||
}
|
|
||||||
if !req.StormEnd.After(req.StormStart) {
|
|
||||||
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
|
||||||
}
|
|
||||||
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -262,14 +262,15 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
module.DerivedDailySummary,
|
module.DerivedDailySummary,
|
||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
module.ForecastDelta,
|
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -277,15 +278,16 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
module.DerivedDailySummary,
|
module.DerivedDailySummary,
|
||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
module.ForecastDelta,
|
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -296,7 +298,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
module.ForecastDelta,
|
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
@@ -313,7 +314,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.WeekendPlanning,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -321,12 +321,10 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.HourlyTable,
|
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.StormWindowSummary,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,70 +12,11 @@ type Registry struct {
|
|||||||
|
|
||||||
func DefaultRegistry() Registry {
|
func DefaultRegistry() Registry {
|
||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
{
|
dailyTodayDefinition(),
|
||||||
ID: DailyToday,
|
dailyTomorrowDefinition(),
|
||||||
Name: "Daily Report",
|
threeDayDefinition(),
|
||||||
PromptID: "weather.daily_report",
|
weekendDefinition(),
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
stormDefinition(),
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "daily.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTodayModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveDailyToday,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: DailyTomorrow,
|
|
||||||
Name: "Tomorrow Planning Brief",
|
|
||||||
PromptID: "weather.daily_report",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "tomorrow.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTomorrowModules(),
|
|
||||||
Evening: true,
|
|
||||||
resolve: resolveDailyTomorrow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: ThreeDay,
|
|
||||||
Name: "3-Day Outlook",
|
|
||||||
PromptID: "weather.three_day_outlook",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "three-day",
|
|
||||||
BatchOutputName: "three-day.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{ThreeDay},
|
|
||||||
Modules: threeDayModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveThreeDay,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: Weekend,
|
|
||||||
Name: "Weekend Outlook",
|
|
||||||
PromptID: "weather.weekend_outlook",
|
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
|
||||||
ArtifactGroup: "weekend",
|
|
||||||
BatchOutputName: "weekend.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{Weekend},
|
|
||||||
Modules: weekendModules(),
|
|
||||||
Morning: true,
|
|
||||||
resolve: resolveWeekend,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: Storm,
|
|
||||||
Name: "Storm Report",
|
|
||||||
PromptID: "weather.storm_report",
|
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
|
||||||
ArtifactGroup: "storm",
|
|
||||||
BatchOutputName: "storm.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{Storm},
|
|
||||||
Modules: stormModules(),
|
|
||||||
resolve: resolveStorm,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
registry := Registry{definitions: map[ID]Definition{}}
|
registry := Registry{definitions: map[ID]Definition{}}
|
||||||
for _, definition := range definitions {
|
for _, definition := range definitions {
|
||||||
@@ -101,68 +42,6 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
|
|||||||
return next, nil
|
return next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTodayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDailySummary,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.ForecastDelta,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
|
||||||
items := dailyTodayModules()
|
|
||||||
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func threeDayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.ForecastDelta,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
module.WeekendPlanning,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
|
||||||
module.Metadata,
|
|
||||||
module.CurrentConditions,
|
|
||||||
module.HourlyTable,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.StormWindowSummary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moduleItems(ids ...module.ID) []module.ConfigItem {
|
func moduleItems(ids ...module.ID) []module.ConfigItem {
|
||||||
items := make([]module.ConfigItem, 0, len(ids))
|
items := make([]module.ConfigItem, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
|||||||
67
internal/report/storm_report.go
Normal file
67
internal/report/storm_report.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stormDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Storm,
|
||||||
|
Name: "Storm Report",
|
||||||
|
PromptID: "weather.storm_report",
|
||||||
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
|
ArtifactGroup: "storm",
|
||||||
|
BatchOutputName: "storm.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Storm},
|
||||||
|
Modules: stormModules(),
|
||||||
|
resolve: resolveStorm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stormModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
|
||||||
|
if location == nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
startTime, err := timeutil.ParseStormTime(start, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
endTime, err := timeutil.ParseStormTime(end, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
period := timeutil.Period{Start: startTime, End: endTime}
|
||||||
|
if !period.IsValid() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return period, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if req.StormStart.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
|
||||||
|
}
|
||||||
|
if req.StormEnd.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
|
||||||
|
}
|
||||||
|
if !req.StormEnd.After(req.StormStart) {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
|
||||||
|
}
|
||||||
44
internal/report/three_day_report.go
Normal file
44
internal/report/three_day_report.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func threeDayDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: ThreeDay,
|
||||||
|
Name: "3-Day Outlook",
|
||||||
|
PromptID: "weather.three_day_outlook",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "three-day",
|
||||||
|
BatchOutputName: "three-day.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
Modules: threeDayModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveThreeDay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func threeDayModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
endDate := localNow.AddDate(0, 0, 3)
|
||||||
|
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
return timeutil.Period{Start: localNow, End: end}, nil
|
||||||
|
}
|
||||||
60
internal/report/weekend_report.go
Normal file
60
internal/report/weekend_report.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func weekendDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Weekend,
|
||||||
|
Name: "Weekend Outlook",
|
||||||
|
PromptID: "weather.weekend_outlook",
|
||||||
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
|
ArtifactGroup: "weekend",
|
||||||
|
BatchOutputName: "weekend.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Weekend},
|
||||||
|
Modules: weekendModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveWeekend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekendModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
weekday := localNow.Weekday()
|
||||||
|
if weekday == time.Sunday {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
|
||||||
|
}
|
||||||
|
|
||||||
|
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
|
||||||
|
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
|
||||||
|
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
if weekday == time.Friday || weekday == time.Saturday {
|
||||||
|
friday := start.AddDate(0, 0, -1)
|
||||||
|
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
|
||||||
|
start = fridayEvening
|
||||||
|
if localNow.After(start) {
|
||||||
|
start = localNow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
|
||||||
|
return timeutil.Period{Start: start, End: end}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user