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`.
|
||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||
|
||||
Hourly forecast data is required for generated reports. Optional sources and
|
||||
stub source slots use the missing-source policy.
|
||||
Hourly forecast data is required for generated reports. Optional sources use
|
||||
the missing-source policy.
|
||||
|
||||
### `scriptorium`
|
||||
|
||||
@@ -196,10 +196,12 @@ reports:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- hourly_forecast
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The module registry accepts all module IDs documented in
|
||||
[Module Contract Internals](internal/module.md). Modules without builders are
|
||||
valid in composition but do not emit YAML stanzas.
|
||||
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -89,10 +89,6 @@ source-specific `missing_source.sources` policy:
|
||||
- `discussion` for `/discussion`
|
||||
- `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:
|
||||
|
||||
- `error`: fail the fetch for that source
|
||||
|
||||
@@ -27,15 +27,18 @@ Outputs:
|
||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `alert_digest`,
|
||||
`area_forecast_discussion`, and `weather_story`
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
|
||||
The registry also contains accepted composition entries for modules that do not
|
||||
emit stanzas until a builder exists. App orchestration skips those entries when
|
||||
constructing snapshots.
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -31,23 +31,19 @@ The registry recognizes these IDs:
|
||||
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `narrative_forecast`
|
||||
- `hourly_forecast`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `hourly_table`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `area_forecast_discussion`
|
||||
- `weather_story`
|
||||
- `forecast_delta`
|
||||
- `outdoor_windows`
|
||||
- `tomorrow_planning`
|
||||
- `weekend_planning`
|
||||
- `storm_window_summary`
|
||||
|
||||
Modules with builders emit stanzas into module snapshots. Registered modules
|
||||
without builders are valid composition entries but do not emit snapshot stanzas.
|
||||
That keeps report composition declarations centralized while limiting prompt
|
||||
packages to data the application builds.
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `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 file written atomically by `promptinput.Save`
|
||||
|
||||
@@ -38,13 +39,37 @@ report:
|
||||
prompt_id: <prompt_id>
|
||||
briefing:
|
||||
metadata: {}
|
||||
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:
|
||||
items: []
|
||||
```
|
||||
|
||||
The `briefing` mapping contains named module stanzas. Stanza order follows the
|
||||
module snapshot output order.
|
||||
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
||||
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
|
||||
|
||||
@@ -90,6 +115,7 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- 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
|
||||
infer changes from rendered report text.
|
||||
|
||||
@@ -22,7 +22,6 @@ Outputs:
|
||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, latest weather story, source
|
||||
records, and source warnings
|
||||
- stub source record for the daily forecast source slot
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
|
||||
## Boundaries
|
||||
@@ -70,7 +69,7 @@ data is required and cannot be skipped.
|
||||
- HTTP errors, response read failures, and envelope decode failures include
|
||||
endpoint context.
|
||||
- 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
|
||||
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
|
||||
documented outside `docs/roadmap/`.
|
||||
|
||||
## Deferred: Automatic Storm Monitoring
|
||||
## Automatic Storm Monitoring
|
||||
|
||||
Manual Storm Report generation is implemented through
|
||||
`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;
|
||||
- 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`;
|
||||
- database-backed state;
|
||||
- public HTTP API;
|
||||
- multi-location selection;
|
||||
- daemon mode;
|
||||
- multi-user authorization;
|
||||
- plugin system.
|
||||
Possible future report types:
|
||||
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
- `next_6_hours` or another short-fuse planning report;
|
||||
- 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
|
||||
successful generated report through the configured HTTP upload endpoint.
|
||||
successful generated report through the configured HTTP upload pipeline.
|
||||
|
||||
These enhancements are not current behavior:
|
||||
|
||||
- `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;
|
||||
- durable upload retry queues;
|
||||
- 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,
|
||||
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
|
||||
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
|
||||
additional Weather API sources make per-source fan-out, policy handling, and
|
||||
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
|
||||
coordinated fixture updates.
|
||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||
future background workflows gain enough shared step semantics to justify a
|
||||
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,
|
||||
explicit, and covered by parser tests.
|
||||
- Manifest, resume, or progress system: defer until operators need resumable
|
||||
@@ -101,5 +144,5 @@ more expensive:
|
||||
artifact output.
|
||||
|
||||
Any future implementation should preserve the existing public CLI, artifact
|
||||
paths, report identities, and adapter boundaries unless a separate roadmap
|
||||
explicitly changes them.
|
||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||
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:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- forecast_delta
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
@@ -86,3 +86,4 @@ reports:
|
||||
- long_term
|
||||
- weather_story
|
||||
- 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 {
|
||||
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
|
||||
}
|
||||
@@ -267,15 +264,6 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
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 {
|
||||
source.Missing = true
|
||||
if required {
|
||||
|
||||
@@ -55,11 +55,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
if len(bundle.Sources) != 7 {
|
||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 1 {
|
||||
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
||||
if len(bundle.Warnings) != 0 {
|
||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
@@ -197,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
wantWarns int
|
||||
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: "error", policy: config.MissingSourceError, wantErr: true},
|
||||
}
|
||||
|
||||
@@ -827,13 +827,6 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
|
||||
}
|
||||
var outputs []module.Output
|
||||
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)
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
|
||||
@@ -160,10 +160,33 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
||||
!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), "narrative_forecast:") ||
|
||||
!strings.Contains(string(data), "hourly_forecast:") ||
|
||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("decode data package: %v", err)
|
||||
@@ -178,6 +201,14 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if !ok || current["condition_text"] != "Clear" {
|
||||
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)
|
||||
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"])
|
||||
@@ -665,7 +696,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||
@@ -954,8 +985,11 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("InspectSources() error = %v", err)
|
||||
}
|
||||
if len(sources.Sources) == 0 || len(sources.Warnings) == 0 {
|
||||
t.Fatalf("sources = %#v, want provenance and warnings", sources)
|
||||
if len(sources.Sources) == 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1342,11 +1376,12 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||
"low_temp_f": low,
|
||||
"high_temp_f": high,
|
||||
"max_pop_percent": precip,
|
||||
"daily_precipitation_probability": precip,
|
||||
}},
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: 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",
|
||||
},
|
||||
}},
|
||||
@@ -1368,7 +1403,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: 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",
|
||||
"max_pop_percent": precip,
|
||||
"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.CurrentConditions, stanza: "current_conditions"},
|
||||
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -87,16 +197,22 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal current conditions: %v", err)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
@@ -130,10 +246,14 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
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.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})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
@@ -212,6 +332,14 @@ func testModuleContext() ModuleContext {
|
||||
humidity := 71.0
|
||||
windMph := 8.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")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
@@ -225,6 +353,50 @@ func testModuleContext() ModuleContext {
|
||||
WindSpeedMph: &windMph,
|
||||
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{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
@@ -257,6 +429,31 @@ func testModuleContext() ModuleContext {
|
||||
}},
|
||||
},
|
||||
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{{
|
||||
Event: "Flood Watch",
|
||||
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)
|
||||
|
||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
|
||||
if value.Date != "Friday, May 29, 2026" {
|
||||
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||
}
|
||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
|
||||
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
|
||||
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||
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 {
|
||||
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
|
||||
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||
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 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
@@ -44,16 +50,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
t.Fatalf("marshal daily summary: %v", err)
|
||||
}
|
||||
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) {
|
||||
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") {
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
@@ -63,8 +96,27 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||
}
|
||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
|
||||
t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy)
|
||||
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, 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)})
|
||||
@@ -73,8 +125,8 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||
}
|
||||
dry := moduleValue[PrecipTimingModule](t, output)
|
||||
if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
|
||||
t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry)
|
||||
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||
}
|
||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||
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 {
|
||||
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"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
@@ -191,10 +249,33 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
hours := []weatherdata.ForecastPeriod{
|
||||
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-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-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.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: definition,
|
||||
@@ -202,8 +283,16 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
Timezone: "America/Chicago",
|
||||
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{
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
ValidPeriodNarrativePeriods: narrative,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
@@ -238,3 +327,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
|
||||
func floatPtr(value float64) *float64 {
|
||||
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 (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
@@ -61,6 +62,12 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||
if _, ok := registry.definitions[definition.ID]; ok {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
if options == nil {
|
||||
options = definition.DefaultOptions
|
||||
@@ -112,6 +133,61 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
||||
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 {
|
||||
seenModules := map[module.ID]struct{}{}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
@@ -190,6 +266,26 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
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,
|
||||
StanzaName: "derived_daily_summary",
|
||||
@@ -208,14 +304,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataError,
|
||||
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,
|
||||
StanzaName: "precip_timing",
|
||||
@@ -253,13 +341,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
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,
|
||||
StanzaName: "outdoor_windows",
|
||||
@@ -278,21 +359,5 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
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 {
|
||||
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) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}},
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
|
||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||
@@ -48,12 +83,30 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||
|
||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.StormWindowSummary}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "storm_window_summary" is not compatible with report "daily_today"`) {
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
@@ -74,3 +127,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
func copyInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyFloat(value *float64) *float64 {
|
||||
if value == 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"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -64,7 +63,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
|
||||
var changes []Change
|
||||
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, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
|
||||
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
|
||||
if previousHasTiming && currentHasTiming {
|
||||
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||
}
|
||||
@@ -79,12 +78,13 @@ type dailySummaryStanza 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"`
|
||||
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
}
|
||||
|
||||
type daypartSummaryStanza struct {
|
||||
Period timeutil.Period `json:"period"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
|
||||
@@ -3,10 +3,8 @@ package changes
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
@@ -95,10 +93,10 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
||||
Date: "2026-05-29",
|
||||
HighTempF: &high,
|
||||
LowTempF: &low,
|
||||
MaxPopPercent: &precip,
|
||||
DailyPrecipitationProbability: &precip,
|
||||
}},
|
||||
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.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||
@@ -114,10 +112,6 @@ func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func period(start string, end string) timeutil.Period {
|
||||
return timeutil.Period{Start: at(start), End: at(end)}
|
||||
}
|
||||
|
||||
func testThresholds() Thresholds {
|
||||
return Thresholds{
|
||||
TemperatureDegrees: 5,
|
||||
@@ -136,11 +130,3 @@ func countType(changes []Change, changeType string) int {
|
||||
}
|
||||
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"
|
||||
|
||||
"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) {
|
||||
@@ -113,10 +112,7 @@ func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[strin
|
||||
}
|
||||
|
||||
func daypartDate(daypart daypartSummaryStanza) string {
|
||||
if !daypart.Period.Start.IsZero() {
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout)
|
||||
}
|
||||
return ""
|
||||
return daypart.Date
|
||||
}
|
||||
|
||||
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()
|
||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
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,
|
||||
MaxPopPercent: &precip,
|
||||
MaxPopTime: precipTime,
|
||||
|
||||
@@ -736,8 +736,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
sourcesOutput = stdout.String()
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sourcesOutput, `"warnings"`) {
|
||||
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput)
|
||||
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
|
||||
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,10 +196,20 @@ reports:
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- storm_window_summary
|
||||
- tomorrow_planning
|
||||
`,
|
||||
wantErr: `not compatible with report "daily_today"`,
|
||||
},
|
||||
{
|
||||
name: "RemovedPlaceholderModule",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- forecast_delta
|
||||
`,
|
||||
wantErr: `unknown module "forecast_delta"`,
|
||||
},
|
||||
{
|
||||
name: "InvalidOptions",
|
||||
yaml: `
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||
Hourly: &weatherdata.ForecastRun{Product: "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)
|
||||
@@ -27,13 +27,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||
}
|
||||
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
|
||||
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
|
||||
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" {
|
||||
t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings)
|
||||
}
|
||||
|
||||
bundle.Sources[0].Name = "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)
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
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 {
|
||||
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ type TimedValue struct {
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
const DefaultPrecipWindowProbabilityThreshold = 40
|
||||
|
||||
type Indicators struct {
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
@@ -69,31 +71,98 @@ type PrecipTiming struct {
|
||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
||||
LastPrecipitation *TimedValue `json:"lastPrecipitation,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 {
|
||||
var timing PrecipTiming
|
||||
for _, forecastPeriod := range periods {
|
||||
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
|
||||
value := TimedValue{
|
||||
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
|
||||
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold)
|
||||
}
|
||||
|
||||
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {
|
||||
timing := PrecipTiming{ProbabilityThreshold: threshold}
|
||||
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,
|
||||
}
|
||||
if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) {
|
||||
copied := value
|
||||
timing.FirstPrecipitation = &copied
|
||||
}
|
||||
if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) {
|
||||
copied := value
|
||||
timing.LastPrecipitation = &copied
|
||||
}
|
||||
|
||||
for _, forecastPeriod := range sorted {
|
||||
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||
if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
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(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-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)
|
||||
|
||||
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" {
|
||||
t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation)
|
||||
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
|
||||
}
|
||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
||||
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 {
|
||||
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) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
@@ -243,9 +306,12 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
|
||||
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
|
||||
}
|
||||
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
||||
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,16 @@ type ID string
|
||||
const (
|
||||
Metadata ID = "metadata"
|
||||
CurrentConditions ID = "current_conditions"
|
||||
NarrativeForecast ID = "narrative_forecast"
|
||||
HourlyForecast ID = "hourly_forecast"
|
||||
DerivedDailySummary ID = "derived_daily_summary"
|
||||
DerivedDaypartSummaries ID = "derived_daypart_summaries"
|
||||
HourlyTable ID = "hourly_table"
|
||||
PrecipTiming ID = "precip_timing"
|
||||
AlertDigest ID = "alert_digest"
|
||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||
WeatherStory ID = "weather_story"
|
||||
ForecastDelta ID = "forecast_delta"
|
||||
OutdoorWindows ID = "outdoor_windows"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
WeekendPlanning ID = "weekend_planning"
|
||||
StormWindowSummary ID = "storm_window_summary"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
@@ -108,6 +106,8 @@ type FactRequirement string
|
||||
|
||||
const (
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
@@ -118,7 +118,6 @@ const (
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary"
|
||||
)
|
||||
|
||||
type MissingDataBehavior string
|
||||
@@ -132,17 +131,15 @@ const (
|
||||
|
||||
type MetadataOptions struct{}
|
||||
type CurrentConditionsOptions struct{}
|
||||
type NarrativeForecastOptions struct{}
|
||||
type HourlyForecastOptions struct{}
|
||||
type DerivedDailySummaryOptions struct{}
|
||||
type DerivedDaypartSummariesOptions struct{}
|
||||
type HourlyTableOptions struct{}
|
||||
type PrecipTimingOptions struct{}
|
||||
type AlertDigestOptions struct{}
|
||||
type AreaForecastDiscussionOptions struct {
|
||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||
}
|
||||
type WeatherStoryOptions struct{}
|
||||
type ForecastDeltaOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
type WeekendPlanningOptions struct{}
|
||||
type StormWindowSummaryOptions struct{}
|
||||
|
||||
@@ -18,6 +18,35 @@ import (
|
||||
|
||||
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 {
|
||||
Metadata Metadata
|
||||
Modules module.Snapshot
|
||||
@@ -144,13 +173,24 @@ func Validate(pkg Package) error {
|
||||
if len(pkg.Briefing.Order) == 0 {
|
||||
return fmt.Errorf("briefing stanzas are required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, name := range pkg.Briefing.Order {
|
||||
if name == "" {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -191,17 +231,40 @@ func LoadYAML(data []byte) (Package, error) {
|
||||
|
||||
func (b BriefingStanzas) MarshalYAML() (any, error) {
|
||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||
categoryNames := map[string][]string{}
|
||||
for _, name := range b.Order {
|
||||
value, ok := b.Values[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
|
||||
valueNode, err := yamlNode(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
|
||||
if name == metadataStanza {
|
||||
if err := appendYAMLMappingValue(node, name, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.Content = append(node.Content, keyNode, valueNode)
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -212,14 +275,46 @@ func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
|
||||
}
|
||||
values := map[string]any{}
|
||||
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 {
|
||||
name := value.Content[i].Value
|
||||
var stanza any
|
||||
if err := value.Content[i+1].Decode(&stanza); err != nil {
|
||||
if name == metadataStanza {
|
||||
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
order = append(order, name)
|
||||
values[name] = stanza
|
||||
continue
|
||||
}
|
||||
if !knownBriefingCategory(name) {
|
||||
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.Values = values
|
||||
@@ -250,6 +345,39 @@ func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
|
||||
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) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
req.Metadata.PromptID = "weather.three_day_outlook"
|
||||
req.Modules = snapshotWithOutputs(t,
|
||||
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)
|
||||
@@ -117,12 +117,12 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
if pkg.Report.ID != report.ThreeDay {
|
||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
||||
}
|
||||
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
|
||||
t.Fatal("Briefing.Values[three_day] missing")
|
||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
|
||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
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") ||
|
||||
!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), " 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 {
|
||||
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
||||
}
|
||||
if loaded.Briefing.Order[1] != "current_conditions" {
|
||||
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
|
||||
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
|
||||
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.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.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 (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||
@@ -70,75 +68,3 @@ func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (
|
||||
ValidPeriod: period,
|
||||
}, 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{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -277,15 +278,16 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -296,7 +298,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.ForecastDelta,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
@@ -313,7 +314,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.WeekendPlanning,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -321,12 +321,10 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.HourlyTable,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.AreaForecastDiscussion,
|
||||
module.WeatherStory,
|
||||
module.StormWindowSummary,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,70 +12,11 @@ type Registry struct {
|
||||
|
||||
func DefaultRegistry() Registry {
|
||||
definitions := []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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
dailyTodayDefinition(),
|
||||
dailyTomorrowDefinition(),
|
||||
threeDayDefinition(),
|
||||
weekendDefinition(),
|
||||
stormDefinition(),
|
||||
}
|
||||
registry := Registry{definitions: map[ID]Definition{}}
|
||||
for _, definition := range definitions {
|
||||
@@ -101,68 +42,6 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
|
||||
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 {
|
||||
items := make([]module.ConfigItem, 0, len(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