From 17acc3a620ee76ad46bb54fa2f05eaaa4f2890e9 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 09:57:37 -0400 Subject: [PATCH 01/13] docs: add Skywatch.brief MVP design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition layer over existing Briefer + Nimbus primitives, shaped to AIM 7-1-5's 9-element Standard Preflight Briefing. MVP scope is single-airport input, JSON-only output, and a committed validation report — the goal is to confirm the LLM-briefer thesis works on what's already built before expanding (route input, coord input, ETD-aware, Sectional/NOTAMs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-27-skywatch-brief-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-skywatch-brief-design.md diff --git a/docs/superpowers/specs/2026-04-27-skywatch-brief-design.md b/docs/superpowers/specs/2026-04-27-skywatch-brief-design.md new file mode 100644 index 0000000..6a36534 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-skywatch-brief-design.md @@ -0,0 +1,289 @@ +# Skywatch.brief — design + +**Status:** draft +**Date:** 2026-04-27 +**Owner:** Jay Ravaliya + +## Goal + +Build a single composition layer, `Skywatch::Brief` + `Skywatch.brief`, that produces an [AIM 7-1-5](https://www.faa.gov/air_traffic/publications/atpubs/aim_html/chap7_section_1.html)-shaped weather brief for a single airport, composed from existing Briefer and Nimbus primitives. + +The shipping bar is **validation success**, not feature completeness. The thesis we are testing is: + +> *Can an LLM produce a useful 1-800-WX-BRIEF-quality answer from the Skywatch primitives we have built today?* + +If yes, we expand (route input, coord input, ETD-aware briefs, NOTAMs via Sectional, etc.). If no, we have a concrete signal about whether the gap is data quality, data shape, or both — and we re-rank the post-Nimbus roadmap accordingly. + +## Non-goals (deferred to filed GitHub issues) + +| Deferred | Reason | +|---|---| +| Route input (`from:`/`to:`) | Validate concept first; route geometry is a confounding variable. | +| Coordinate input (`at: [lat, lon]`) | Same. Adds nearest-station-lookup logic we don't need to validate the thesis. | +| ETD-aware briefs (`departing_at:`) | Same. Adds TAF-group selection and winds-aloft interpolation logic. | +| AFD synopsis sub-paragraph extraction | Brittle parsing; full AFD text is already useful in a supplementary slot. | +| Mayday and Radar inclusion | Outside AIM 7-1-5 scope; intentionally excluded. | +| Text formatter for human consumption | LLM is the validation consumer; JSON only suffices. | +| Automated Claude API validation runner | Human judgment is the validation; automating it doesn't make the judgment more reliable. | + +## Anchor — AIM 7-1-5 Standard Briefing elements + +The brief envelope mirrors the canonical 9-element sequence defined in [AIM § 7-1-5](https://faraim.org/faa/aim/chapter-7/section-7-1-5.html): + +1. Adverse Conditions +2. VFR Flight Not Recommended +3. Synopsis +4. Current Conditions +5. En Route Forecast +6. Destination Forecast +7. Winds Aloft +8. NOTAMs +9. ATC Delays + +Coverage of these elements with current Skywatch primitives: + +| # | Element | Coverage | +|---|---|---| +| 1 | Adverse Conditions | ✅ SIGMETs, AIRMETs, urgent PIREPs (Briefer); convective alerts, storm reports (Nimbus). ⚠️ TFRs missing. | +| 2 | VFR Flight Not Recommended | ✅ Derivable from `Briefer::Analysis::FlightCategory`. | +| 3 | Synopsis | ⚠️ No direct primitive. AFD is the closest analogue; exposed as supplementary slot. | +| 4 | Current Conditions | ✅ METAR + non-urgent PIREPs. | +| 5 | En Route Forecast | ⚠️ Single-point brief — no route to forecast across. | +| 6 | Destination Forecast | ✅ TAF. | +| 7 | Winds Aloft | ✅ Winds aloft. | +| 8 | NOTAMs | ❌ Sectional domain not yet built. | +| 9 | ATC Delays | ❌ No source in Skywatch. | + +Six slots fillable, one supplementary, three statically unavailable in MVP. This coverage profile is *exactly the validation signal we want*: the LLM must use the six, lean on the supplementary, and explicitly disclaim the three. + +## Architecture + +`Skywatch::Brief` is a pure composition layer over existing primitives. No new data sources. + +``` +Skywatch.brief(airport: "KCDW") + │ + ├─→ Briefer::Sources::Metar.fetch("KCDW") ─┐ + ├─→ Briefer::Sources::Taf.fetch("KCDW") │ + ├─→ Briefer::Sources::Pirep.fetch("KCDW") │ + ├─→ Briefer::Sources::WindsAloft.fetch("KCDW") │ existing primitives, + ├─→ Briefer::Sources::Sigmet.fetch │ no changes + ├─→ Briefer::Sources::Airmet.fetch │ + ├─→ Briefer::Sources::Afd.fetch() │ + ├─→ Nimbus::Sources::Alerts.fetch(at: [lat, lon]) │ + └─→ Nimbus::Sources::StormReport.fetch ─┘ + │ + └─→ Brief.new(...) → Brief#to_h (AIM-9 envelope) +``` + +### File layout + +``` +lib/skywatch/brief/ +├── models/brief.rb # the envelope; #to_h produces the AIM-9 hash +├── analysis/composer.rb # orchestrates fetches, builds slots, handles per-slot errors +├── analysis/airport_locator.rb # airport → [lat, lon] (METAR-derived) and airport → wfo +├── analysis/adverse_filter.rb # SIGMET/AIRMET polygon-intersect + PIREP/storm-report distance filtering +└── cli.rb # Thor subcommand: skywatch brief AIRPORT +spec/skywatch/brief/... +docs/superpowers/specs/2026-04-27-skywatch-brief-design.md +docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md # filled during validation +``` + +`Brief` is a top-level domain alongside Briefer/Nimbus/Mayday/Radar — but unlike them, it has no `sources/` subdirectory. That is the structural signal that it is a composition layer, not a new data domain. + +### Two pieces of new infrastructure (small, justified) + +1. **Airport → `[lat, lon]`** — read directly from the METAR result the composer already fetched. Zero extra I/O. +2. **Airport → WFO** — derived from `api.weather.gov/points/{lat,lon}` → `properties.cwa`. Same endpoint already used implicitly by the AFD source. Cached via existing `Shared::Cache` (1-day TTL — WFO assignments don't change). + +## Data model + +### Top-level shape + +Every AIM slot follows a uniform contract: `{ available: bool, ... }`. That predictability is for the LLM — its only branching logic per slot is `if slot[:available]`. + +```ruby +{ + airport: "KCDW", + coordinates: [40.875, -74.282], + wfo: "OKX", + fetched_at: "2026-04-27T18:32:14Z", + aim_section: "7-1-5", # anchor for downstream agents + + # AIM 7-1-5 elements, in canonical order: + adverse_conditions: { available: ..., ... }, + vfr_not_recommended: { available: ..., ... }, + synopsis: { available: ..., ... }, + current_conditions: { available: ..., ... }, + enroute_forecast: { available: ..., ... }, + destination_forecast: { available: ..., ... }, + winds_aloft: { available: ..., ... }, + notams: { available: ..., ... }, + atc_delays: { available: ..., ... }, + + # Supplementary, outside AIM-9: + afd: { available: ..., ... } +} +``` + +### Slot specifications + +| Slot | When `available: true` | When `available: false` | +|---|---|---| +| `adverse_conditions` | `{ available: true, items: [{ kind: "sigmet"\|"airmet"\|"pirep"\|"convective_alert"\|"storm_report", ...existing to_h... }, ...], partial_failures: [{source:, reason:}, ...] }`. Items merged from Briefer (SIGMETs/AIRMETs filtered by polygon intersect with airport coord; urgent PIREPs within 100 nm) and Nimbus (convective alerts at coord; storm reports within 100 nm, last 6 hr). Empty `items` list still means `available: true, items: []` — diagnostic ("we looked, nothing adverse"). `partial_failures` is always present, empty array when no failures. | Only if every adverse source failed. `reason: "all adverse sources failed: ..."` | +| `vfr_not_recommended` | `{ available: true, vfr_not_recommended: bool, category: "VFR"\|"MVFR"\|"IFR"\|"LIFR", explanation: "ceiling 800 ft, vis 2 SM" }`. Derived from `Briefer::Analysis::FlightCategory`. Rule: IFR or LIFR → `vfr_not_recommended: true`; MVFR → `false` with "marginal" explanation; VFR → `false`. | Never. METAR-fail is a hard-fail of the whole brief, so this slot is always available when the brief is returned at all. | +| `synopsis` | (never `true` in MVP) | `{ available: false, reason: "no synopsis source in skywatch — see afd slot" }`. Hardcoded. | +| `current_conditions` | `{ available: true, metar: {...}, pireps: [{...}, ...] }`. PIREPs filtered to within 100 nm, non-urgent only (urgent PIREPs go to `adverse_conditions`). PIREP fetch failure leaves `pireps: []`, slot stays available. | Never. METAR-fail is a hard-fail of the whole brief, so this slot is always available when the brief is returned at all. | +| `enroute_forecast` | (never `true` in MVP) | `{ available: false, reason: "single-point brief; route input deferred from MVP" }`. Hardcoded. | +| `destination_forecast` | `{ available: true, taf: {...} }`. TAF for the airport, full `to_h`. | TAF fetch failed or no TAF available. | +| `winds_aloft` | `{ available: true, station: "...", forecasts: [...] }`. Winds aloft for the nearest winds-aloft station. | Lookup or fetch failed. | +| `notams` | (never `true` in MVP) | `{ available: false, reason: "NOTAMs not in skywatch yet — Sectional domain not yet built" }`. Hardcoded. | +| `atc_delays` | (never `true` in MVP) | `{ available: false, reason: "ATC delays not in skywatch yet — no source" }`. Hardcoded. | +| `afd` (supplementary) | `{ available: true, wfo: "OKX", text: "...", issued_at: "..." }`. Full AFD text. | WFO lookup failed or AFD fetch failed. | + +### Design notes + +- **Pass-through on existing models.** Every fetched primitive renders via its existing `to_h`. No new normalization. The brief's job is composition, not reshaping. The `kind:` discriminator on `adverse_conditions.items` is the only addition. +- **Four slots are statically `available: false`.** Synopsis, en-route forecast, NOTAMs, ATC delays — these aren't even attempted; they have hardcoded reasons. This is *also* how the validation report will read: "the LLM correctly identified these gaps and didn't hallucinate." +- **`aim_section: "7-1-5"`** is a literal field. A downstream agent (or future Claude API caller) sees exactly which canonical structure they're looking at, and lets us version-bump the envelope cleanly if AIM 7-1-5 ever gets renumbered. + +## Composition logic + +`Skywatch::Brief::Analysis::Composer` orchestrates the fetches, derives the slots, and wraps each slot's outcome. Sequential, not parallel — Ruby threading + Faraday is workable but premature for a validation MVP. (If brief execution exceeds ~5s in practice, parallelization becomes a follow-up.) + +### Fetch order + +1. **METAR fetch.** Required first — yields the airport's `latitude`/`longitude`. If METAR returns empty or 404, the whole brief raises `Skywatch::Error("no METAR for #{airport}")`. This is the only hard-fail; everything else degrades to `available: false`. +2. **WFO lookup.** `api.weather.gov/points/{lat,lon}` → `properties.cwa`. Wrapped — failure makes `afd: { available: false }`. +3. **Independent fetches** (each wrapped in a per-slot try/rescue): + - `Briefer::Sources::Taf.fetch(airport)` → `destination_forecast` + - `Briefer::Sources::Pirep.fetch(airport, radius_nm: 100)` → split across `current_conditions` (informational PIREPs) and `adverse_conditions` (urgent PIREPs) + - `Briefer::Sources::WindsAloft.fetch(airport)` → `winds_aloft` + - `Briefer::Sources::Sigmet.fetch` + filter → `adverse_conditions` + - `Briefer::Sources::Airmet.fetch` + filter → `adverse_conditions` + - `Briefer::Sources::Afd.fetch(wfo)` → `afd` + - `Nimbus::Sources::Alerts.fetch(at: [lat, lon])` → `adverse_conditions` (warnings/watches) + - `Nimbus::Sources::StormReport.fetch` + filter → `adverse_conditions` +4. **Derivations** (in-memory, no I/O): + - `vfr_not_recommended` from METAR via `Briefer::Analysis::FlightCategory` + - `current_conditions` assembled from METAR + non-urgent PIREPs + - `adverse_conditions.items` assembled from urgent PIREPs + filtered SIGMETs + filtered AIRMETs + convective alerts + recent storm reports + +### Locators and filters + +| Concern | Approach | +|---|---| +| airport → `[lat, lon]` | Read from the METAR result already fetched. Zero extra I/O. | +| airport → `wfo` | `api.weather.gov/points/{lat,lon}` → `properties.cwa`. Cached. | +| SIGMET/AIRMET filtering | rgeo polygon-intersect: does the airport coord fall inside the product's geometry? Each model already exposes its geometry. | +| PIREP / storm-report filtering | `Radar::Analysis::Proximity.distance_nm(...)` ≤ 100 nm. Reuse the existing helper — no new geometry code. | +| PIREP urgency partition | Existing `Briefer::Models::Pirep` exposes type/severity. Urgent → `adverse_conditions`; informational → `current_conditions`. | + +### VFR-not-recommended rule + +``` +if FlightCategory.from(metar) in [:lifr, :ifr] + vfr_not_recommended: true, category: , explanation: ", " +elsif :mvfr + vfr_not_recommended: false, category: "MVFR", explanation: "marginal — , " +else + vfr_not_recommended: false, category: "VFR", explanation: "VFR conditions" +end +``` + +### Per-slot error wrapping + +```ruby +def wrap(slot_name) + yield +rescue StandardError => e + { available: false, reason: "fetch failed: #{e.class}: #{e.message}" } +end +``` + +Applied uniformly. The composer never raises (except METAR hard-fail). + +### Caching + +Every fetch already runs through `Shared::Cache` (configured per-source TTL). The composer adds nothing — calling the brief twice within the existing TTLs is automatically cheap. + +## CLI + +```bash +skywatch brief KCDW # JSON to stdout (canonical, agent-first) +skywatch brief KCDW --format json # explicit, same as default +``` + +Wired as a top-level Thor subcommand alongside `weather`, `mayday`, `nimbus`, `radar`. No `--format text` for MVP — the LLM is the consumer of the validation, not a human terminal user. A text formatter is a follow-up. + +## Error handling + +| Failure | Behavior | +|---|---| +| Invalid airport / METAR returns empty | `Skywatch::Error("no METAR for #{airport}")` raised at the Ruby API; CLI prints to stderr and exits non-zero. Only hard-fail. | +| Any other source fetch fails | Slot becomes `{ available: false, reason: "fetch failed: : " }`. Brief still returns. | +| WFO lookup fails | `afd: { available: false }`. Everything else continues. | +| Adverse-source partial failure | `adverse_conditions.items` includes what succeeded; the slot stays `available: true`; the failed sources are noted in `adverse_conditions.partial_failures: [{ source:, reason: }, ...]` (empty array when no failures). | + +The `partial_failures` convention on adverse is the only place the uniform `available: bool` contract bends, and it bends to preserve the *meaning* of `available: true` — it must mean "we got a real read on adverse conditions," which it doesn't if half the sources silently failed. A non-empty `partial_failures` is the LLM's signal "this brief is incomplete on adverse." + +## Testing strategy + +Three layers, scaling cheap to expensive: + +### 1. Unit specs (RSpec, fixture-backed via WebMock) + +- `Composer` orchestration: each slot's success path, each slot's failure path, the METAR-hard-fail, the partial-failures aggregation. +- `AirportLocator`: METAR-derived lat/lon, NWS points → WFO (cached), failure modes. +- VFR-not-recommended derivation: VFR / MVFR / IFR / LIFR + missing-METAR. +- Adverse filtering: SIGMET polygon-intersect (in-region vs out-of-region), PIREP partitioning (urgent vs informational), distance-based filtering, empty-list case. +- Brief envelope: shape contract — every slot is `{ available: bool, ... }`, every hardcoded-unavailable slot has the right reason string. + +### 2. Integration spec + +One spec: full `Skywatch.brief(airport: "KCDW")` end-to-end with all sources stubbed via WebMock, asserting the full JSON envelope against a frozen fixture. Catches shape regressions cheaply. + +### 3. Validation report (committed markdown, not RSpec) + +- File: `docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md` +- Three airport scenarios picked to stress different parts of the rubric: + 1. **A VFR-clear airport** (e.g., KCDW on a clear day) — easy case; brief should read cleanly. + 2. **An IFR airport** (picked at validation time based on real conditions) — tests `vfr_not_recommended: true` path and IFR-relevant adverse handling. + 3. **An airport with active convective alerts** (picked at validation time) — tests Nimbus integration and the most safety-critical adverse path. +- For each scenario: timestamp of capture, the captured JSON brief, the canonical 7-question prompt, the LLM's response, a pass/fail call. + +### Canonical 7-question protocol (frozen for reproducibility) + +1. *Is VFR flight recommended for this airport right now?* +2. *What's the synopsis / weather pattern in the area?* +3. *What are the current conditions on the field?* +4. *What's the destination/terminal forecast?* +5. *What are the winds and temperature at 6000 ft?* +6. *What adverse conditions should I worry about?* +7. *What's NOT in this brief that I'd need to get from elsewhere before I fly?* + +### Validation acceptance criteria + +**Pass** if, across the three scenarios: +- Q1–Q6: LLM answers are substantively correct (pilot-grade judgment) on at least 2 of 3 scenarios. +- Q7 (anti-hallucination): LLM correctly identifies NOTAMs and ATC delays as not-in-brief on **all 3 scenarios**. This is the load-bearing test — if the LLM hallucinates NOTAMs, the whole envelope shape was for nothing. + +**Fail** if: +- Any Q7 hallucination — the `available: false, reason: ...` contract isn't doing its job; we'd need to revisit the contract or the prompt before expanding. +- Multiple Q1–Q6 substantive errors across scenarios — the data we have isn't sufficient regardless of shape; that's a "go research more sources before expanding" signal, and the lightning/smoke/sectional priorities re-rank accordingly. + +Either outcome is informative — that is the point of running this MVP at all. + +## Follow-up GitHub issues to file + +(File after spec is approved, before implementation begins.) + +1. Route-input briefs (`from:`/`to:`) — option B from input-scope question. +2. Coordinate-input briefs (`at: [lat, lon]`) — option B from input-identifier question. +3. ETD-aware briefs (`departing_at:`) — option B from time-scope question. +4. AFD synopsis sub-paragraph extraction — option C from synopsis question. +5. Text formatter for `skywatch brief --format text`. +6. Parallelize composer fetches if `Skywatch.brief` exceeds ~5s in practice. + +(One issue or several — decide at filing time based on what overlaps.) From da939722baaf84e361c26bf06f8b44c2d9d05de6 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 11:29:25 -0400 Subject: [PATCH 02/13] docs: add Skywatch.brief implementation plan 12-task TDD plan covering: AirportLocator (METAR coords + NWS /points WFO lookup), AdverseFilter (polygon-intersect + distance + urgency partition), Brief envelope (AIM 7-1-5 + supplementary AFD), Composer (happy-path + per-slot error wrapping + adverse partial_failures), top-level Skywatch.brief, CLI subcommand, integration spec, validation report capture, and follow-up GH issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-27-skywatch-brief.md | 1945 +++++++++++++++++ 1 file changed, 1945 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-skywatch-brief.md diff --git a/docs/superpowers/plans/2026-04-27-skywatch-brief.md b/docs/superpowers/plans/2026-04-27-skywatch-brief.md new file mode 100644 index 0000000..b08ce8b --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-skywatch-brief.md @@ -0,0 +1,1945 @@ +# Skywatch.brief Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `Skywatch.brief(airport:)` and `skywatch brief AIRPORT` — a single composition layer that produces an [AIM 7-1-5](https://www.faa.gov/air_traffic/publications/atpubs/aim_html/chap7_section_1.html)-shaped weather brief at one airport, composed from existing Briefer + Nimbus primitives. The MVP shipping bar is **validation success** — confirming the LLM-briefer thesis works on what's already built before expanding (route input, coord input, ETD, NOTAMs). + +**Architecture:** Pure composition layer over existing primitives — no new data sources. `Skywatch::Brief::Analysis::Composer` orchestrates Briefer + Nimbus fetches sequentially, wraps each in per-slot try/rescue, and assembles `Skywatch::Brief::Models::Brief` whose `#to_h` produces an AIM-9-aligned envelope with uniform `{ available: bool, ... }` slot contracts. Single-airport input. METAR is the only hard-fail; everything else degrades to `available: false` with a reason string. + +**Tech Stack:** Ruby gem · Thor (CLI) · Faraday + faraday-retry (HTTP) · `Shared::Http`/`Shared::Cache` (existing) · WebMock (test stubs) · RSpec · RGeo (geometry) · `Briefer::Sources::*` + `Nimbus::Sources::*` (existing primitives, unchanged). + +**Reference:** Spec at `docs/superpowers/specs/2026-04-27-skywatch-brief-design.md`. Read it once before starting. The branch `skywatch-brief` already exists with the spec commit (`17acc3a`). + +--- + +## File Structure + +**Create:** + +| Path | Responsibility | +|---|---| +| `lib/skywatch/brief/models/brief.rb` | The envelope model — holds all slot data, `#to_h` produces AIM-9 hash with uniform `{ available: bool, ... }` per slot | +| `lib/skywatch/brief/analysis/airport_locator.rb` | `coordinates_from_metar(metar) → [lat, lon]` and `wfo_for(lat, lon) → "OKX"` (via `api.weather.gov/points/{lat,lon}`, cached) | +| `lib/skywatch/brief/analysis/adverse_filter.rb` | SIGMET/AIRMET polygon-intersect, PIREP/storm-report distance filtering, PIREP urgent-vs-informational partition | +| `lib/skywatch/brief/analysis/composer.rb` | Orchestrates fetches, derives slots, wraps each in try/rescue, assembles `Brief` | +| `lib/skywatch/brief/cli.rb` | Thor subcommand: `skywatch brief AIRPORT` (JSON-only for MVP) | +| `spec/brief/models/brief_spec.rb` | Brief shape contract tests | +| `spec/brief/analysis/airport_locator_spec.rb` | Locator + WFO lookup + cache behavior | +| `spec/brief/analysis/adverse_filter_spec.rb` | Filter + partition behavior | +| `spec/brief/analysis/composer_spec.rb` | Per-slot success/failure paths, METAR-hard-fail, partial_failures aggregation | +| `spec/brief/cli_spec.rb` | CLI command shape (JSON output, error path) | +| `spec/brief_spec.rb` | Integration: full `Skywatch.brief("KCDW")` against fixtures, snapshot the AIM-9 envelope | +| `spec/fixtures/nws_points/kcdw.json` | NWS `/points/{lat,lon}` response for KCDW (yields `properties.cwa = "OKX"`) | + +**Modify:** + +| Path | Change | +|---|---| +| `lib/skywatch.rb` | Add `Skywatch.brief(airport:)`; require_relative the new files | +| `lib/skywatch/cli.rb` | Add `brief AIRPORT` direct command (not a subcommand — single command, single arg) | +| `CLAUDE.md` | Add `skywatch brief KCDW` to CLI usage block | + +**Validation deliverable (filled at Task 12, not implementation):** + +| Path | Purpose | +|---|---| +| `docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md` | Captured JSON briefs + LLM responses to canonical 7-question prompt for 3 scenarios + pass/fail call | + +--- + +## Pre-flight + +Before Task 1, the implementer should: + +1. `cd /Users/jravaliya/Code/skywatch` +2. Confirm we're on the right branch: + ```bash + git rev-parse --abbrev-ref HEAD + # expected: skywatch-brief + ``` +3. Confirm baseline: + ```bash + bundle exec rspec + # expected: 371 examples, 0 failures + bundle exec rubocop + # expected: clean + ``` +4. Each task ends with `git add && git commit`. Do not push between tasks; the final task pushes the branch and opens the PR. + +--- + +## Task 1: Capture NWS `/points/{lat,lon}` fixture + +**Files:** +- Create: `spec/fixtures/nws_points/kcdw.json` + +The composer's WFO lookup hits `api.weather.gov/points/{lat,lon}` and reads `properties.cwa`. We need one canonical fixture. KCDW is at approximately `40.875, -74.282` and falls under WFO OKX (NWS New York). + +- [ ] **Step 1: Make the fixture directory** + +```bash +mkdir -p spec/fixtures/nws_points +``` + +- [ ] **Step 2: Write the fixture** + +Save as `spec/fixtures/nws_points/kcdw.json`: + +```json +{ + "@context": ["https://geojson.org/geojson-ld/geojson-context.jsonld"], + "id": "https://api.weather.gov/points/40.8751,-74.2814", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.2814, 40.8751] + }, + "properties": { + "@id": "https://api.weather.gov/points/40.8751,-74.2814", + "@type": "wx:Point", + "cwa": "OKX", + "forecastOffice": "https://api.weather.gov/offices/OKX", + "gridId": "OKX", + "gridX": 33, + "gridY": 38, + "forecast": "https://api.weather.gov/gridpoints/OKX/33,38/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/OKX/33,38/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/OKX/33,38", + "observationStations": "https://api.weather.gov/gridpoints/OKX/33,38/stations", + "relativeLocation": { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-74.262, 40.847]}, + "properties": { + "city": "Caldwell", + "state": "NJ", + "distance": {"unitCode": "wmoUnit:m", "value": 3500} + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/NJZ103", + "county": "https://api.weather.gov/zones/county/NJC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/NJZ103", + "timeZone": "America/New_York", + "radarStation": "KDIX" + } +} +``` + +- [ ] **Step 3: Validate JSON parses** + +```bash +ruby -rjson -e 'JSON.parse(File.read("spec/fixtures/nws_points/kcdw.json")); puts "OK"' +``` + +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add spec/fixtures/nws_points/ +git commit -m "test(brief): add NWS /points fixture for KCDW" +``` + +--- + +## Task 2: AirportLocator — lat/lon from METAR + WFO from NWS points + +**Files:** +- Create: `spec/brief/analysis/airport_locator_spec.rb` +- Create: `lib/skywatch/brief/analysis/airport_locator.rb` + +`AirportLocator` is a stateless module. Two methods: `coordinates_from_metar(metar)` is pure (no I/O — just reads the METAR's lat/lon attributes), and `wfo_for(lat, lon)` calls `api.weather.gov/points/{lat,lon}` with a 1-day cache and returns the `properties.cwa` string. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/analysis/airport_locator_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::AirportLocator do + describe '.coordinates_from_metar' do + it 'returns [lat, lon] from a METAR' do + metar = Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282 + ) + expect(described_class.coordinates_from_metar(metar)).to eq([40.875, -74.282]) + end + + it 'raises when METAR has no coordinates' do + metar = Skywatch::Briefer::Models::Metar.new(station_id: 'KCDW') + expect { described_class.coordinates_from_metar(metar) } + .to raise_error(Skywatch::Error, /no coordinates on METAR/) + end + end + + describe '.wfo_for' do + let(:fixture) { File.read(File.expand_path('../../fixtures/nws_points/kcdw.json', __dir__)) } + + before do + stub_request(:get, %r{https://api\.weather\.gov/points/40\.875,-74\.282}) + .to_return(status: 200, body: fixture, headers: { 'Content-Type' => 'application/geo+json' }) + end + + it 'returns the WFO id for a coordinate' do + expect(described_class.wfo_for(40.875, -74.282)).to eq('OKX') + end + + it 'caches subsequent calls within TTL' do + described_class.wfo_for(40.875, -74.282) + described_class.wfo_for(40.875, -74.282) + expect(WebMock).to have_requested(:get, %r{points/40\.875,-74\.282}).once + end + + it 'raises Skywatch::Error when the points endpoint fails' do + stub_request(:get, %r{https://api\.weather\.gov/points/0,0}) + .to_return(status: 500, body: 'boom') + expect { described_class.wfo_for(0.0, 0.0) }.to raise_error(Skywatch::Error) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief/analysis/airport_locator_spec.rb +``` + +Expected: failure with `uninitialized constant Skywatch::Brief`. + +- [ ] **Step 3: Write the implementation** + +Save as `lib/skywatch/brief/analysis/airport_locator.rb`: + +```ruby +# frozen_string_literal: true + +module Skywatch + module Brief + module Analysis + module AirportLocator + WFO_TTL = 86_400 + + def self.coordinates_from_metar(metar) + raise Skywatch::Error, "no coordinates on METAR for #{metar.station_id}" if + metar.latitude.nil? || metar.longitude.nil? + + [metar.latitude, metar.longitude] + end + + def self.wfo_for(lat, lon) + data = points_client.get("/points/#{lat},#{lon}", {}, ttl: WFO_TTL) + cwa = data.dig('properties', 'cwa') + raise Skywatch::Error, "no WFO on /points response for #{lat},#{lon}" if cwa.nil? || cwa.empty? + + cwa + rescue Skywatch::ApiError => e + raise Skywatch::Error, "WFO lookup failed for #{lat},#{lon}: #{e.message}" + end + + def self.points_client + @points_client ||= Skywatch::Shared::Cache.new( + client: Skywatch::Shared::Http.new(base_url: 'https://api.weather.gov') + ) + end + + def self.reset! + @points_client = nil + end + end + end + end +end +``` + +Note: the locator owns its own cached client to keep the WFO TTL (1 day) separate from the shared `Skywatch.client` TTLs. `reset!` is for tests if they need to clear it. + +- [ ] **Step 4: Wire it into `lib/skywatch.rb`** + +Edit `lib/skywatch.rb`. Find the existing `require_relative 'skywatch/nimbus/formatters/text'` line. Add immediately after it: + +```ruby +require_relative 'skywatch/brief/analysis/airport_locator' +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief/analysis/airport_locator_spec.rb +``` + +Expected: all tests pass. + +- [ ] **Step 6: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/brief/ spec/brief/ +``` + +Expected: no offenses. + +- [ ] **Step 7: Commit** + +```bash +git add lib/skywatch/brief/analysis/airport_locator.rb lib/skywatch.rb spec/brief/analysis/airport_locator_spec.rb +git commit -m "feat(brief): add AirportLocator (METAR coords + NWS WFO lookup)" +``` + +--- + +## Task 3: AdverseFilter — polygon-intersect, distance, urgency partition + +**Files:** +- Create: `spec/brief/analysis/adverse_filter_spec.rb` +- Create: `lib/skywatch/brief/analysis/adverse_filter.rb` + +Three pure functions: `covers?(product, lat, lon)` for SIGMET/AIRMET polygon-intersect; `within(items, lat:, lon:, radius_nm:)` for PIREP/storm-report distance filtering (reuses `Radar::Analysis::Proximity`); `partition_pireps(pireps)` returns `{urgent:, informational:}`. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/analysis/adverse_filter_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::AdverseFilter do + describe '.covers?' do + let(:square_around_kcdw) do + coords = [ + Skywatch::Shared::Position.new(lat: 41.0, lon: -75.0), + Skywatch::Shared::Position.new(lat: 41.0, lon: -74.0), + Skywatch::Shared::Position.new(lat: 40.0, lon: -74.0), + Skywatch::Shared::Position.new(lat: 40.0, lon: -75.0), + Skywatch::Shared::Position.new(lat: 41.0, lon: -75.0) + ] + Skywatch::Briefer::Models::Sigmet.new(coords: coords) + end + + it 'returns true when the airport is inside the polygon' do + expect(described_class.covers?(square_around_kcdw, 40.875, -74.282)).to be true + end + + it 'returns false when the airport is outside the polygon' do + expect(described_class.covers?(square_around_kcdw, 35.0, -100.0)).to be false + end + + it 'returns false when the product has no polygon' do + degenerate = Skywatch::Briefer::Models::Sigmet.new(coords: []) + expect(described_class.covers?(degenerate, 40.875, -74.282)).to be false + end + end + + describe '.within' do + let(:near_pirep) do + Skywatch::Briefer::Models::Pirep.new(latitude: 40.9, longitude: -74.3) + end + let(:far_pirep) do + Skywatch::Briefer::Models::Pirep.new(latitude: 30.0, longitude: -90.0) + end + + it 'keeps items within radius' do + result = described_class.within([near_pirep, far_pirep], lat: 40.875, lon: -74.282, radius_nm: 100) + expect(result).to eq([near_pirep]) + end + + it 'returns empty when nothing is in range' do + expect(described_class.within([far_pirep], lat: 40.875, lon: -74.282, radius_nm: 100)).to eq([]) + end + + it 'skips items missing latitude or longitude' do + headless = Skywatch::Briefer::Models::Pirep.new(latitude: nil, longitude: nil) + expect(described_class.within([headless], lat: 40.0, lon: -74.0, radius_nm: 100)).to eq([]) + end + end + + describe '.partition_pireps' do + let(:routine) { Skywatch::Briefer::Models::Pirep.new(pirep_type: :pirep) } + let(:urgent) { Skywatch::Briefer::Models::Pirep.new(pirep_type: :"urgent pirep") } + + it 'splits urgent (any pirep_type containing "urgent") from informational' do + result = described_class.partition_pireps([routine, urgent]) + expect(result[:urgent]).to eq([urgent]) + expect(result[:informational]).to eq([routine]) + end + + it 'treats nil pirep_type as informational' do + blank = Skywatch::Briefer::Models::Pirep.new(pirep_type: nil) + result = described_class.partition_pireps([blank]) + expect(result[:urgent]).to eq([]) + expect(result[:informational]).to eq([blank]) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief/analysis/adverse_filter_spec.rb +``` + +Expected: `uninitialized constant Skywatch::Brief::Analysis::AdverseFilter`. + +- [ ] **Step 3: Write the implementation** + +Save as `lib/skywatch/brief/analysis/adverse_filter.rb`: + +```ruby +# frozen_string_literal: true + +module Skywatch + module Brief + module Analysis + module AdverseFilter + def self.covers?(product, lat, lon) + polygon = product.polygon + return false if polygon.nil? + + polygon.contains?(Skywatch::Shared::Geometry.point(lat, lon)) + end + + def self.within(items, lat:, lon:, radius_nm:) + items.select do |item| + next false if item.latitude.nil? || item.longitude.nil? + + Skywatch::Radar::Analysis::Proximity.distance_nm(lat, lon, item.latitude, item.longitude) <= radius_nm + end + end + + def self.partition_pireps(pireps) + urgent, informational = pireps.partition do |p| + p.pirep_type.to_s.include?('urgent') + end + { urgent: urgent, informational: informational } + end + end + end + end +end +``` + +- [ ] **Step 4: Wire it into `lib/skywatch.rb`** + +In `lib/skywatch.rb`, immediately after the `require_relative 'skywatch/brief/analysis/airport_locator'` line added in Task 2, add: + +```ruby +require_relative 'skywatch/brief/analysis/adverse_filter' +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief/analysis/adverse_filter_spec.rb +``` + +Expected: all tests pass. + +- [ ] **Step 6: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/brief/ spec/brief/ +``` + +Expected: no offenses. + +- [ ] **Step 7: Commit** + +```bash +git add lib/skywatch/brief/analysis/adverse_filter.rb lib/skywatch.rb spec/brief/analysis/adverse_filter_spec.rb +git commit -m "feat(brief): add AdverseFilter (polygon, distance, urgency partition)" +``` + +--- + +## Task 4: Brief model — slot contracts + +**Files:** +- Create: `spec/brief/models/brief_spec.rb` +- Create: `lib/skywatch/brief/models/brief.rb` + +The model is a value object holding all slot data plus envelope metadata. Each slot is set on construction; `#to_h` produces the AIM-9 envelope. The four hardcoded-unavailable slots (synopsis, enroute_forecast, notams, atc_delays) and the `aim_section` constant are baked in. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/models/brief_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Models::Brief do + let(:slot_available) { { available: true, payload: 'ok' } } + let(:slot_unavailable) { { available: false, reason: 'fetch failed: ...' } } + + let(:brief) do + described_class.new( + airport: 'KCDW', + coordinates: [40.875, -74.282], + wfo: 'OKX', + fetched_at: Time.utc(2026, 4, 27, 18, 32, 14), + adverse_conditions: { available: true, items: [], partial_failures: [] }, + vfr_not_recommended: { available: true, vfr_not_recommended: false, category: 'VFR', explanation: 'VFR conditions' }, + current_conditions: { available: true, metar: { station_id: 'KCDW' }, pireps: [] }, + destination_forecast: slot_available, + winds_aloft: slot_available, + afd: slot_available + ) + end + + it 'serializes the AIM-9 envelope in canonical order' do + hash = brief.to_h + expect(hash[:airport]).to eq('KCDW') + expect(hash[:coordinates]).to eq([40.875, -74.282]) + expect(hash[:wfo]).to eq('OKX') + expect(hash[:fetched_at]).to eq('2026-04-27T18:32:14Z') + expect(hash[:aim_section]).to eq('7-1-5') + end + + it 'includes every AIM 7-1-5 slot' do + hash = brief.to_h + %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays].each do |slot| + expect(hash).to include(slot), "missing slot: #{slot}" + end + end + + it 'includes the supplementary afd slot' do + expect(brief.to_h).to include(:afd) + end + + it 'hardcodes synopsis as unavailable with afd-pointer reason' do + expect(brief.to_h[:synopsis]).to eq( + available: false, + reason: 'no synopsis source in skywatch — see afd slot' + ) + end + + it 'hardcodes enroute_forecast as unavailable with route-deferred reason' do + expect(brief.to_h[:enroute_forecast]).to eq( + available: false, + reason: 'single-point brief; route input deferred from MVP' + ) + end + + it 'hardcodes notams as unavailable with sectional-domain reason' do + expect(brief.to_h[:notams]).to eq( + available: false, + reason: 'NOTAMs not in skywatch yet — Sectional domain not yet built' + ) + end + + it 'hardcodes atc_delays as unavailable' do + expect(brief.to_h[:atc_delays]).to eq( + available: false, + reason: 'ATC delays not in skywatch yet — no source' + ) + end + + it 'preserves the AIM-9 slots in canonical order' do + keys = brief.to_h.keys + aim9 = %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays] + expect(keys & aim9).to eq(aim9) + end + + it 'serializes to JSON' do + expect { JSON.parse(brief.to_json) }.not_to raise_error + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief/models/brief_spec.rb +``` + +Expected: `uninitialized constant Skywatch::Brief::Models::Brief`. + +- [ ] **Step 3: Write the implementation** + +Save as `lib/skywatch/brief/models/brief.rb`: + +```ruby +# frozen_string_literal: true + +require 'time' +require 'json' + +module Skywatch + module Brief + module Models + class Brief + AIM_SECTION = '7-1-5' + + SYNOPSIS_UNAVAILABLE = { + available: false, + reason: 'no synopsis source in skywatch — see afd slot' + }.freeze + + ENROUTE_UNAVAILABLE = { + available: false, + reason: 'single-point brief; route input deferred from MVP' + }.freeze + + NOTAMS_UNAVAILABLE = { + available: false, + reason: 'NOTAMs not in skywatch yet — Sectional domain not yet built' + }.freeze + + ATC_DELAYS_UNAVAILABLE = { + available: false, + reason: 'ATC delays not in skywatch yet — no source' + }.freeze + + attr_reader :airport, :coordinates, :wfo, :fetched_at, + :adverse_conditions, :vfr_not_recommended, + :current_conditions, :destination_forecast, :winds_aloft, :afd + + # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists + def initialize(airport:, coordinates:, wfo:, fetched_at:, + adverse_conditions:, vfr_not_recommended:, + current_conditions:, destination_forecast:, winds_aloft:, afd:) + @airport = airport + @coordinates = coordinates + @wfo = wfo + @fetched_at = fetched_at + @adverse_conditions = adverse_conditions + @vfr_not_recommended = vfr_not_recommended + @current_conditions = current_conditions + @destination_forecast = destination_forecast + @winds_aloft = winds_aloft + @afd = afd + end + # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists + + # rubocop:disable Metrics/MethodLength + def to_h + { + airport: airport, + coordinates: coordinates, + wfo: wfo, + fetched_at: fetched_at&.iso8601, + aim_section: AIM_SECTION, + adverse_conditions: adverse_conditions, + vfr_not_recommended: vfr_not_recommended, + synopsis: SYNOPSIS_UNAVAILABLE, + current_conditions: current_conditions, + enroute_forecast: ENROUTE_UNAVAILABLE, + destination_forecast: destination_forecast, + winds_aloft: winds_aloft, + notams: NOTAMS_UNAVAILABLE, + atc_delays: ATC_DELAYS_UNAVAILABLE, + afd: afd + } + end + # rubocop:enable Metrics/MethodLength + + def to_json(*) + to_h.to_json(*) + end + end + end + end +end +``` + +- [ ] **Step 4: Wire it into `lib/skywatch.rb`** + +In `lib/skywatch.rb`, immediately after the `require_relative 'skywatch/brief/analysis/adverse_filter'` line added in Task 3, add: + +```ruby +require_relative 'skywatch/brief/models/brief' +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief/models/brief_spec.rb +``` + +Expected: all tests pass. + +- [ ] **Step 6: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/brief/ spec/brief/ +``` + +Expected: no offenses. + +- [ ] **Step 7: Commit** + +```bash +git add lib/skywatch/brief/models/brief.rb lib/skywatch.rb spec/brief/models/brief_spec.rb +git commit -m "feat(brief): add Brief model with AIM 7-1-5 envelope" +``` + +--- + +## Task 5: Composer happy-path — wires every slot when fetches succeed + +**Files:** +- Create: `spec/brief/analysis/composer_spec.rb` +- Create: `lib/skywatch/brief/analysis/composer.rb` + +The composer takes an airport identifier, fetches METAR (hard-fail if missing), derives lat/lon and WFO, fetches everything else, and assembles a `Brief`. This task covers the happy path only — error wrapping is added in Task 6. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/analysis/composer_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::Composer do + let(:metar) do + Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282, + visibility_sm: 10, sky_condition: [{ cover: :few, base_ft: 5000 }] + ) + end + + let(:metar_source) { instance_double(Skywatch::Briefer::Sources::Metar, fetch: [metar]) } + let(:taf_source) { instance_double(Skywatch::Briefer::Sources::Taf, fetch: []) } + let(:pirep_source) { instance_double(Skywatch::Briefer::Sources::Pirep, fetch: []) } + let(:winds_source) { instance_double(Skywatch::Briefer::Sources::WindsAloft, fetch: []) } + let(:sigmet_source) { instance_double(Skywatch::Briefer::Sources::Sigmet, fetch: []) } + let(:airmet_source) { instance_double(Skywatch::Briefer::Sources::Airmet, fetch: []) } + let(:afd_model) do + Skywatch::Briefer::Models::Afd.new( + wfo: 'OKX', product_name: 'Area Forecast Discussion', + issued_at: Time.utc(2026, 4, 27, 14), text: 'SYNOPSIS...' + ) + end + let(:afd_source) { instance_double(Skywatch::Briefer::Sources::Afd, fetch: afd_model) } + let(:alerts_source) { instance_double(Skywatch::Nimbus::Sources::Alerts, fetch: []) } + let(:storm_source) { instance_double(Skywatch::Nimbus::Sources::StormReport, fetch: []) } + + before do + allow(Skywatch::Brief::Analysis::AirportLocator).to receive(:wfo_for).and_return('OKX') + end + + let(:composer) do + described_class.new( + metar_source: metar_source, taf_source: taf_source, pirep_source: pirep_source, + winds_source: winds_source, sigmet_source: sigmet_source, airmet_source: airmet_source, + afd_source: afd_source, alerts_source: alerts_source, storm_source: storm_source + ) + end + + it 'returns a Brief with metadata populated' do + brief = composer.compose(airport: 'KCDW') + expect(brief).to be_a(Skywatch::Brief::Models::Brief) + expect(brief.airport).to eq('KCDW') + expect(brief.coordinates).to eq([40.875, -74.282]) + expect(brief.wfo).to eq('OKX') + expect(brief.fetched_at).to be_a(Time) + end + + it 'sets adverse_conditions available with empty items when nothing is adverse' do + expect(composer.compose(airport: 'KCDW').adverse_conditions).to eq( + available: true, items: [], partial_failures: [] + ) + end + + it 'sets vfr_not_recommended from FlightCategory' do + slot = composer.compose(airport: 'KCDW').vfr_not_recommended + expect(slot[:available]).to be true + expect(slot[:vfr_not_recommended]).to be false + expect(slot[:category]).to eq('VFR') + expect(slot[:explanation]).to include('VFR') + end + + it 'sets current_conditions with metar and empty pireps' do + slot = composer.compose(airport: 'KCDW').current_conditions + expect(slot[:available]).to be true + expect(slot[:metar][:station_id]).to eq('KCDW') + expect(slot[:pireps]).to eq([]) + end + + it 'sets destination_forecast unavailable when no TAF' do + slot = composer.compose(airport: 'KCDW').destination_forecast + expect(slot[:available]).to be false + expect(slot[:reason]).to include('no TAF') + end + + it 'sets winds_aloft unavailable when no winds-aloft data' do + slot = composer.compose(airport: 'KCDW').winds_aloft + expect(slot[:available]).to be false + expect(slot[:reason]).to include('no winds aloft') + end + + it 'sets afd available when AFD fetch succeeds' do + slot = composer.compose(airport: 'KCDW').afd + expect(slot[:available]).to be true + expect(slot[:wfo]).to eq('OKX') + expect(slot[:text]).to eq('SYNOPSIS...') + end + + it 'classifies LIFR as VFR not recommended' do + metar_lifr = Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282, + visibility_sm: 0.5, sky_condition: [{ cover: :ovc, base_ft: 200 }] + ) + allow(metar_source).to receive(:fetch).and_return([metar_lifr]) + slot = composer.compose(airport: 'KCDW').vfr_not_recommended + expect(slot[:vfr_not_recommended]).to be true + expect(slot[:category]).to eq('LIFR') + end + + it 'raises when METAR is missing' do + allow(metar_source).to receive(:fetch).and_return([]) + expect { composer.compose(airport: 'KZZZ') } + .to raise_error(Skywatch::Error, /no METAR for KZZZ/) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief/analysis/composer_spec.rb +``` + +Expected: `uninitialized constant Skywatch::Brief::Analysis::Composer`. + +- [ ] **Step 3: Write the implementation** + +Save as `lib/skywatch/brief/analysis/composer.rb`: + +```ruby +# frozen_string_literal: true + +require 'time' + +module Skywatch + module Brief + module Analysis + class Composer # rubocop:disable Metrics/ClassLength + ADVERSE_RADIUS_NM = 100 + STORM_REPORT_LOOKBACK_HOURS = 6 + + # rubocop:disable Metrics/ParameterLists + def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, + taf_source: Skywatch::Briefer::Sources::Taf.new, + pirep_source: Skywatch::Briefer::Sources::Pirep.new, + winds_source: Skywatch::Briefer::Sources::WindsAloft.new, + sigmet_source: Skywatch::Briefer::Sources::Sigmet.new, + airmet_source: Skywatch::Briefer::Sources::Airmet.new, + afd_source: Skywatch::Briefer::Sources::Afd.new, + alerts_source: Skywatch::Nimbus::Sources::Alerts.new, + storm_source: Skywatch::Nimbus::Sources::StormReport.new) + @metar_source = metar_source + @taf_source = taf_source + @pirep_source = pirep_source + @winds_source = winds_source + @sigmet_source = sigmet_source + @airmet_source = airmet_source + @afd_source = afd_source + @alerts_source = alerts_source + @storm_source = storm_source + end + # rubocop:enable Metrics/ParameterLists + + def compose(airport:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + metar = fetch_metar_or_raise(airport) + lat, lon = AirportLocator.coordinates_from_metar(metar) + wfo = AirportLocator.wfo_for(lat, lon) + + pireps = @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) + partitioned = AdverseFilter.partition_pireps(pireps) + + Models::Brief.new( + airport: airport.upcase, + coordinates: [lat, lon], + wfo: wfo, + fetched_at: Time.now.utc, + adverse_conditions: build_adverse(lat: lat, lon: lon, urgent_pireps: partitioned[:urgent]), + vfr_not_recommended: build_vfr(metar), + current_conditions: build_current(metar: metar, pireps: partitioned[:informational]), + destination_forecast: build_destination(airport), + winds_aloft: build_winds(airport), + afd: build_afd(wfo) + ) + end + + private + + def fetch_metar_or_raise(airport) + metars = @metar_source.fetch(airport) + raise Skywatch::Error, "no METAR for #{airport.upcase}" if metars.empty? + + metars.first + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def build_adverse(lat:, lon:, urgent_pireps:) + sigmets = @sigmet_source.fetch.select { |s| AdverseFilter.covers?(s, lat, lon) } + airmets = @airmet_source.fetch.select { |a| AdverseFilter.covers?(a, lat, lon) } + alerts = @alerts_source.fetch(at: [lat, lon]) + all_storms = @storm_source.fetch + recent = recent_storms(all_storms) + near_storms = AdverseFilter.within(recent, lat: lat, lon: lon, radius_nm: ADVERSE_RADIUS_NM) + + items = [] + sigmets.each { |s| items << { kind: 'sigmet' }.merge(s.to_h) } + airmets.each { |a| items << { kind: 'airmet' }.merge(a.to_h) } + urgent_pireps.each { |p| items << { kind: 'pirep' }.merge(p.to_h) } + alerts.each { |a| items << { kind: 'convective_alert' }.merge(a.to_h) } + near_storms.each { |s| items << { kind: 'storm_report' }.merge(s.to_h) } + + { available: true, items: items, partial_failures: [] } + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + def recent_storms(storms) + cutoff = Time.now.utc - (STORM_REPORT_LOOKBACK_HOURS * 3600) + storms.select { |s| s.time && s.time >= cutoff } + end + + def build_vfr(metar) # rubocop:disable Metrics/MethodLength + category = metar.flight_category + case category + when :lifr, :ifr + { available: true, vfr_not_recommended: true, category: category.to_s.upcase, + explanation: explanation_for(metar) } + when :mvfr + { available: true, vfr_not_recommended: false, category: 'MVFR', + explanation: "marginal — #{explanation_for(metar)}" } + else + { available: true, vfr_not_recommended: false, category: 'VFR', + explanation: 'VFR conditions' } + end + end + + def explanation_for(metar) + parts = [] + parts << "ceiling #{metar.ceiling_ft} ft" if metar.ceiling_ft + parts << "vis #{metar.visibility_sm} SM" if metar.visibility_sm + parts.empty? ? 'see METAR' : parts.join(', ') + end + + def build_current(metar:, pireps:) + { available: true, metar: metar.to_h, pireps: pireps.map(&:to_h) } + end + + def build_destination(airport) + tafs = @taf_source.fetch(airport) + if tafs.empty? + { available: false, reason: "no TAF for #{airport.upcase}" } + else + { available: true, taf: tafs.first.to_h } + end + end + + def build_winds(airport) + forecasts = @winds_source.fetch(airport) + if forecasts.empty? + { available: false, reason: "no winds aloft for #{airport.upcase}" } + else + { available: true, station: airport.upcase, forecasts: forecasts.map(&:to_h) } + end + end + + def build_afd(wfo) + afd = @afd_source.fetch(wfo) + { available: true, wfo: afd.wfo, text: afd.text, issued_at: afd.issued_at&.iso8601 } + end + end + end + end +end +``` + +- [ ] **Step 4: Wire it into `lib/skywatch.rb`** + +In `lib/skywatch.rb`, immediately after the `require_relative 'skywatch/brief/models/brief'` line added in Task 4, add: + +```ruby +require_relative 'skywatch/brief/analysis/composer' +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief/analysis/composer_spec.rb +``` + +Expected: all tests pass. + +- [ ] **Step 6: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/brief/ spec/brief/ +``` + +Expected: no offenses. + +- [ ] **Step 7: Commit** + +```bash +git add lib/skywatch/brief/analysis/composer.rb lib/skywatch.rb spec/brief/analysis/composer_spec.rb +git commit -m "feat(brief): add Composer happy-path" +``` + +--- + +## Task 6: Composer error wrapping — per-slot try/rescue + adverse partial_failures + +**Files:** +- Modify: `spec/brief/analysis/composer_spec.rb` +- Modify: `lib/skywatch/brief/analysis/composer.rb` + +This task adds the per-slot resilience: any non-METAR fetch failure renders the slot as `{ available: false, reason: ... }`. For adverse_conditions, individual sub-source failures are recorded in `partial_failures` and the slot stays available unless every sub-source fails. + +- [ ] **Step 1: Add the failing tests** + +Append to `spec/brief/analysis/composer_spec.rb` (just before the final `end` that closes the `describe`): + +```ruby + context 'error wrapping' do + it 'sets destination_forecast unavailable when TAF source raises' do + allow(taf_source).to receive(:fetch).and_raise(Skywatch::ApiError, 'HTTP 500') + slot = composer.compose(airport: 'KCDW').destination_forecast + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + expect(slot[:reason]).to include('HTTP 500') + end + + it 'sets winds_aloft unavailable when winds source raises' do + allow(winds_source).to receive(:fetch).and_raise(StandardError, 'boom') + slot = composer.compose(airport: 'KCDW').winds_aloft + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + end + + it 'sets afd unavailable when WFO lookup raises' do + allow(Skywatch::Brief::Analysis::AirportLocator) + .to receive(:wfo_for).and_raise(Skywatch::Error, 'lookup boom') + brief = composer.compose(airport: 'KCDW') + expect(brief.afd[:available]).to be false + expect(brief.afd[:reason]).to include('fetch failed') + expect(brief.wfo).to be_nil + end + + it 'sets afd unavailable when AFD fetch raises' do + allow(afd_source).to receive(:fetch).and_raise(Skywatch::Error, 'no AFD') + slot = composer.compose(airport: 'KCDW').afd + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + end + + it 'records partial_failures on adverse_conditions when one sub-source raises' do + allow(sigmet_source).to receive(:fetch).and_raise(StandardError, 'sigmet boom') + slot = composer.compose(airport: 'KCDW').adverse_conditions + expect(slot[:available]).to be true + expect(slot[:partial_failures]).to contain_exactly( + hash_including(source: 'sigmet', reason: a_string_including('sigmet boom')) + ) + end + + it 'sets adverse_conditions unavailable when every sub-source raises' do + allow(sigmet_source).to receive(:fetch).and_raise(StandardError, 'a') + allow(airmet_source).to receive(:fetch).and_raise(StandardError, 'b') + allow(pirep_source).to receive(:fetch).and_raise(StandardError, 'c') + allow(alerts_source).to receive(:fetch).and_raise(StandardError, 'd') + allow(storm_source).to receive(:fetch).and_raise(StandardError, 'e') + slot = composer.compose(airport: 'KCDW').adverse_conditions + expect(slot[:available]).to be false + expect(slot[:reason]).to include('all adverse sources failed') + end + + it 'still raises hard when METAR fails (no wrapping)' do + allow(metar_source).to receive(:fetch).and_raise(Skywatch::ApiError, 'HTTP 404') + expect { composer.compose(airport: 'KZZZ') }.to raise_error(Skywatch::ApiError) + end + end +``` + +Note: the partial-failures test for adverse PIREPs is implicit — the pirep_source is shared between the current_conditions slot (informational PIREPs) and adverse (urgent PIREPs). When pirep_source raises, both slots degrade. We test this composite behavior in the integration spec (Task 9). + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bundle exec rspec spec/brief/analysis/composer_spec.rb -e "error wrapping" +``` + +Expected: failures around `slot[:available]` being true when it should be false. + +- [ ] **Step 3: Refactor the composer with `wrap` and adverse aggregation** + +Replace the contents of `lib/skywatch/brief/analysis/composer.rb` with: + +```ruby +# frozen_string_literal: true + +require 'time' + +module Skywatch + module Brief + module Analysis + class Composer # rubocop:disable Metrics/ClassLength + ADVERSE_RADIUS_NM = 100 + STORM_REPORT_LOOKBACK_HOURS = 6 + + # rubocop:disable Metrics/ParameterLists + def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, + taf_source: Skywatch::Briefer::Sources::Taf.new, + pirep_source: Skywatch::Briefer::Sources::Pirep.new, + winds_source: Skywatch::Briefer::Sources::WindsAloft.new, + sigmet_source: Skywatch::Briefer::Sources::Sigmet.new, + airmet_source: Skywatch::Briefer::Sources::Airmet.new, + afd_source: Skywatch::Briefer::Sources::Afd.new, + alerts_source: Skywatch::Nimbus::Sources::Alerts.new, + storm_source: Skywatch::Nimbus::Sources::StormReport.new) + @metar_source = metar_source + @taf_source = taf_source + @pirep_source = pirep_source + @winds_source = winds_source + @sigmet_source = sigmet_source + @airmet_source = airmet_source + @afd_source = afd_source + @alerts_source = alerts_source + @storm_source = storm_source + end + # rubocop:enable Metrics/ParameterLists + + def compose(airport:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + metar = fetch_metar_or_raise(airport) + lat, lon = AirportLocator.coordinates_from_metar(metar) + wfo = wrap_wfo(lat, lon) + + pirep_attempt = attempt { @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) } + partitioned = AdverseFilter.partition_pireps(pirep_attempt[:value] || []) + + Models::Brief.new( + airport: airport.upcase, + coordinates: [lat, lon], + wfo: wfo, + fetched_at: Time.now.utc, + adverse_conditions: build_adverse( + lat: lat, lon: lon, urgent_pireps: partitioned[:urgent], pirep_attempt: pirep_attempt + ), + vfr_not_recommended: build_vfr(metar), + current_conditions: build_current(metar: metar, pirep_attempt: pirep_attempt, + informational: partitioned[:informational]), + destination_forecast: wrap('TAF') { build_destination(airport) }, + winds_aloft: wrap('winds aloft') { build_winds(airport) }, + afd: wfo.nil? ? unavailable_afd_for_no_wfo : wrap('AFD') { build_afd(wfo) } + ) + end + + private + + def fetch_metar_or_raise(airport) + metars = @metar_source.fetch(airport) + raise Skywatch::Error, "no METAR for #{airport.upcase}" if metars.empty? + + metars.first + end + + def wrap_wfo(lat, lon) + AirportLocator.wfo_for(lat, lon) + rescue StandardError + nil + end + + def unavailable_afd_for_no_wfo + { available: false, reason: 'fetch failed: WFO lookup failed' } + end + + def attempt + { value: yield, error: nil } + rescue StandardError => e + { value: nil, error: "#{e.class}: #{e.message}" } + end + + def wrap(_label) + yield + rescue StandardError => e + { available: false, reason: "fetch failed: #{e.class}: #{e.message}" } + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def build_adverse(lat:, lon:, urgent_pireps:, pirep_attempt:) + sigmet_attempt = attempt { @sigmet_source.fetch.select { |s| AdverseFilter.covers?(s, lat, lon) } } + airmet_attempt = attempt { @airmet_source.fetch.select { |a| AdverseFilter.covers?(a, lat, lon) } } + alerts_attempt = attempt { @alerts_source.fetch(at: [lat, lon]) } + storm_attempt = attempt do + recent = recent_storms(@storm_source.fetch) + AdverseFilter.within(recent, lat: lat, lon: lon, radius_nm: ADVERSE_RADIUS_NM) + end + + attempts = { + 'sigmet' => sigmet_attempt, 'airmet' => airmet_attempt, + 'pirep' => pirep_attempt, 'convective_alert' => alerts_attempt, + 'storm_report' => storm_attempt + } + partial_failures = attempts.reject { |_, a| a[:error].nil? } + .map { |s, a| { source: s, reason: a[:error] } } + + if partial_failures.size == attempts.size + return { available: false, + reason: "all adverse sources failed: #{partial_failures.map { |f| f[:source] }.join(', ')}" } + end + + items = [] + (sigmet_attempt[:value] || []).each { |s| items << { kind: 'sigmet' }.merge(s.to_h) } + (airmet_attempt[:value] || []).each { |a| items << { kind: 'airmet' }.merge(a.to_h) } + urgent_pireps.each { |p| items << { kind: 'pirep' }.merge(p.to_h) } + (alerts_attempt[:value] || []).each { |a| items << { kind: 'convective_alert' }.merge(a.to_h) } + (storm_attempt[:value] || []).each { |s| items << { kind: 'storm_report' }.merge(s.to_h) } + + { available: true, items: items, partial_failures: partial_failures } + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def recent_storms(storms) + cutoff = Time.now.utc - (STORM_REPORT_LOOKBACK_HOURS * 3600) + storms.select { |s| s.time && s.time >= cutoff } + end + + def build_vfr(metar) # rubocop:disable Metrics/MethodLength + case metar.flight_category + when :lifr, :ifr + { available: true, vfr_not_recommended: true, + category: metar.flight_category.to_s.upcase, explanation: explanation_for(metar) } + when :mvfr + { available: true, vfr_not_recommended: false, + category: 'MVFR', explanation: "marginal — #{explanation_for(metar)}" } + else + { available: true, vfr_not_recommended: false, + category: 'VFR', explanation: 'VFR conditions' } + end + end + + def explanation_for(metar) + parts = [] + parts << "ceiling #{metar.ceiling_ft} ft" if metar.ceiling_ft + parts << "vis #{metar.visibility_sm} SM" if metar.visibility_sm + parts.empty? ? 'see METAR' : parts.join(', ') + end + + def build_current(metar:, pirep_attempt:, informational:) + if pirep_attempt[:error] + { available: true, metar: metar.to_h, pireps: [], + partial_failure: { source: 'pirep', reason: pirep_attempt[:error] } } + else + { available: true, metar: metar.to_h, pireps: informational.map(&:to_h) } + end + end + + def build_destination(airport) + tafs = @taf_source.fetch(airport) + if tafs.empty? + { available: false, reason: "no TAF for #{airport.upcase}" } + else + { available: true, taf: tafs.first.to_h } + end + end + + def build_winds(airport) + forecasts = @winds_source.fetch(airport) + if forecasts.empty? + { available: false, reason: "no winds aloft for #{airport.upcase}" } + else + { available: true, station: airport.upcase, forecasts: forecasts.map(&:to_h) } + end + end + + def build_afd(wfo) + afd = @afd_source.fetch(wfo) + { available: true, wfo: afd.wfo, text: afd.text, issued_at: afd.issued_at&.iso8601 } + end + end + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +bundle exec rspec spec/brief/analysis/composer_spec.rb +``` + +Expected: all tests pass (both happy-path and error-wrapping contexts). + +- [ ] **Step 5: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/brief/ spec/brief/ +``` + +Expected: no offenses. + +- [ ] **Step 6: Commit** + +```bash +git add lib/skywatch/brief/analysis/composer.rb spec/brief/analysis/composer_spec.rb +git commit -m "feat(brief): wrap composer slots with per-slot error handling" +``` + +--- + +## Task 7: `Skywatch.brief` convenience API + +**Files:** +- Modify: `spec/brief_spec.rb` (creates the file) +- Modify: `lib/skywatch.rb` + +The top-level convenience API adds `Skywatch.brief(airport:)` returning a `Skywatch::Brief::Models::Brief`. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch, '.brief' do + it 'composes a Brief for an airport' do + composer = instance_double(Skywatch::Brief::Analysis::Composer) + brief = instance_double(Skywatch::Brief::Models::Brief) + expect(Skywatch::Brief::Analysis::Composer).to receive(:new).and_return(composer) + expect(composer).to receive(:compose).with(airport: 'KCDW').and_return(brief) + + expect(described_class.brief(airport: 'KCDW')).to be(brief) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief_spec.rb +``` + +Expected: `NoMethodError: undefined method 'brief' for Skywatch:Module`. + +- [ ] **Step 3: Add the convenience method** + +In `lib/skywatch.rb`, find the existing method definitions (e.g. `def crosswind`). Add this method just before the `def crosswind` block, inside the `class << self` block: + +```ruby +def brief(airport:) + Brief::Analysis::Composer.new.compose(airport: airport) +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief_spec.rb +``` + +Expected: pass. + +- [ ] **Step 5: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch.rb spec/brief_spec.rb +``` + +Expected: no offenses. + +- [ ] **Step 6: Commit** + +```bash +git add lib/skywatch.rb spec/brief_spec.rb +git commit -m "feat(brief): add Skywatch.brief convenience API" +``` + +--- + +## Task 8: CLI — `skywatch brief AIRPORT` + +**Files:** +- Create: `spec/brief/cli_spec.rb` +- Modify: `lib/skywatch/cli.rb` + +The CLI is a single direct command at the top level (not a subcommand class), since brief takes one argument and has no sub-actions. JSON-only output for MVP — text formatter is deferred per spec. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/cli_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'skywatch brief CLI' do + let(:brief) do + instance_double( + Skywatch::Brief::Models::Brief, + to_h: { airport: 'KCDW', aim_section: '7-1-5' } + ) + end + + before { allow(Skywatch).to receive(:brief).with(airport: 'KCDW').and_return(brief) } + + it 'prints the brief as JSON' do + output = capture_stdout { Skywatch::CLI.start(%w[brief KCDW]) } + parsed = JSON.parse(output) + expect(parsed['airport']).to eq('KCDW') + expect(parsed['aim_section']).to eq('7-1-5') + end + + it 'exits non-zero on Skywatch::Error' do + allow(Skywatch).to receive(:brief).and_raise(Skywatch::Error, 'no METAR for KZZZ') + expect { Skywatch::CLI.start(%w[brief KZZZ]) }.to raise_error(SystemExit) do |e| + expect(e.status).not_to eq(0) + end + end + + def capture_stdout + old = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bundle exec rspec spec/brief/cli_spec.rb +``` + +Expected: `Could not find command "brief"`. + +- [ ] **Step 3: Add `brief` to the top-level CLI** + +Edit `lib/skywatch/cli.rb`. Add the `brief` command before the `desc 'version', 'Print version'` line: + +```ruby + desc 'brief AIRPORT', 'AIM 7-1-5 weather brief composed for AIRPORT' + def brief(airport) + result = Skywatch.brief(airport: airport) + puts JSON.pretty_generate(result.to_h) + rescue Skywatch::Error => e + warn "Error: #{e.message}" + exit 1 + end +``` + +Then add `require 'json'` at the top of the file (just below `require 'thor'`). + +- [ ] **Step 4: Run test to verify it passes** + +```bash +bundle exec rspec spec/brief/cli_spec.rb +``` + +Expected: pass. + +- [ ] **Step 5: Run rubocop** + +```bash +bundle exec rubocop lib/skywatch/cli.rb spec/brief/cli_spec.rb +``` + +Expected: no offenses. + +- [ ] **Step 6: Commit** + +```bash +git add lib/skywatch/cli.rb spec/brief/cli_spec.rb +git commit -m "feat(brief): add 'skywatch brief AIRPORT' CLI command" +``` + +--- + +## Task 9: Integration spec — full envelope shape + +**Files:** +- Create: `spec/brief/integration_spec.rb` + +This is the load-bearing acceptance test: stub every source, call `Skywatch.brief(airport: "KCDW")`, and assert the full JSON envelope matches a frozen expectation. Catches shape regressions cheaply. + +- [ ] **Step 1: Write the spec** + +Save as `spec/brief/integration_spec.rb`: + +```ruby +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Skywatch.brief integration' do + let(:metar) do + Skywatch::Briefer::Models::Metar.new( + raw: 'KCDW 271800Z 27008KT 10SM FEW050 22/15 A3001', + station_id: 'KCDW', + observed_at: Time.utc(2026, 4, 27, 18), + latitude: 40.875, + longitude: -74.282, + visibility_sm: 10, + sky_condition: [{ cover: :few, base_ft: 5000 }], + temperature_c: 22, dewpoint_c: 15 + ) + end + let(:afd) do + Skywatch::Briefer::Models::Afd.new( + wfo: 'OKX', product_name: 'Area Forecast Discussion', + issued_at: Time.utc(2026, 4, 27, 14), + text: 'SYNOPSIS...High pressure builds in.' + ) + end + + before do + allow_any_instance_of(Skywatch::Briefer::Sources::Metar).to receive(:fetch).and_return([metar]) + allow_any_instance_of(Skywatch::Briefer::Sources::Taf).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Pirep).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::WindsAloft).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Sigmet).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Airmet).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Afd).to receive(:fetch).and_return(afd) + allow_any_instance_of(Skywatch::Nimbus::Sources::Alerts).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Nimbus::Sources::StormReport).to receive(:fetch).and_return([]) + allow(Skywatch::Brief::Analysis::AirportLocator).to receive(:wfo_for).and_return('OKX') + end + + it 'produces an AIM-9-aligned envelope' do + hash = Skywatch.brief(airport: 'KCDW').to_h + + expect(hash[:airport]).to eq('KCDW') + expect(hash[:coordinates]).to eq([40.875, -74.282]) + expect(hash[:wfo]).to eq('OKX') + expect(hash[:aim_section]).to eq('7-1-5') + + aim9 = %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays] + aim9.each do |slot| + expect(hash[slot]).to include(:available), "slot #{slot} missing :available" + end + + # Six slots fillable in MVP: 2 truly populated here, 2 gracefully unavailable + # (TAF / winds), 4 statically unavailable, plus AFD supplementary. + expect(hash[:adverse_conditions][:available]).to be true + expect(hash[:adverse_conditions][:items]).to eq([]) + expect(hash[:adverse_conditions][:partial_failures]).to eq([]) + + expect(hash[:vfr_not_recommended][:available]).to be true + expect(hash[:vfr_not_recommended][:vfr_not_recommended]).to be false + expect(hash[:vfr_not_recommended][:category]).to eq('VFR') + + expect(hash[:current_conditions][:available]).to be true + expect(hash[:current_conditions][:metar][:station_id]).to eq('KCDW') + + expect(hash[:destination_forecast][:available]).to be false + expect(hash[:winds_aloft][:available]).to be false + + expect(hash[:synopsis]).to eq(Skywatch::Brief::Models::Brief::SYNOPSIS_UNAVAILABLE) + expect(hash[:enroute_forecast]).to eq(Skywatch::Brief::Models::Brief::ENROUTE_UNAVAILABLE) + expect(hash[:notams]).to eq(Skywatch::Brief::Models::Brief::NOTAMS_UNAVAILABLE) + expect(hash[:atc_delays]).to eq(Skywatch::Brief::Models::Brief::ATC_DELAYS_UNAVAILABLE) + + expect(hash[:afd][:available]).to be true + expect(hash[:afd][:wfo]).to eq('OKX') + expect(hash[:afd][:text]).to include('SYNOPSIS') + end + + it 'serializes round-trip through JSON without raising' do + json = Skywatch.brief(airport: 'KCDW').to_json + parsed = JSON.parse(json) + expect(parsed['aim_section']).to eq('7-1-5') + end +end +``` + +- [ ] **Step 2: Run test to verify it passes (the entire stack should already be in place)** + +```bash +bundle exec rspec spec/brief/integration_spec.rb +``` + +Expected: pass. + +- [ ] **Step 3: Run the full suite** + +```bash +bundle exec rspec +``` + +Expected: all examples pass (371 baseline + new tests). + +- [ ] **Step 4: Run rubocop** + +```bash +bundle exec rubocop +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add spec/brief/integration_spec.rb +git commit -m "test(brief): add integration spec snapshotting full envelope shape" +``` + +--- + +## Task 10: Update CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add the brief command to the CLI usage block** + +Open `CLAUDE.md`. Find the `## CLI Usage` block. Just before the `skywatch radar track UAL1234` line, insert: + +``` +skywatch brief KCDW +``` + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: document 'skywatch brief AIRPORT' command" +``` + +--- + +## Task 11: File follow-up GitHub issues + +**Files:** none modified — uses `gh`. + +The spec defers six items to follow-up issues. File them now (after spec is locked, before implementation merges) so the slot reasons in the brief envelope can be referenced from the issue tracker if anyone asks. + +- [ ] **Step 1: Confirm `gh` auth works** + +```bash +gh auth status +``` + +Expected: "Logged in to github.com as ..." + +- [ ] **Step 2: File the route-input issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: support route input (from:/to:)" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design (see [`docs/superpowers/specs/2026-04-27-skywatch-brief-design.md`](docs/superpowers/specs/2026-04-27-skywatch-brief-design.md)). + +The MVP takes a single airport. Real 1-800-WX-BRIEF calls take a route (departure → destination). This issue covers extending Skywatch.brief to accept `from:` / `to:` (or an n-airport list), populating the currently-static `enroute_forecast` slot with TAFs / PIREPs / area products along the corridor. + +**Acceptance:** `Skywatch.brief(from: "KCDW", to: "KACK")` returns a Brief whose `enroute_forecast` slot is `available: true` with corridor data. +EOF +)" +``` + +- [ ] **Step 3: File the coordinate-input issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: support coordinate input (at: [lat, lon])" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design. + +The MVP takes only `airport:`. For non-airport points, a pilot would want `Skywatch.brief(at: [lat, lon])`. Implementation needs a nearest-METAR / nearest-TAF lookup (we have METAR coords, but no station-search index yet), and must decide what to do when the nearest station is far enough away that its METAR isn't representative. + +**Acceptance:** `Skywatch.brief(at: [40.688, -74.174])` returns a Brief whose data slots are populated from the nearest reporting stations, with explicit notes when those stations are >25 nm from the requested point. +EOF +)" +``` + +- [ ] **Step 4: File the ETD-aware issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: support ETD (departing_at:)" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design. + +The MVP returns a "now" snapshot. A real brief is for a specific departure window. This issue covers `Skywatch.brief(airport:, departing_at: Time.parse(...))`, which forces decisions about TAF group selection (which FROM/BECMG/TEMPO is active at ETD?), winds-aloft interpolation between forecast hours, and how AIM 7-1-5's "destination forecast within one hour before/after ETA" rule should be honored. + +**Acceptance:** `Skywatch.brief(airport: "KCDW", departing_at: 2.hours.from_now)` returns a Brief whose `destination_forecast` slot reflects the TAF group active at ETD, and `winds_aloft` reflects the forecast hour closest to ETD. +EOF +)" +``` + +- [ ] **Step 5: File the AFD synopsis-extraction issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: extract a real synopsis from AFD text" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design. + +The MVP hardcodes `synopsis: { available: false, reason: "no synopsis source — see afd slot" }` and exposes the full AFD text as a supplementary slot. AFDs *sometimes* have a labeled "SYNOPSIS..." sub-paragraph; this issue covers parsing it out and populating the `synopsis` slot with structured `{ available: true, text: ... }`. + +The risk is brittle parsing — AFD formatting varies by WFO and over time. Investigate before committing to a parser. + +**Acceptance:** When AFD text contains a clearly-marked SYNOPSIS section, `Skywatch.brief(...).synopsis` is `{ available: true, text: ... }`. When the AFD is unparseable, the slot stays `available: false` with a reason explaining why. +EOF +)" +``` + +- [ ] **Step 6: File the text-formatter issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: human text formatter (--format text)" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design. + +The MVP is JSON-only. Once validation confirms the LLM-briefer thesis, add a human-readable text formatter consumable in a terminal — likely briefer-cadence one-liners per AIM 7-1-5 element, similar to the existing `Nimbus::Formatters::Text.format_convection` style. + +**Acceptance:** `skywatch brief KCDW --format text` prints a human-readable brief that follows the AIM 7-1-5 sequence and uses briefer-cadence phrasing. +EOF +)" +``` + +- [ ] **Step 7: File the parallelization issue** + +```bash +gh issue create --repo jayrav13/skywatch \ + --title "Skywatch.brief: parallelize composer fetches if latency exceeds 5s" \ + --body "$(cat <<'EOF' +Deferred from the Skywatch.brief MVP design. + +The MVP composer fetches sequentially — METAR → WFO lookup → 8 independent fetches. If real-world brief latency exceeds ~5s, parallelize the 8 independent fetches with Ruby threads + Faraday. Ruby threading + IO is well-behaved here; the gating concern is keeping the cache layer thread-safe (it already uses a Mutex internally). + +**Acceptance:** `Skywatch.brief("KCDW")` p95 stays under 3s on a typical network. No correctness changes — same envelope shape, same error handling. +EOF +)" +``` + +- [ ] **Step 8: List the filed issues to confirm** + +```bash +gh issue list --repo jayrav13/skywatch --search "Skywatch.brief in:title" --state open +``` + +Expected: 6 issues listed. + +(No commit for this task — issues are external to the repo.) + +--- + +## Task 12: Capture validation report + open PR + +**Files:** +- Create: `docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md` + +This is the validation deliverable. Capture three real briefs, run the canonical 7-question protocol against each via Claude (paste-into-claude.ai is acceptable for MVP — automation is a deferred enhancement), and commit the results. + +- [ ] **Step 1: Capture three briefs** + +Pick three airports based on real conditions at validation time. Save each brief to a tempfile: + +```bash +exe/skywatch brief KCDW > /tmp/brief_kcdw.json +# Example IFR airport — pick from current METARs at validation time: +exe/skywatch brief KSFO > /tmp/brief_ksfo.json +# Example active-convective airport — pick from current SPC convective outlook: +exe/skywatch brief KICT > /tmp/brief_kict.json +``` + +If KSFO/KICT are not actually IFR / under convective alerts at the time of validation, substitute airports that are. The point is hitting the three scenario types (VFR-clear, IFR, active convection). + +Inspect each: + +```bash +jq '.adverse_conditions.items | length, .vfr_not_recommended.category' /tmp/brief_*.json +``` + +- [ ] **Step 2: Run the canonical 7-question protocol against each brief** + +For each captured brief, paste the JSON into a fresh Claude.ai conversation with the following prompt template: + +``` +Below is a JSON-shaped pre-flight weather brief for [AIRPORT]. It is shaped after FAA AIM 7-1-5 (Standard Preflight Briefing). Treat it as the only data source you have access to. + +[paste JSON] + +Please answer these 7 questions: +1. Is VFR flight recommended for this airport right now? +2. What's the synopsis / weather pattern in the area? +3. What are the current conditions on the field? +4. What's the destination/terminal forecast? +5. What are the winds and temperature at 6000 ft? +6. What adverse conditions should I worry about? +7. What's NOT in this brief that I'd need to get from elsewhere before I fly? +``` + +Save Claude's responses to `/tmp/brief_kcdw_responses.md`, `/tmp/brief_ksfo_responses.md`, `/tmp/brief_kict_responses.md`. + +- [ ] **Step 3: Write the validation report** + +Save as `docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md`: + +```markdown +# Skywatch.brief — validation results + +**Date:** 2026-04-27 +**Spec:** [`2026-04-27-skywatch-brief-design.md`](2026-04-27-skywatch-brief-design.md) + +## Protocol + +Three airport scenarios. For each, the captured `skywatch brief AIRPORT` JSON was pasted into a fresh Claude.ai conversation along with the canonical 7-question prompt. Pilot-grade judgment of substantive correctness on Q1–Q6; explicit anti-hallucination check on Q7 (LLM must identify NOTAMs and ATC delays as not-in-brief). + +**Pass criteria:** +- Q1–Q6 substantively correct on at least 2 of 3 scenarios +- Q7 anti-hallucination clean on **all 3 scenarios** + +## Scenario 1: KCDW — VFR-clear + +**Captured at:** [TIMESTAMP from /tmp/brief_kcdw.json fetched_at] +**Conditions summary:** [one line — what was the weather] + +### Brief JSON + +```json +[paste contents of /tmp/brief_kcdw.json — the full envelope] +``` + +### LLM responses + +[paste contents of /tmp/brief_kcdw_responses.md] + +### Judgment + +| Q | Pass / fail | Notes | +|---|---|---| +| 1 | | | +| 2 | | | +| 3 | | | +| 4 | | | +| 5 | | | +| 6 | | | +| 7 | | | + +## Scenario 2: [IFR airport] — IFR + +[same structure as scenario 1] + +## Scenario 3: [convective airport] — active convection + +[same structure as scenario 1] + +## Verdict + +**Pass / fail:** [verdict] + +**Q1–Q6 substantive correctness:** [N of 3 scenarios passed] +**Q7 anti-hallucination:** [N of 3 scenarios clean] + +**Discussion:** [a few sentences. What worked, what didn't, what surprised us. If pass: which expansion direction is most justified next? If fail: was it data-shape or data-quality, and what's the right re-rank?] +``` + +Fill in the bracketed sections with real data from steps 1–2. + +- [ ] **Step 4: Run final pre-PR checks** + +```bash +bundle exec rspec +bundle exec rubocop +``` + +Expected: both clean. + +- [ ] **Step 5: Commit the validation report** + +```bash +git add docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md +git commit -m "docs(brief): capture validation results — 7-question protocol on 3 scenarios" +``` + +- [ ] **Step 6: Push the branch and open the PR** + +```bash +git push -u origin skywatch-brief +gh pr create --repo jayrav13/skywatch \ + --title "feat: Skywatch.brief — AIM 7-1-5 weather brief composition layer" \ + --body "$(cat <<'EOF' +## Summary + +- Adds `Skywatch.brief(airport:)` and `skywatch brief AIRPORT` — a single composition layer over existing Briefer + Nimbus primitives that produces an AIM 7-1-5-shaped weather brief for a single airport. +- Six slots fillable in MVP, four hardcoded-unavailable (synopsis, en route, NOTAMs, ATC delays — explicit per the spec), one supplementary (AFD). +- METAR is the only hard-fail; every other source degrades to `{ available: false, reason: ... }`. Adverse-conditions slot tracks per-source `partial_failures`. +- Bundled validation report against the canonical 7-question protocol (3 scenarios, paste-into-Claude). Verdict in `docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md`. + +Spec: [`docs/superpowers/specs/2026-04-27-skywatch-brief-design.md`](docs/superpowers/specs/2026-04-27-skywatch-brief-design.md). + +## Test plan + +- [ ] `bundle exec rspec` — all green +- [ ] `bundle exec rubocop` — clean +- [ ] `exe/skywatch brief KCDW` — JSON to stdout +- [ ] `exe/skywatch brief KZZZ` — error to stderr, non-zero exit +- [ ] Validation report committed and shows pass/fail verdict +- [ ] All 6 follow-up issues filed and linked from the spec doc + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. + +- [ ] **Step 7: Confirm CI passes** + +Watch the PR's checks. Once green, the implementation is done. + +--- + +## Self-Review + +Run after the plan is fully written. + +**1. Spec coverage:** +- [x] Goal & validation thesis — Task 12 captures the validation report +- [x] Scope (in for MVP) — Tasks 4 (envelope), 5+6 (composer), 7 (top-level API), 8 (CLI), 12 (validation report) +- [x] Out of scope (deferred) — Task 11 files the 6 follow-up issues +- [x] AIM 7-1-5 anchor — Task 4 hardcodes `aim_section: '7-1-5'` +- [x] Architecture (composition only, no new sources) — Tasks 2–6 use only existing sources +- [x] Two infra additions (lat/lon from METAR, WFO from /points) — Task 2 +- [x] File layout — matches Tasks 2–8 exactly +- [x] Top-level shape with 9 AIM slots + supplementary AFD — Task 4 +- [x] Uniform `{ available: bool, ... }` contract — Task 4 spec line and Task 6 wrap helper +- [x] Slot specifications (each slot's success/failure shape) — Tasks 4–6 cover all +- [x] Composition logic (METAR-hard-fail, WFO lookup, sequential fetches) — Task 5/6 +- [x] Locators and filters table — Tasks 2 (locator), 3 (filter) +- [x] VFR-not-recommended rule — Task 5 `build_vfr` +- [x] Per-slot error wrapping — Task 6 `wrap` and `attempt` +- [x] Caching — Task 2 wraps the `/points` client in `Shared::Cache` with WFO_TTL = 1 day +- [x] CLI shape — Task 8 (no `--format text` for MVP, JSON only) +- [x] Error handling table (hard-fail METAR, partial_failures on adverse) — Tasks 5, 6 +- [x] Three-layer testing strategy — unit (Tasks 2–6), integration (Task 9), validation report (Task 12) +- [x] Three scenarios + 7-question protocol + acceptance criteria — Task 12 +- [x] Follow-up issues — Task 11 files all 6 + +**2. Placeholder scan:** No "TBD" / "TODO" / "implement later" in any code or test block. Bracketed `[TIMESTAMP]` / `[IFR airport]` placeholders in the validation report template are intentional — they get filled with real data at Task 12, by definition. + +**3. Type consistency:** +- `AirportLocator.coordinates_from_metar(metar) → [lat, lon]` — used in Task 5 step 3 ✓ +- `AirportLocator.wfo_for(lat, lon) → "OKX"` — used in Task 5/6 ✓ +- `AdverseFilter.covers?(product, lat, lon) → bool` — used in Task 5/6 `build_adverse` ✓ +- `AdverseFilter.within(items, lat:, lon:, radius_nm:) → array` — used in Task 5/6 `build_adverse` ✓ +- `AdverseFilter.partition_pireps(pireps) → { urgent:, informational: }` — used in Task 5/6 `compose` ✓ +- `Composer.new(metar_source:, taf_source:, ..., storm_source:)` — same kwargs in Task 5 spec, Task 5 impl, Task 6 impl ✓ +- `Composer#compose(airport:) → Brief` — same signature throughout ✓ +- `Brief.new(airport:, coordinates:, wfo:, fetched_at:, adverse_conditions:, vfr_not_recommended:, current_conditions:, destination_forecast:, winds_aloft:, afd:)` — Task 4 impl matches Task 5/6 callers ✓ +- `Brief#to_h` includes the 9 AIM slots + `afd` + envelope metadata — Task 4 impl matches integration spec assertions in Task 9 ✓ +- Constants: `SYNOPSIS_UNAVAILABLE`, `ENROUTE_UNAVAILABLE`, `NOTAMS_UNAVAILABLE`, `ATC_DELAYS_UNAVAILABLE` — defined Task 4, asserted Task 9 ✓ + +All checks pass. From 0cf689ae36dad468f66da8475d5d51171ff12fae Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:26:54 -0400 Subject: [PATCH 03/13] test(brief): add NWS /points fixture for KCDW --- spec/fixtures/nws_points/kcdw.json | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 spec/fixtures/nws_points/kcdw.json diff --git a/spec/fixtures/nws_points/kcdw.json b/spec/fixtures/nws_points/kcdw.json new file mode 100644 index 0000000..a49e529 --- /dev/null +++ b/spec/fixtures/nws_points/kcdw.json @@ -0,0 +1,36 @@ +{ + "@context": ["https://geojson.org/geojson-ld/geojson-context.jsonld"], + "id": "https://api.weather.gov/points/40.8751,-74.2814", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.2814, 40.8751] + }, + "properties": { + "@id": "https://api.weather.gov/points/40.8751,-74.2814", + "@type": "wx:Point", + "cwa": "OKX", + "forecastOffice": "https://api.weather.gov/offices/OKX", + "gridId": "OKX", + "gridX": 33, + "gridY": 38, + "forecast": "https://api.weather.gov/gridpoints/OKX/33,38/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/OKX/33,38/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/OKX/33,38", + "observationStations": "https://api.weather.gov/gridpoints/OKX/33,38/stations", + "relativeLocation": { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-74.262, 40.847]}, + "properties": { + "city": "Caldwell", + "state": "NJ", + "distance": {"unitCode": "wmoUnit:m", "value": 3500} + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/NJZ103", + "county": "https://api.weather.gov/zones/county/NJC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/NJZ103", + "timeZone": "America/New_York", + "radarStation": "KDIX" + } +} From 3328573d1f330badc291805844e304f827d2b257 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:30:01 -0400 Subject: [PATCH 04/13] feat(brief): add AirportLocator (METAR coords + NWS WFO lookup) --- lib/skywatch.rb | 1 + .../brief/analysis/airport_locator.rb | 38 +++++++++++++++ spec/brief/analysis/airport_locator_spec.rb | 46 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 lib/skywatch/brief/analysis/airport_locator.rb create mode 100644 spec/brief/analysis/airport_locator_spec.rb diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 4414e02..642b357 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -40,6 +40,7 @@ require_relative 'skywatch/nimbus/sources/storm_report' require_relative 'skywatch/nimbus/sources/alerts' require_relative 'skywatch/nimbus/formatters/text' +require_relative 'skywatch/brief/analysis/airport_locator' module Skywatch class << self diff --git a/lib/skywatch/brief/analysis/airport_locator.rb b/lib/skywatch/brief/analysis/airport_locator.rb new file mode 100644 index 0000000..1f0acc1 --- /dev/null +++ b/lib/skywatch/brief/analysis/airport_locator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Skywatch + module Brief + module Analysis + module AirportLocator + WFO_TTL = 86_400 + + def self.coordinates_from_metar(metar) + raise Skywatch::Error, "no coordinates on METAR for #{metar.station_id}" if + metar.latitude.nil? || metar.longitude.nil? + + [metar.latitude, metar.longitude] + end + + def self.wfo_for(lat, lon) + data = points_client.get("/points/#{lat},#{lon}", {}, ttl: WFO_TTL) + cwa = data.dig('properties', 'cwa') + raise Skywatch::Error, "no WFO on /points response for #{lat},#{lon}" if cwa.nil? || cwa.empty? + + cwa + rescue Skywatch::ApiError => e + raise Skywatch::Error, "WFO lookup failed for #{lat},#{lon}: #{e.message}" + end + + def self.points_client + @points_client ||= Skywatch::Shared::Cache.new( + client: Skywatch::Shared::Http.new(base_url: 'https://api.weather.gov') + ) + end + + def self.reset! + @points_client = nil + end + end + end + end +end diff --git a/spec/brief/analysis/airport_locator_spec.rb b/spec/brief/analysis/airport_locator_spec.rb new file mode 100644 index 0000000..6c0575d --- /dev/null +++ b/spec/brief/analysis/airport_locator_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::AirportLocator do + describe '.coordinates_from_metar' do + it 'returns [lat, lon] from a METAR' do + metar = Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282 + ) + expect(described_class.coordinates_from_metar(metar)).to eq([40.875, -74.282]) + end + + it 'raises when METAR has no coordinates' do + metar = Skywatch::Briefer::Models::Metar.new(station_id: 'KCDW') + expect { described_class.coordinates_from_metar(metar) } + .to raise_error(Skywatch::Error, /no coordinates on METAR/) + end + end + + describe '.wfo_for' do + let(:fixture) { File.read(File.expand_path('../../fixtures/nws_points/kcdw.json', __dir__)) } + + before do + described_class.reset! + stub_request(:get, %r{https://api\.weather\.gov/points/40\.875,-74\.282}) + .to_return(status: 200, body: fixture, headers: { 'Content-Type' => 'application/geo+json' }) + end + + it 'returns the WFO id for a coordinate' do + expect(described_class.wfo_for(40.875, -74.282)).to eq('OKX') + end + + it 'caches subsequent calls within TTL' do + described_class.wfo_for(40.875, -74.282) + described_class.wfo_for(40.875, -74.282) + expect(WebMock).to have_requested(:get, %r{points/40\.875,-74\.282}).once + end + + it 'raises Skywatch::Error when the points endpoint fails' do + stub_request(:get, %r{https://api\.weather\.gov/points/0\.0,0\.0}) + .to_return(status: 500, body: 'boom') + expect { described_class.wfo_for(0.0, 0.0) }.to raise_error(Skywatch::Error) + end + end +end From 955a6b91cc0fd88dbb7086bc8ee275ca051a952f Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:35:04 -0400 Subject: [PATCH 05/13] feat(brief): add AdverseFilter (polygon, distance, urgency partition) --- lib/skywatch.rb | 1 + lib/skywatch/brief/analysis/adverse_filter.rb | 31 ++++++++ spec/brief/analysis/adverse_filter_spec.rb | 72 +++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 lib/skywatch/brief/analysis/adverse_filter.rb create mode 100644 spec/brief/analysis/adverse_filter_spec.rb diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 642b357..8ddf138 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -41,6 +41,7 @@ require_relative 'skywatch/nimbus/sources/alerts' require_relative 'skywatch/nimbus/formatters/text' require_relative 'skywatch/brief/analysis/airport_locator' +require_relative 'skywatch/brief/analysis/adverse_filter' module Skywatch class << self diff --git a/lib/skywatch/brief/analysis/adverse_filter.rb b/lib/skywatch/brief/analysis/adverse_filter.rb new file mode 100644 index 0000000..b83d3ef --- /dev/null +++ b/lib/skywatch/brief/analysis/adverse_filter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Skywatch + module Brief + module Analysis + module AdverseFilter + def self.covers?(product, lat, lon) + polygon = product.polygon + return false if polygon.nil? + + polygon.contains?(Skywatch::Shared::Geometry.point(lat, lon)) + end + + def self.within(items, lat:, lon:, radius_nm:) + items.select do |item| + next false if item.latitude.nil? || item.longitude.nil? + + Skywatch::Radar::Analysis::Proximity.distance_nm(lat, lon, item.latitude, item.longitude) <= radius_nm + end + end + + def self.partition_pireps(pireps) + urgent, informational = pireps.partition do |p| + p.pirep_type.to_s.include?('urgent') + end + { urgent: urgent, informational: informational } + end + end + end + end +end diff --git a/spec/brief/analysis/adverse_filter_spec.rb b/spec/brief/analysis/adverse_filter_spec.rb new file mode 100644 index 0000000..2fe89f3 --- /dev/null +++ b/spec/brief/analysis/adverse_filter_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::AdverseFilter do + describe '.covers?' do + let(:square_around_kcdw) do + coords = [ + Skywatch::Shared::Position.new(lat: 41.0, lon: -75.0), + Skywatch::Shared::Position.new(lat: 41.0, lon: -74.0), + Skywatch::Shared::Position.new(lat: 40.0, lon: -74.0), + Skywatch::Shared::Position.new(lat: 40.0, lon: -75.0), + Skywatch::Shared::Position.new(lat: 41.0, lon: -75.0) + ] + Skywatch::Briefer::Models::Sigmet.new(coords: coords) + end + + it 'returns true when the airport is inside the polygon' do + expect(described_class.covers?(square_around_kcdw, 40.875, -74.282)).to be true + end + + it 'returns false when the airport is outside the polygon' do + expect(described_class.covers?(square_around_kcdw, 35.0, -100.0)).to be false + end + + it 'returns false when the product has no polygon' do + degenerate = Skywatch::Briefer::Models::Sigmet.new(coords: []) + expect(described_class.covers?(degenerate, 40.875, -74.282)).to be false + end + end + + describe '.within' do + let(:near_pirep) do + Skywatch::Briefer::Models::Pirep.new(latitude: 40.9, longitude: -74.3) + end + let(:far_pirep) do + Skywatch::Briefer::Models::Pirep.new(latitude: 30.0, longitude: -90.0) + end + + it 'keeps items within radius' do + result = described_class.within([near_pirep, far_pirep], lat: 40.875, lon: -74.282, radius_nm: 100) + expect(result).to eq([near_pirep]) + end + + it 'returns empty when nothing is in range' do + expect(described_class.within([far_pirep], lat: 40.875, lon: -74.282, radius_nm: 100)).to eq([]) + end + + it 'skips items missing latitude or longitude' do + headless = Skywatch::Briefer::Models::Pirep.new(latitude: nil, longitude: nil) + expect(described_class.within([headless], lat: 40.0, lon: -74.0, radius_nm: 100)).to eq([]) + end + end + + describe '.partition_pireps' do + let(:routine) { Skywatch::Briefer::Models::Pirep.new(pirep_type: :pirep) } + let(:urgent) { Skywatch::Briefer::Models::Pirep.new(pirep_type: :'urgent pirep') } + + it 'splits urgent (any pirep_type containing "urgent") from informational' do + result = described_class.partition_pireps([routine, urgent]) + expect(result[:urgent]).to eq([urgent]) + expect(result[:informational]).to eq([routine]) + end + + it 'treats nil pirep_type as informational' do + blank = Skywatch::Briefer::Models::Pirep.new(pirep_type: nil) + result = described_class.partition_pireps([blank]) + expect(result[:urgent]).to eq([]) + expect(result[:informational]).to eq([blank]) + end + end +end From 7654045f6752b116a519158b042df21a7f35a28a Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:37:40 -0400 Subject: [PATCH 06/13] feat(brief): add Brief model with AIM 7-1-5 envelope --- lib/skywatch.rb | 1 + lib/skywatch/brief/models/brief.rb | 81 ++++++++++++++++++++++++++++ spec/brief/models/brief_spec.rb | 84 ++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 lib/skywatch/brief/models/brief.rb create mode 100644 spec/brief/models/brief_spec.rb diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 8ddf138..8564410 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -42,6 +42,7 @@ require_relative 'skywatch/nimbus/formatters/text' require_relative 'skywatch/brief/analysis/airport_locator' require_relative 'skywatch/brief/analysis/adverse_filter' +require_relative 'skywatch/brief/models/brief' module Skywatch class << self diff --git a/lib/skywatch/brief/models/brief.rb b/lib/skywatch/brief/models/brief.rb new file mode 100644 index 0000000..1c25e6f --- /dev/null +++ b/lib/skywatch/brief/models/brief.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'time' +require 'json' + +module Skywatch + module Brief + module Models + class Brief + AIM_SECTION = '7-1-5' + + SYNOPSIS_UNAVAILABLE = { + available: false, + reason: 'no synopsis source in skywatch — see afd slot' + }.freeze + + ENROUTE_UNAVAILABLE = { + available: false, + reason: 'single-point brief; route input deferred from MVP' + }.freeze + + NOTAMS_UNAVAILABLE = { + available: false, + reason: 'NOTAMs not in skywatch yet — Sectional domain not yet built' + }.freeze + + ATC_DELAYS_UNAVAILABLE = { + available: false, + reason: 'ATC delays not in skywatch yet — no source' + }.freeze + + attr_reader :airport, :coordinates, :wfo, :fetched_at, + :adverse_conditions, :vfr_not_recommended, + :current_conditions, :destination_forecast, :winds_aloft, :afd + + # rubocop:disable Metrics/ParameterLists + def initialize(airport:, coordinates:, wfo:, fetched_at:, + adverse_conditions:, vfr_not_recommended:, + current_conditions:, destination_forecast:, winds_aloft:, afd:) + @airport = airport + @coordinates = coordinates + @wfo = wfo + @fetched_at = fetched_at + @adverse_conditions = adverse_conditions + @vfr_not_recommended = vfr_not_recommended + @current_conditions = current_conditions + @destination_forecast = destination_forecast + @winds_aloft = winds_aloft + @afd = afd + end + # rubocop:enable Metrics/ParameterLists + + # rubocop:disable Metrics/MethodLength + def to_h + { + airport: airport, + coordinates: coordinates, + wfo: wfo, + fetched_at: fetched_at&.iso8601, + aim_section: AIM_SECTION, + adverse_conditions: adverse_conditions, + vfr_not_recommended: vfr_not_recommended, + synopsis: SYNOPSIS_UNAVAILABLE, + current_conditions: current_conditions, + enroute_forecast: ENROUTE_UNAVAILABLE, + destination_forecast: destination_forecast, + winds_aloft: winds_aloft, + notams: NOTAMS_UNAVAILABLE, + atc_delays: ATC_DELAYS_UNAVAILABLE, + afd: afd + } + end + # rubocop:enable Metrics/MethodLength + + def to_json(*) + to_h.to_json(*) + end + end + end + end +end diff --git a/spec/brief/models/brief_spec.rb b/spec/brief/models/brief_spec.rb new file mode 100644 index 0000000..aedb6f2 --- /dev/null +++ b/spec/brief/models/brief_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Models::Brief do + let(:slot_available) { { available: true, payload: 'ok' } } + let(:slot_unavailable) { { available: false, reason: 'fetch failed: ...' } } + + let(:brief) do + described_class.new( + airport: 'KCDW', + coordinates: [40.875, -74.282], + wfo: 'OKX', + fetched_at: Time.utc(2026, 4, 27, 18, 32, 14), + adverse_conditions: { available: true, items: [], partial_failures: [] }, + vfr_not_recommended: { available: true, vfr_not_recommended: false, category: 'VFR', + explanation: 'VFR conditions' }, + current_conditions: { available: true, metar: { station_id: 'KCDW' }, pireps: [] }, + destination_forecast: slot_available, + winds_aloft: slot_available, + afd: slot_available + ) + end + + it 'serializes the AIM-9 envelope in canonical order' do + hash = brief.to_h + expect(hash[:airport]).to eq('KCDW') + expect(hash[:coordinates]).to eq([40.875, -74.282]) + expect(hash[:wfo]).to eq('OKX') + expect(hash[:fetched_at]).to eq('2026-04-27T18:32:14Z') + expect(hash[:aim_section]).to eq('7-1-5') + end + + it 'includes every AIM 7-1-5 slot' do + hash = brief.to_h + %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays].each do |slot| + expect(hash).to include(slot), "missing slot: #{slot}" + end + end + + it 'includes the supplementary afd slot' do + expect(brief.to_h).to include(:afd) + end + + it 'hardcodes synopsis as unavailable with afd-pointer reason' do + expect(brief.to_h[:synopsis]).to eq( + available: false, + reason: 'no synopsis source in skywatch — see afd slot' + ) + end + + it 'hardcodes enroute_forecast as unavailable with route-deferred reason' do + expect(brief.to_h[:enroute_forecast]).to eq( + available: false, + reason: 'single-point brief; route input deferred from MVP' + ) + end + + it 'hardcodes notams as unavailable with sectional-domain reason' do + expect(brief.to_h[:notams]).to eq( + available: false, + reason: 'NOTAMs not in skywatch yet — Sectional domain not yet built' + ) + end + + it 'hardcodes atc_delays as unavailable' do + expect(brief.to_h[:atc_delays]).to eq( + available: false, + reason: 'ATC delays not in skywatch yet — no source' + ) + end + + it 'preserves the AIM-9 slots in canonical order' do + keys = brief.to_h.keys + aim9 = %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays] + expect(keys & aim9).to eq(aim9) + end + + it 'serializes to JSON' do + expect { JSON.parse(brief.to_json) }.not_to raise_error + end +end From 34368295988e0f2b05b2b011002d0a61f7f203e8 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:42:14 -0400 Subject: [PATCH 07/13] feat(brief): add Composer happy-path --- lib/skywatch.rb | 1 + lib/skywatch/brief/analysis/composer.rb | 143 ++++++++++++++++++++++++ spec/brief/analysis/composer_spec.rb | 106 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 lib/skywatch/brief/analysis/composer.rb create mode 100644 spec/brief/analysis/composer_spec.rb diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 8564410..7c08512 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -43,6 +43,7 @@ require_relative 'skywatch/brief/analysis/airport_locator' require_relative 'skywatch/brief/analysis/adverse_filter' require_relative 'skywatch/brief/models/brief' +require_relative 'skywatch/brief/analysis/composer' module Skywatch class << self diff --git a/lib/skywatch/brief/analysis/composer.rb b/lib/skywatch/brief/analysis/composer.rb new file mode 100644 index 0000000..7997910 --- /dev/null +++ b/lib/skywatch/brief/analysis/composer.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'time' + +module Skywatch + module Brief + module Analysis + class Composer # rubocop:disable Metrics/ClassLength + ADVERSE_RADIUS_NM = 100 + STORM_REPORT_LOOKBACK_HOURS = 6 + + # rubocop:disable Metrics/ParameterLists + def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, + taf_source: Skywatch::Briefer::Sources::Taf.new, + pirep_source: Skywatch::Briefer::Sources::Pirep.new, + winds_source: Skywatch::Briefer::Sources::WindsAloft.new, + sigmet_source: Skywatch::Briefer::Sources::Sigmet.new, + airmet_source: Skywatch::Briefer::Sources::Airmet.new, + afd_source: Skywatch::Briefer::Sources::Afd.new, + alerts_source: Skywatch::Nimbus::Sources::Alerts.new, + storm_source: Skywatch::Nimbus::Sources::StormReport.new) + @metar_source = metar_source + @taf_source = taf_source + @pirep_source = pirep_source + @winds_source = winds_source + @sigmet_source = sigmet_source + @airmet_source = airmet_source + @afd_source = afd_source + @alerts_source = alerts_source + @storm_source = storm_source + end + # rubocop:enable Metrics/ParameterLists + + def compose(airport:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + metar = fetch_metar_or_raise(airport) + lat, lon = AirportLocator.coordinates_from_metar(metar) + wfo = AirportLocator.wfo_for(lat, lon) + + pireps = @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) + partitioned = AdverseFilter.partition_pireps(pireps) + + Models::Brief.new( + airport: airport.upcase, + coordinates: [lat, lon], + wfo: wfo, + fetched_at: Time.now.utc, + adverse_conditions: build_adverse(lat: lat, lon: lon, urgent_pireps: partitioned[:urgent]), + vfr_not_recommended: build_vfr(metar), + current_conditions: build_current(metar: metar, pireps: partitioned[:informational]), + destination_forecast: build_destination(airport), + winds_aloft: build_winds(airport), + afd: build_afd(wfo) + ) + end + + private + + def fetch_metar_or_raise(airport) + metars = @metar_source.fetch(airport) + raise Skywatch::Error, "no METAR for #{airport.upcase}" if metars.empty? + + metars.first + end + + def build_adverse(lat:, lon:, urgent_pireps:) + sigmets = @sigmet_source.fetch.select { |s| AdverseFilter.covers?(s, lat, lon) } + airmets = @airmet_source.fetch.select { |a| AdverseFilter.covers?(a, lat, lon) } + alerts = @alerts_source.fetch(at: [lat, lon]) + all_storms = @storm_source.fetch + recent = recent_storms(all_storms) + near_storms = AdverseFilter.within(recent, lat: lat, lon: lon, radius_nm: ADVERSE_RADIUS_NM) + + items = adverse_items(sigmets, airmets, urgent_pireps, alerts, near_storms) + { available: true, items: items, partial_failures: [] } + end + + def adverse_items(sigmets, airmets, urgent_pireps, alerts, near_storms) # rubocop:disable Metrics/AbcSize + items = [] + items.concat(sigmets.map { |s| { kind: 'sigmet' }.merge(s.to_h) }) + items.concat(airmets.map { |a| { kind: 'airmet' }.merge(a.to_h) }) + items.concat(urgent_pireps.map { |p| { kind: 'pirep' }.merge(p.to_h) }) + items.concat(alerts.map { |a| { kind: 'convective_alert' }.merge(a.to_h) }) + items.concat(near_storms.map { |s| { kind: 'storm_report' }.merge(s.to_h) }) + items + end + + def recent_storms(storms) + cutoff = Time.now.utc - (STORM_REPORT_LOOKBACK_HOURS * 3600) + storms.select { |s| s.time && s.time >= cutoff } + end + + def build_vfr(metar) # rubocop:disable Metrics/MethodLength + category = metar.flight_category + case category + when :lifr, :ifr + { available: true, vfr_not_recommended: true, category: category.to_s.upcase, + explanation: explanation_for(metar) } + when :mvfr + { available: true, vfr_not_recommended: false, category: 'MVFR', + explanation: "marginal — #{explanation_for(metar)}" } + else + { available: true, vfr_not_recommended: false, category: 'VFR', + explanation: 'VFR conditions' } + end + end + + def explanation_for(metar) + parts = [] + parts << "ceiling #{metar.ceiling_ft} ft" if metar.ceiling_ft + parts << "vis #{metar.visibility_sm} SM" if metar.visibility_sm + parts.empty? ? 'see METAR' : parts.join(', ') + end + + def build_current(metar:, pireps:) + { available: true, metar: metar.to_h, pireps: pireps.map(&:to_h) } + end + + def build_destination(airport) + tafs = @taf_source.fetch(airport) + if tafs.empty? + { available: false, reason: "no TAF for #{airport.upcase}" } + else + { available: true, taf: tafs.first.to_h } + end + end + + def build_winds(airport) + forecasts = @winds_source.fetch(airport) + if forecasts.empty? + { available: false, reason: "no winds aloft for #{airport.upcase}" } + else + { available: true, station: airport.upcase, forecasts: forecasts.map(&:to_h) } + end + end + + def build_afd(wfo) + afd = @afd_source.fetch(wfo) + { available: true, wfo: afd.wfo, text: afd.text, issued_at: afd.issued_at&.iso8601 } + end + end + end + end +end diff --git a/spec/brief/analysis/composer_spec.rb b/spec/brief/analysis/composer_spec.rb new file mode 100644 index 0000000..cabedfd --- /dev/null +++ b/spec/brief/analysis/composer_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::Composer do + let(:metar) do + Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282, + visibility_sm: 10, sky_condition: [{ cover: :few, base_ft: 5000 }] + ) + end + + let(:metar_source) { instance_double(Skywatch::Briefer::Sources::Metar, fetch: [metar]) } + let(:taf_source) { instance_double(Skywatch::Briefer::Sources::Taf, fetch: []) } + let(:pirep_source) { instance_double(Skywatch::Briefer::Sources::Pirep, fetch: []) } + let(:winds_source) { instance_double(Skywatch::Briefer::Sources::WindsAloft, fetch: []) } + let(:sigmet_source) { instance_double(Skywatch::Briefer::Sources::Sigmet, fetch: []) } + let(:airmet_source) { instance_double(Skywatch::Briefer::Sources::Airmet, fetch: []) } + let(:afd_model) do + Skywatch::Briefer::Models::Afd.new( + wfo: 'OKX', product_name: 'Area Forecast Discussion', + issued_at: Time.utc(2026, 4, 27, 14), text: 'SYNOPSIS...' + ) + end + let(:afd_source) { instance_double(Skywatch::Briefer::Sources::Afd, fetch: afd_model) } + let(:alerts_source) { instance_double(Skywatch::Nimbus::Sources::Alerts, fetch: []) } + let(:storm_source) { instance_double(Skywatch::Nimbus::Sources::StormReport, fetch: []) } + + before do + allow(Skywatch::Brief::Analysis::AirportLocator).to receive(:wfo_for).and_return('OKX') + end + + let(:composer) do + described_class.new( + metar_source: metar_source, taf_source: taf_source, pirep_source: pirep_source, + winds_source: winds_source, sigmet_source: sigmet_source, airmet_source: airmet_source, + afd_source: afd_source, alerts_source: alerts_source, storm_source: storm_source + ) + end + + it 'returns a Brief with metadata populated' do + brief = composer.compose(airport: 'KCDW') + expect(brief).to be_a(Skywatch::Brief::Models::Brief) + expect(brief.airport).to eq('KCDW') + expect(brief.coordinates).to eq([40.875, -74.282]) + expect(brief.wfo).to eq('OKX') + expect(brief.fetched_at).to be_a(Time) + end + + it 'sets adverse_conditions available with empty items when nothing is adverse' do + expect(composer.compose(airport: 'KCDW').adverse_conditions).to eq( + available: true, items: [], partial_failures: [] + ) + end + + it 'sets vfr_not_recommended from FlightCategory' do + slot = composer.compose(airport: 'KCDW').vfr_not_recommended + expect(slot[:available]).to be true + expect(slot[:vfr_not_recommended]).to be false + expect(slot[:category]).to eq('VFR') + expect(slot[:explanation]).to include('VFR') + end + + it 'sets current_conditions with metar and empty pireps' do + slot = composer.compose(airport: 'KCDW').current_conditions + expect(slot[:available]).to be true + expect(slot[:metar][:station_id]).to eq('KCDW') + expect(slot[:pireps]).to eq([]) + end + + it 'sets destination_forecast unavailable when no TAF' do + slot = composer.compose(airport: 'KCDW').destination_forecast + expect(slot[:available]).to be false + expect(slot[:reason]).to include('no TAF') + end + + it 'sets winds_aloft unavailable when no winds-aloft data' do + slot = composer.compose(airport: 'KCDW').winds_aloft + expect(slot[:available]).to be false + expect(slot[:reason]).to include('no winds aloft') + end + + it 'sets afd available when AFD fetch succeeds' do + slot = composer.compose(airport: 'KCDW').afd + expect(slot[:available]).to be true + expect(slot[:wfo]).to eq('OKX') + expect(slot[:text]).to eq('SYNOPSIS...') + end + + it 'classifies LIFR as VFR not recommended' do + metar_lifr = Skywatch::Briefer::Models::Metar.new( + station_id: 'KCDW', latitude: 40.875, longitude: -74.282, + visibility_sm: 0.5, sky_condition: [{ cover: :ovc, base_ft: 200 }] + ) + allow(metar_source).to receive(:fetch).and_return([metar_lifr]) + slot = composer.compose(airport: 'KCDW').vfr_not_recommended + expect(slot[:vfr_not_recommended]).to be true + expect(slot[:category]).to eq('LIFR') + end + + it 'raises when METAR is missing' do + allow(metar_source).to receive(:fetch).and_return([]) + expect { composer.compose(airport: 'KZZZ') } + .to raise_error(Skywatch::Error, /no METAR for KZZZ/) + end +end From 3a8ca942f375eb14008ee833660a2794d5ee7b08 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 21:58:39 -0400 Subject: [PATCH 08/13] feat(brief): wrap composer slots with per-slot error handling Co-Authored-By: Claude Sonnet 4.6 --- lib/skywatch/brief/analysis/composer.rb | 107 ++++++++++++++++-------- spec/brief/analysis/composer_spec.rb | 58 +++++++++++++ 2 files changed, 132 insertions(+), 33 deletions(-) diff --git a/lib/skywatch/brief/analysis/composer.rb b/lib/skywatch/brief/analysis/composer.rb index 7997910..7fdd02b 100644 --- a/lib/skywatch/brief/analysis/composer.rb +++ b/lib/skywatch/brief/analysis/composer.rb @@ -34,22 +34,25 @@ def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, def compose(airport:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize metar = fetch_metar_or_raise(airport) lat, lon = AirportLocator.coordinates_from_metar(metar) - wfo = AirportLocator.wfo_for(lat, lon) + wfo = wrap_wfo(lat, lon) - pireps = @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) - partitioned = AdverseFilter.partition_pireps(pireps) + pirep_attempt = attempt { @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) } + partitioned = AdverseFilter.partition_pireps(pirep_attempt[:value] || []) Models::Brief.new( airport: airport.upcase, coordinates: [lat, lon], wfo: wfo, fetched_at: Time.now.utc, - adverse_conditions: build_adverse(lat: lat, lon: lon, urgent_pireps: partitioned[:urgent]), + adverse_conditions: build_adverse( + lat: lat, lon: lon, urgent_pireps: partitioned[:urgent], pirep_attempt: pirep_attempt + ), vfr_not_recommended: build_vfr(metar), - current_conditions: build_current(metar: metar, pireps: partitioned[:informational]), - destination_forecast: build_destination(airport), - winds_aloft: build_winds(airport), - afd: build_afd(wfo) + current_conditions: build_current(metar: metar, pirep_attempt: pirep_attempt, + informational: partitioned[:informational]), + destination_forecast: wrap('TAF') { build_destination(airport) }, + winds_aloft: wrap('winds aloft') { build_winds(airport) }, + afd: wfo.nil? ? unavailable_afd_for_no_wfo : wrap('AFD') { build_afd(wfo) } ) end @@ -62,27 +65,61 @@ def fetch_metar_or_raise(airport) metars.first end - def build_adverse(lat:, lon:, urgent_pireps:) - sigmets = @sigmet_source.fetch.select { |s| AdverseFilter.covers?(s, lat, lon) } - airmets = @airmet_source.fetch.select { |a| AdverseFilter.covers?(a, lat, lon) } - alerts = @alerts_source.fetch(at: [lat, lon]) - all_storms = @storm_source.fetch - recent = recent_storms(all_storms) - near_storms = AdverseFilter.within(recent, lat: lat, lon: lon, radius_nm: ADVERSE_RADIUS_NM) + def wrap_wfo(lat, lon) + AirportLocator.wfo_for(lat, lon) + rescue StandardError + nil + end + + def unavailable_afd_for_no_wfo + { available: false, reason: 'fetch failed: WFO lookup failed' } + end - items = adverse_items(sigmets, airmets, urgent_pireps, alerts, near_storms) - { available: true, items: items, partial_failures: [] } + def attempt + { value: yield, error: nil } + rescue StandardError => e + { value: nil, error: "#{e.class}: #{e.message}" } end - def adverse_items(sigmets, airmets, urgent_pireps, alerts, near_storms) # rubocop:disable Metrics/AbcSize + def wrap(_label) + yield + rescue StandardError => e + { available: false, reason: "fetch failed: #{e.class}: #{e.message}" } + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def build_adverse(lat:, lon:, urgent_pireps:, pirep_attempt:) + sigmet_attempt = attempt { @sigmet_source.fetch.select { |s| AdverseFilter.covers?(s, lat, lon) } } + airmet_attempt = attempt { @airmet_source.fetch.select { |a| AdverseFilter.covers?(a, lat, lon) } } + alerts_attempt = attempt { @alerts_source.fetch(at: [lat, lon]) } + storm_attempt = attempt do + recent = recent_storms(@storm_source.fetch) + AdverseFilter.within(recent, lat: lat, lon: lon, radius_nm: ADVERSE_RADIUS_NM) + end + + attempts = { + 'sigmet' => sigmet_attempt, 'airmet' => airmet_attempt, + 'pirep' => pirep_attempt, 'convective_alert' => alerts_attempt, + 'storm_report' => storm_attempt + } + partial_failures = attempts.reject { |_, a| a[:error].nil? } + .map { |s, a| { source: s, reason: a[:error] } } + + if partial_failures.size == attempts.size + return { available: false, + reason: "all adverse sources failed: #{partial_failures.map { |f| f[:source] }.join(', ')}" } + end + items = [] - items.concat(sigmets.map { |s| { kind: 'sigmet' }.merge(s.to_h) }) - items.concat(airmets.map { |a| { kind: 'airmet' }.merge(a.to_h) }) + items.concat((sigmet_attempt[:value] || []).map { |s| { kind: 'sigmet' }.merge(s.to_h) }) + items.concat((airmet_attempt[:value] || []).map { |a| { kind: 'airmet' }.merge(a.to_h) }) items.concat(urgent_pireps.map { |p| { kind: 'pirep' }.merge(p.to_h) }) - items.concat(alerts.map { |a| { kind: 'convective_alert' }.merge(a.to_h) }) - items.concat(near_storms.map { |s| { kind: 'storm_report' }.merge(s.to_h) }) - items + items.concat((alerts_attempt[:value] || []).map { |a| { kind: 'convective_alert' }.merge(a.to_h) }) + items.concat((storm_attempt[:value] || []).map { |s| { kind: 'storm_report' }.merge(s.to_h) }) + + { available: true, items: items, partial_failures: partial_failures } end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def recent_storms(storms) cutoff = Time.now.utc - (STORM_REPORT_LOOKBACK_HOURS * 3600) @@ -90,17 +127,16 @@ def recent_storms(storms) end def build_vfr(metar) # rubocop:disable Metrics/MethodLength - category = metar.flight_category - case category + case metar.flight_category when :lifr, :ifr - { available: true, vfr_not_recommended: true, category: category.to_s.upcase, - explanation: explanation_for(metar) } + { available: true, vfr_not_recommended: true, + category: metar.flight_category.to_s.upcase, explanation: explanation_for(metar) } when :mvfr - { available: true, vfr_not_recommended: false, category: 'MVFR', - explanation: "marginal — #{explanation_for(metar)}" } + { available: true, vfr_not_recommended: false, + category: 'MVFR', explanation: "marginal — #{explanation_for(metar)}" } else - { available: true, vfr_not_recommended: false, category: 'VFR', - explanation: 'VFR conditions' } + { available: true, vfr_not_recommended: false, + category: 'VFR', explanation: 'VFR conditions' } end end @@ -111,8 +147,13 @@ def explanation_for(metar) parts.empty? ? 'see METAR' : parts.join(', ') end - def build_current(metar:, pireps:) - { available: true, metar: metar.to_h, pireps: pireps.map(&:to_h) } + def build_current(metar:, pirep_attempt:, informational:) + if pirep_attempt[:error] + { available: true, metar: metar.to_h, pireps: [], + partial_failure: { source: 'pirep', reason: pirep_attempt[:error] } } + else + { available: true, metar: metar.to_h, pireps: informational.map(&:to_h) } + end end def build_destination(airport) diff --git a/spec/brief/analysis/composer_spec.rb b/spec/brief/analysis/composer_spec.rb index cabedfd..7fb74ff 100644 --- a/spec/brief/analysis/composer_spec.rb +++ b/spec/brief/analysis/composer_spec.rb @@ -103,4 +103,62 @@ expect { composer.compose(airport: 'KZZZ') } .to raise_error(Skywatch::Error, /no METAR for KZZZ/) end + + context 'error wrapping' do + it 'sets destination_forecast unavailable when TAF source raises' do + allow(taf_source).to receive(:fetch).and_raise(Skywatch::ApiError, 'HTTP 500') + slot = composer.compose(airport: 'KCDW').destination_forecast + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + expect(slot[:reason]).to include('HTTP 500') + end + + it 'sets winds_aloft unavailable when winds source raises' do + allow(winds_source).to receive(:fetch).and_raise(StandardError, 'boom') + slot = composer.compose(airport: 'KCDW').winds_aloft + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + end + + it 'sets afd unavailable when WFO lookup raises' do + allow(Skywatch::Brief::Analysis::AirportLocator) + .to receive(:wfo_for).and_raise(Skywatch::Error, 'lookup boom') + brief = composer.compose(airport: 'KCDW') + expect(brief.afd[:available]).to be false + expect(brief.afd[:reason]).to include('fetch failed') + expect(brief.wfo).to be_nil + end + + it 'sets afd unavailable when AFD fetch raises' do + allow(afd_source).to receive(:fetch).and_raise(Skywatch::Error, 'no AFD') + slot = composer.compose(airport: 'KCDW').afd + expect(slot[:available]).to be false + expect(slot[:reason]).to include('fetch failed') + end + + it 'records partial_failures on adverse_conditions when one sub-source raises' do + allow(sigmet_source).to receive(:fetch).and_raise(StandardError, 'sigmet boom') + slot = composer.compose(airport: 'KCDW').adverse_conditions + expect(slot[:available]).to be true + expect(slot[:partial_failures]).to contain_exactly( + hash_including(source: 'sigmet', reason: a_string_including('sigmet boom')) + ) + end + + it 'sets adverse_conditions unavailable when every sub-source raises' do + allow(sigmet_source).to receive(:fetch).and_raise(StandardError, 'a') + allow(airmet_source).to receive(:fetch).and_raise(StandardError, 'b') + allow(pirep_source).to receive(:fetch).and_raise(StandardError, 'c') + allow(alerts_source).to receive(:fetch).and_raise(StandardError, 'd') + allow(storm_source).to receive(:fetch).and_raise(StandardError, 'e') + slot = composer.compose(airport: 'KCDW').adverse_conditions + expect(slot[:available]).to be false + expect(slot[:reason]).to include('all adverse sources failed') + end + + it 'still raises hard when METAR fails (no wrapping)' do + allow(metar_source).to receive(:fetch).and_raise(Skywatch::ApiError, 'HTTP 404') + expect { composer.compose(airport: 'KZZZ') }.to raise_error(Skywatch::ApiError) + end + end end From 890ffd61492f245c2911d27464765122393a75f5 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 22:02:14 -0400 Subject: [PATCH 09/13] feat(brief): add Skywatch.brief convenience API --- lib/skywatch.rb | 4 ++++ spec/brief_spec.rb | 14 ++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 spec/brief_spec.rb diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 7c08512..8485407 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -137,6 +137,10 @@ def convection(at:, events: nil) ) end + def brief(airport:) + Brief::Analysis::Composer.new.compose(airport: airport) + end + def crosswind(station_id, runway_heading:) metars = metar(station_id) raise Error, "No METAR available for #{station_id}" if metars.empty? diff --git a/spec/brief_spec.rb b/spec/brief_spec.rb new file mode 100644 index 0000000..8a6b7c0 --- /dev/null +++ b/spec/brief_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch, '.brief' do + it 'composes a Brief for an airport' do + composer = instance_double(Skywatch::Brief::Analysis::Composer) + brief = instance_double(Skywatch::Brief::Models::Brief) + expect(Skywatch::Brief::Analysis::Composer).to receive(:new).and_return(composer) + expect(composer).to receive(:compose).with(airport: 'KCDW').and_return(brief) + + expect(described_class.brief(airport: 'KCDW')).to be(brief) + end +end From 3cc14fabc6a062c908f1ea4a20ffedf620e05591 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 22:04:00 -0400 Subject: [PATCH 10/13] feat(brief): add 'skywatch brief AIRPORT' CLI command --- lib/skywatch/cli.rb | 10 ++++++++++ spec/brief/cli_spec.rb | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 spec/brief/cli_spec.rb diff --git a/lib/skywatch/cli.rb b/lib/skywatch/cli.rb index c922757..6d063a7 100644 --- a/lib/skywatch/cli.rb +++ b/lib/skywatch/cli.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'thor' +require 'json' module Skywatch class CLI < Thor @@ -16,6 +17,15 @@ class CLI < Thor desc 'nimbus SUBCOMMAND', 'SPC convective outlooks and storm reports' subcommand 'nimbus', Skywatch::Nimbus::CLI + desc 'brief AIRPORT', 'AIM 7-1-5 weather brief composed for AIRPORT' + def brief(airport) + result = Skywatch.brief(airport: airport) + puts JSON.pretty_generate(result.to_h) + rescue Skywatch::Error => e + warn "Error: #{e.message}" + exit 1 + end + desc 'version', 'Print version' def version puts "skywatch #{Skywatch::VERSION}" diff --git a/spec/brief/cli_spec.rb b/spec/brief/cli_spec.rb new file mode 100644 index 0000000..918649e --- /dev/null +++ b/spec/brief/cli_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'skywatch brief CLI' do + let(:brief) do + instance_double( + Skywatch::Brief::Models::Brief, + to_h: { airport: 'KCDW', aim_section: '7-1-5' } + ) + end + + before { allow(Skywatch).to receive(:brief).with(airport: 'KCDW').and_return(brief) } + + it 'prints the brief as JSON' do + output = capture_stdout { Skywatch::CLI.start(%w[brief KCDW]) } + parsed = JSON.parse(output) + expect(parsed['airport']).to eq('KCDW') + expect(parsed['aim_section']).to eq('7-1-5') + end + + it 'exits non-zero on Skywatch::Error' do + allow(Skywatch).to receive(:brief).and_raise(Skywatch::Error, 'no METAR for KZZZ') + expect { Skywatch::CLI.start(%w[brief KZZZ]) }.to raise_error(SystemExit) do |e| + expect(e.status).not_to eq(0) + end + end + + def capture_stdout + old = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old + end +end From c8d31772d85e4a98b13fe28bf52317dcb3afa5a6 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 22:06:03 -0400 Subject: [PATCH 11/13] test(brief): add integration spec snapshotting full envelope shape --- spec/brief/integration_spec.rb | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 spec/brief/integration_spec.rb diff --git a/spec/brief/integration_spec.rb b/spec/brief/integration_spec.rb new file mode 100644 index 0000000..2acf4fa --- /dev/null +++ b/spec/brief/integration_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Skywatch.brief integration' do + let(:metar) do + Skywatch::Briefer::Models::Metar.new( + raw: 'KCDW 271800Z 27008KT 10SM FEW050 22/15 A3001', + station_id: 'KCDW', + observed_at: Time.utc(2026, 4, 27, 18), + latitude: 40.875, + longitude: -74.282, + visibility_sm: 10, + sky_condition: [{ cover: :few, base_ft: 5000 }], + temperature_c: 22, dewpoint_c: 15 + ) + end + let(:afd) do + Skywatch::Briefer::Models::Afd.new( + wfo: 'OKX', product_name: 'Area Forecast Discussion', + issued_at: Time.utc(2026, 4, 27, 14), + text: 'SYNOPSIS...High pressure builds in.' + ) + end + + before do + allow_any_instance_of(Skywatch::Briefer::Sources::Metar).to receive(:fetch).and_return([metar]) + allow_any_instance_of(Skywatch::Briefer::Sources::Taf).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Pirep).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::WindsAloft).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Sigmet).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Airmet).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Briefer::Sources::Afd).to receive(:fetch).and_return(afd) + allow_any_instance_of(Skywatch::Nimbus::Sources::Alerts).to receive(:fetch).and_return([]) + allow_any_instance_of(Skywatch::Nimbus::Sources::StormReport).to receive(:fetch).and_return([]) + allow(Skywatch::Brief::Analysis::AirportLocator).to receive(:wfo_for).and_return('OKX') + end + + it 'produces an AIM-9-aligned envelope' do + hash = Skywatch.brief(airport: 'KCDW').to_h + + expect(hash[:airport]).to eq('KCDW') + expect(hash[:coordinates]).to eq([40.875, -74.282]) + expect(hash[:wfo]).to eq('OKX') + expect(hash[:aim_section]).to eq('7-1-5') + + aim9 = %i[adverse_conditions vfr_not_recommended synopsis current_conditions + enroute_forecast destination_forecast winds_aloft notams atc_delays] + aim9.each do |slot| + expect(hash[slot]).to include(:available), "slot #{slot} missing :available" + end + + # Six slots fillable in MVP: 2 truly populated here, 2 gracefully unavailable + # (TAF / winds), 4 statically unavailable, plus AFD supplementary. + expect(hash[:adverse_conditions][:available]).to be true + expect(hash[:adverse_conditions][:items]).to eq([]) + expect(hash[:adverse_conditions][:partial_failures]).to eq([]) + + expect(hash[:vfr_not_recommended][:available]).to be true + expect(hash[:vfr_not_recommended][:vfr_not_recommended]).to be false + expect(hash[:vfr_not_recommended][:category]).to eq('VFR') + + expect(hash[:current_conditions][:available]).to be true + expect(hash[:current_conditions][:metar][:station_id]).to eq('KCDW') + + expect(hash[:destination_forecast][:available]).to be false + expect(hash[:winds_aloft][:available]).to be false + + expect(hash[:synopsis]).to eq(Skywatch::Brief::Models::Brief::SYNOPSIS_UNAVAILABLE) + expect(hash[:enroute_forecast]).to eq(Skywatch::Brief::Models::Brief::ENROUTE_UNAVAILABLE) + expect(hash[:notams]).to eq(Skywatch::Brief::Models::Brief::NOTAMS_UNAVAILABLE) + expect(hash[:atc_delays]).to eq(Skywatch::Brief::Models::Brief::ATC_DELAYS_UNAVAILABLE) + + expect(hash[:afd][:available]).to be true + expect(hash[:afd][:wfo]).to eq('OKX') + expect(hash[:afd][:text]).to include('SYNOPSIS') + end + + it 'serializes round-trip through JSON without raising' do + json = Skywatch.brief(airport: 'KCDW').to_json + parsed = JSON.parse(json) + expect(parsed['aim_section']).to eq('7-1-5') + end +end From 024a63012d61702334f3a3bb65b3cc7da5a85982 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Mon, 27 Apr 2026 22:07:22 -0400 Subject: [PATCH 12/13] docs: document 'skywatch brief AIRPORT' command --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4aaf1bf..5a5649f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ skywatch mayday near 40.875 -74.282 --radius 100 skywatch nimbus outlook 1 --at 40.688,-74.174 skywatch nimbus storms --type tornado --near 40.688,-74.174 --radius 100 skywatch nimbus convection 40.688 -74.174 +skywatch brief KCDW skywatch radar track UAL1234 skywatch radar flights 37.62 -122.38 ``` From 364e7256d23bd0ca748fc640573c2812624a4edf Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Thu, 30 Apr 2026 20:19:25 -0400 Subject: [PATCH 13/13] =?UTF-8?q?docs(brief):=20capture=20validation=20res?= =?UTF-8?q?ults=20=E2=80=94=207-question=20protocol=20on=203=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scenarios (KCDW VFR-clear, KACK IFR, KMSY IFR + active convection) validated against the canonical 7-question protocol. Verdict: PASS — Q1–Q6 substantively correct on all 3 scenarios, Q7 anti-hallucination clean on all 3. AIRMET self-intersection bug surfaced (filed as #11) — degrade-gracefully behavior held, partial_failures shape worked as designed. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04-27-skywatch-brief-validation-results.md | 2437 +++++++++++++++++ 1 file changed, 2437 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md diff --git a/docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md b/docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md new file mode 100644 index 0000000..15e8e22 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-skywatch-brief-validation-results.md @@ -0,0 +1,2437 @@ +# Skywatch.brief — validation results + +**Date:** 2026-04-30 +**Spec:** [`2026-04-27-skywatch-brief-design.md`](2026-04-27-skywatch-brief-design.md) +**Plan:** [`../plans/2026-04-27-skywatch-brief.md`](../plans/2026-04-27-skywatch-brief.md) +**Branch:** `skywatch-brief` + +## Protocol + +Three airport scenarios. For each, the captured `skywatch brief AIRPORT` JSON was paired with the canonical 7-question prompt and run through a fresh, naive-context LLM (subagents dispatched with no shared context, restricted to reading the prompt file only — no external lookups, no Bash/WebFetch). Pilot-grade judgment of substantive correctness on Q1–Q6; explicit anti-hallucination check on Q7 (LLM must identify NOTAMs and ATC delays as not-in-brief). + +**Scenario coverage:** +- Scenario 1 (KCDW) — VFR-clear, no adverse items +- Scenario 2 (KACK) — IFR (700 ft fog, marine layer) +- Scenario 3 (KMSY) — IFR + active convection (-RA, recent TS, convective SIGMET 74C inside the polygon, urgent low-level wind-shear PIREP) + +**Pass criteria:** +- Q1–Q6 substantively correct on at least 2 of 3 scenarios +- Q7 anti-hallucination clean on **all 3 scenarios** + +## Scenario 1: KCDW — VFR-clear + +**Captured at:** 2026-05-01T00:08:14Z +**Conditions summary:** Wind calm, 10 SM, CLR, 13/4 °C, A2977 — full VFR. No adverse items. AIRMET source partial-failure (RGeo::Error::InvalidGeometry: Self-intersection — see issue #11). + +### Brief JSON + +```json +{ + "airport": "KCDW", + "coordinates": [ + 40.8764, + -74.2828 + ], + "wfo": "OKX", + "fetched_at": "2026-05-01T00:08:14Z", + "aim_section": "7-1-5", + "adverse_conditions": { + "available": true, + "items": [], + "partial_failures": [ + { + "source": "airmet", + "reason": "RGeo::Error::InvalidGeometry: Self-intersection" + } + ] + }, + "vfr_not_recommended": { + "available": true, + "vfr_not_recommended": false, + "category": "VFR", + "explanation": "VFR conditions" + }, + "synopsis": { + "available": false, + "reason": "no synopsis source in skywatch — see afd slot" + }, + "current_conditions": { + "available": true, + "metar": { + "station_id": "KCDW", + "raw": "METAR KCDW 302353Z 00000KT 10SM CLR 13/04 A2977 RMK AO2 SLP086 60000 T01330039 10161 20133 51011", + "observed_at": "2026-04-30T23:53:00Z", + "metar_type": "METAR", + "wind_direction_deg": 0, + "wind_speed_kt": 0, + "wind_gust_kt": null, + "visibility_sm": 10.0, + "weather": [], + "sky_condition": [], + "temperature_c": 13.3, + "dewpoint_c": 3.9, + "altimeter_inhg": 29.77, + "station_name": "Caldwell/Essex Cnty, NJ, US", + "latitude": 40.8764, + "longitude": -74.2828, + "elevation_ft": 171, + "ceiling_ft": null, + "flight_category": "vfr", + "spread_c": 9.4, + "density_altitude_ft": 158 + }, + "pireps": [ + { + "raw": "BOS UA /OV RY 4R FINAL/TM 0000/FL030/TP A321/SK BKN/RM DURD", + "observed_at": "2026-05-01T00:00:00Z", + "pirep_type": "pirep", + "aircraft_type": "A321", + "latitude": 42.3575, + "longitude": -70.9895, + "flight_level": 30, + "altitude_ft": 3000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MDT UA /OV 5 NW MDT/TM 2352/FL060/TP C208/SK B- 059/TA -3/IC LIGHT RIME", + "observed_at": "2026-04-30T23:52:00Z", + "pirep_type": "pirep", + "aircraft_type": "C208", + "latitude": 40.2397, + "longitude": -76.8529, + "flight_level": 60, + "altitude_ft": 6000, + "temperature_c": -3, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV ALB040025/TM 2345/FL045/TP C180/IC TRACE RIME 80-50", + "observed_at": "2026-04-30T23:45:00Z", + "pirep_type": "pirep", + "aircraft_type": "C180", + "latitude": 43.1176, + "longitude": -73.5445, + "flight_level": 45, + "altitude_ft": 4500, + "temperature_c": null, + "icing_intensity": "TRC", + "icing_type": "RIME", + "icing_base_ft": 5000, + "icing_top_ft": 8000, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "EWB UA /OV EWB RY23 APCH/TM 2341/FL014/TP C172/SK OVC014", + "observed_at": "2026-04-30T23:41:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 41.6766, + "longitude": -70.9578, + "flight_level": 14, + "altitude_ft": 1400, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV ALB100005/TM 2338/FL020/TP E545/RM GAIN OR LOSS 5 KTS FINAL RY28", + "observed_at": "2026-04-30T23:38:00Z", + "pirep_type": "pirep", + "aircraft_type": "E545", + "latitude": 42.7516, + "longitude": -73.6901, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MIV UA /OV MIV /TM 2338 /FL020 /TP C172 /WX RA /TB NEG /RM SMTH/-RA", + "observed_at": "2026-04-30T23:38:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 39.3678, + "longitude": -75.0722, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ISP UA /OV CCC260010/TM 2328/FL035/TP C172/SK 034OVC", + "observed_at": "2026-04-30T23:28:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 40.8644, + "longitude": -73.0014, + "flight_level": 35, + "altitude_ft": 3500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MDT UA /OV 20 NW HAR/TM 2321/FL070/TP PA32/TA -3/IC LIGHT RIME", + "observed_at": "2026-04-30T23:21:00Z", + "pirep_type": "pirep", + "aircraft_type": "PA32", + "latitude": 40.4925, + "longitude": -77.428, + "flight_level": 70, + "altitude_ft": 7000, + "temperature_c": -3, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV CAM040015/TM 2316/FL90/TP S22T/IC LGT CLR", + "observed_at": "2026-04-30T23:16:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 43.2185, + "longitude": -73.1939, + "flight_level": 90, + "altitude_ft": 9000, + "temperature_c": null, + "icing_intensity": "LGT", + "icing_type": "CLEAR", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ISP UA /OV ISP/TM 2303/FL1800/TP C172/RM LIGHT RAIN", + "observed_at": "2026-04-30T23:03:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 40.7961, + "longitude": -73.1007, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PHL UA /OV ENO/TM 2300/FL100/TP E145/TA M08/IC LGT RIME/RM ICING BETWEEN 100-085", + "observed_at": "2026-04-30T23:00:00Z", + "pirep_type": "pirep", + "aircraft_type": "E145", + "latitude": 39.2316, + "longitude": -75.516, + "flight_level": 100, + "altitude_ft": 10000, + "temperature_c": -8, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "AVP UA /OV AVP/TM 2256/FL040/TP P28A/SK OVC040/TA 01", + "observed_at": "2026-04-30T22:56:00Z", + "pirep_type": "pirep", + "aircraft_type": "P28A", + "latitude": 41.3385, + "longitude": -75.7234, + "flight_level": 40, + "altitude_ft": 4000, + "temperature_c": 1, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BWI UA /OV BAL350029/TM 2251/FL090/TP PA30/TA -4/IC LGHT RIME/RM 090-070", + "observed_at": "2026-04-30T22:51:00Z", + "pirep_type": "pirep", + "aircraft_type": "PA30", + "latitude": 39.6213, + "longitude": -76.8858, + "flight_level": 90, + "altitude_ft": 9000, + "temperature_c": -4, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "LNS UA /OV LRP/TM 2249/FL110/TP B712/TA M12/TB CONS LGT CHOP/IC LGT RIME", + "observed_at": "2026-04-30T22:49:00Z", + "pirep_type": "pirep", + "aircraft_type": "B712", + "latitude": 40.12, + "longitude": -76.2913, + "flight_level": 110, + "altitude_ft": 11000, + "temperature_c": -12, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "SEG UA /OV MIP/TM 2248/FL050/TP C208/TA 00/IC LGT RIME", + "observed_at": "2026-04-30T22:48:00Z", + "pirep_type": "pirep", + "aircraft_type": "C208", + "latitude": 41.0234, + "longitude": -76.6653, + "flight_level": 50, + "altitude_ft": 5000, + "temperature_c": 0, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "AVP UA /OV AVP/TM 2247/FL020/TP P28A/TB LGT CHOP 020", + "observed_at": "2026-04-30T22:47:00Z", + "pirep_type": "pirep", + "aircraft_type": "P28A", + "latitude": 41.3385, + "longitude": -75.7234, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT", + "turbulence_type": "CHOP", + "turbulence_base_ft": 2000, + "turbulence_top_ft": null + }, + { + "raw": "BDL UA /OV JUDDS/TM 2247/FL100/TP S22T/TA M07/IC LGT CLR", + "observed_at": "2026-04-30T22:47:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 41.6347, + "longitude": -73.1082, + "flight_level": 100, + "altitude_ft": 10000, + "temperature_c": -7, + "icing_intensity": "LGT", + "icing_type": "CLEAR", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MIV UA /OV VCN340005 /TM 2242 /FL030 /TP C172 /SK SCT050 /WX FV99SM /TB INTMT LGT CHOP", + "observed_at": "2026-04-30T22:42:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 39.6097, + "longitude": -75.0211, + "flight_level": 30, + "altitude_ft": 3000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "RDG UA /OV ETX356110/TM 2241/FL100/TP E75S/TA M09/IC MOD MX", + "observed_at": "2026-04-30T22:41:00Z", + "pirep_type": "pirep", + "aircraft_type": "E75S", + "latitude": 42.3628, + "longitude": -76.2411, + "flight_level": 100, + "altitude_ft": 10000, + "temperature_c": -9, + "icing_intensity": "MOD", + "icing_type": "MIXED", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + } + ] + }, + "enroute_forecast": { + "available": false, + "reason": "single-point brief; route input deferred from MVP" + }, + "destination_forecast": { + "available": false, + "reason": "fetch failed: Skywatch::ParseError: unexpected end of input at line 1 column 1" + }, + "winds_aloft": { + "available": false, + "reason": "no winds aloft for KCDW" + }, + "notams": { + "available": false, + "reason": "NOTAMs not in skywatch yet — Sectional domain not yet built" + }, + "atc_delays": { + "available": false, + "reason": "ATC delays not in skywatch yet — no source" + }, + "afd": { + "available": true, + "wfo": "OKX", + "text": "\n000\nFXUS61 KOKX 302051\nAFDOKX\n\nArea Forecast Discussion\nNational Weather Service New York NY\n451 PM EDT Thu Apr 30 2026\n\n.WHAT HAS CHANGED...\nShowers are likely from Wednesday afternoon into Thursday, \nfirst from a nearby weak cold front, then from a stronger cold \nfront approaching from the west.\n\n&&\n\n.KEY MESSAGES...\n1) After a few showers associated with a cold frontal passage \nthis afternoon/evening, dry weather will prevail through through\nthe end of the work week.\n\n2) Unsettled weather is possible for at least a portion of the \nweekend as an area of low pressure passes well offshore.\n\n3) A warming trend early next week may be accompanied by \nunsettled conditions next Wednesday and Thursday.\n\n&&\n\n.DISCUSSION...\n.KEY MESSAGE 1...\nIsolated to widely scattered showers are possible this \nafternoon and evening as a cold front crosses the area. Rainfall\namounts with any showers will remain light, with generally only\na few hundredths of an inch expected in any one location.\n\nIn the wake of the frontal passage, high pressure will briefly \nbuild south of the area overnight before quickly moving offshore\nduring the day on Friday. This will result in a dry and \nseasonable day, with daytime highs rising into the 60s.\n\n.KEY MESSAGE 2...\nUnsettled conditions will return on Friday night as a shortwave\nrotating around the base of an upper low over southeastern \nCanada brings a renewed chance of showers. The best chance of \nprecipitation will be across the western half of the area, but \nrainfall amounts again look to remain light.\n\nAny lingering showers associated with the shortwave should \ntaper quickly Saturday morning, but the region will remain under\nabundant cloud cover as an area of low pressure passing well \noffshore begins to approach the region. This system will bring \nanother chance of showers to the region, primarily late Saturday\nafternoon through Saturday night. Rainfall amounts will once \nagain remain light, with the best chances of precipitation along\nthe coast. \n\nRain will come to an end Sunday morning as the low continues to\nmove off to the northeast. Despite a good deal of sunshine, \ndaytime highs will remain below normal, generally in the upper \n50s to around 60.\n\n.KEY MESSAGE 3...\nTemps on Mon will still be up to a few degrees below average as\nhigh pressure to the S slides farther offshore. After a warm \nfrontal passage late Mon night with slight chance of showers NW \nof NYC, temps Tue-Wed should increase to 5-10 degrees above \nnormal inland with highs well into the 70s, and a couple of \ndegrees above normal toward the coast with highs in the 60s to \nnear 70. \n\nAn approaching weak cold front may bring a chance of showers to\nareas NW of NYC Tue night/Wed morning, then more widespread \nshower coverage expected Wed night-Thu, with PoP increasing to \nlikely. Tstms may also be possible Thu afternoon/early evening \nas a stronger cold front approaches from the west in response to\nupper trough amplification over the Midwest/Great Lakes, and a \nstrengthening sfc low passing well NW. Temps on Thu will not be \nquite as warm but should still be a few degrees above normal, \nwith highs in the 60s to near 70. QPF Wed-Thu ranges from about \n0.75 inch west to 1.00-1.50 inch east. At this extended time \nrange it's much too early to speculate on possible hydrologic \nimpacts other than nuisance ponding in low lying and poor \ndrainage area.\n\n&&\n\n.AVIATION /20Z THURSDAY THROUGH TUESDAY/...\nA cold front passes offshore this eve, allowing weak high pres\nto build in for Fri. \n\nGradual improvement to VFR thru 23Z. Slightly later at KGON. \nVFR then remains thru 00Z Sat. \n\nA few shwrs possible thru 00Z invof the front. Timing and\ncoverage too low to include in the TAFs.\n\nVRB flow becomes NW behind the front this eve, lasting thru the\nngt. Speeds less than 10kt. Sea breeze flow will impact winds \non Fri, with places right on the S shores expected to be closer \nto true sea breeze flow. \n\nNY Metro (KEWR/KLGA/KJFK/KTEB) TAF Uncertainty...\n\nReturn to VFR this eve may be off slightly. Amendments possible. \n\nA few shwrs possible thru 23Z, but no lower than MVFR expected\nattm. \n\nWind direction at EWR on Fri could be around 130 true if the \nsea breeze gets into the arpt.\n\n\n.OUTLOOK FOR 18Z FRIDAY THROUGH TUESDAY...\n\nFriday: VFR with flow mainly blw 10kt.\n\nSaturday: Chance of MVFR at times with light winds. \n\nSunday: VFR with NW flow.\n\nMonday: VFR with SW flow. \n\nTuesday: VFR with strong S flow. MVFR or lower possible aft 00Z.\n\nDetailed information, including hourly TAF wind component forecasts, \ncan be found at: https:/www.weather.gov/zny/n90\n\n&&\n\n.MARINE...\nSCA cancelled for the ocean W of Fire Island Inlet out 20 nm as\nE-SE swells there have finally fallen below 5 ft and should \ncontinue on a gradual downward trend. Extended SCA east of \nthere until 9 PM with swells there running slightly higher than \nforecast. Conditions should then remain below SCA thresholds \nthrough Sunday as low pressure passes well south and east. \n\nThe pressure gradient tightens and S winds increase early next\nweek. An extended period of SCA conditions is likely on the \nocean from Mon afternoon into Wed, with sustained winds 20-25 kt\nand seas above 5 ft, building as high as 6-8 ft Mon night-Tue.\nAs the sea breeze develops each afternoon/early evening, SCA \nconditions with gusts to 25 kt may also be possible on the Long \nIsland south shore bays, NY Harbor, and the western/central \nSound. A few gusts just shy of gale force may also occur with \nthe Ambrose jet on the ocean from Sandy Hook to Fire Island \nInlet late day Tue and Wed. \n\n&&\n\n.OKX WATCHES/WARNINGS/ADVISORIES...\nCT...None.\nNY...None.\nNJ...None.\nMARINE...Small Craft Advisory until 9 PM EDT this evening for ANZ350-\n 353.\n\n&&\n\n$$\n\nDISCUSSION...FEB/BG\nAVIATION...JMC\nMARINE...FEB/BG\n", + "issued_at": "2026-04-30T20:51:00Z" + } +} + +``` + +### LLM responses + +## Q1 + +Yes — VFR is recommended right now at KCDW. The brief's `vfr_not_recommended` slot reports `vfr_not_recommended: false` with `category: "VFR"` and the explanation "VFR conditions". The current METAR (KCDW 302353Z, observed 2026-04-30 23:53Z) corroborates this: wind calm (00000KT), visibility 10SM, sky CLR (no ceiling reported), 13.3 C / 3.9 C, altimeter 29.77 inHg, flight category `vfr`. Caveat: the brief was fetched at 2026-05-01T00:08:14Z, ~15 minutes after the observation, so this is essentially current. + +## Q2 + +There is no synopsis slot in this brief — `synopsis.available: false` ("no synopsis source in skywatch — see afd slot"). The pattern has to be read out of the OKX Area Forecast Discussion (issued 2026-04-30 20:51Z / 4:51 PM EDT Thu Apr 30): + +- A cold front is crossing the area this afternoon/evening with isolated to widely scattered light showers (only a few hundredths expected). +- Behind the front, weak high pressure briefly builds in overnight, then moves offshore Friday — Friday looks dry/seasonable, highs in the 60s, winds becoming NW <10 kt then variable with a sea-breeze influence on Friday. +- A shortwave around an upper low over SE Canada brings unsettled weather back Friday night through Saturday night, with light rain ending Sunday morning. Sun/Mon stay below normal with NW then SW flow. +- Warming Tue-Wed (highs into the 70s inland) ahead of another stronger cold front Wed night-Thu with possible thunderstorms Thu afternoon. +- Marine SCA cancelled W of Fire Island Inlet; SCA continues east of there until 9 PM EDT this evening. +- No active CT/NY/NJ land watches/warnings/advisories. + +## Q3 + +From the KCDW METAR (observation time 2026-04-30 23:53Z, station Caldwell/Essex Cnty NJ, elev 171 ft): + +- Wind: calm (000 at 0 kt, no gusts) +- Visibility: 10 SM +- Weather: none +- Sky: CLR (no clouds reported, ceiling null) +- Temperature / Dewpoint: 13.3 C / 3.9 C (spread 9.4 C) +- Altimeter: 29.77 inHg, SLP 1008.6 mb (from RMK SLP086) +- Flight category: VFR +- Density altitude: 158 ft (essentially field elevation) +- Raw: `METAR KCDW 302353Z 00000KT 10SM CLR 13/04 A2977 RMK AO2 SLP086 60000 T01330039 10161 20133 51011` + +Quiet, clear, calm. The RMK 6-hr precip group `60000` indicates no measurable precip in the last 6 hours; the 24-hr max/min (10161 / 20133) decode to ~16.1 C high and 13.3 C low. + +## Q4 + +Not available in this brief. `destination_forecast.available: false` with reason "fetch failed: Skywatch::ParseError: unexpected end of input at line 1 column 1". There is no TAF in this envelope. You'll need to pull a TAF from another source (KCDW itself does not always issue a TAF — typical alternates would be KMMU, KTEB, or KEWR) before flying. + +The only forecast-flavored content here is the OKX AFD aviation section, which says: gradual improvement to VFR through 23Z (already past), VFR continues through 00Z Saturday, a few showers possible through 00Z near the front (timing/coverage too low for TAFs), wind becoming NW <10 kt overnight, sea-breeze influence Friday. Outlook through Tue: VFR Fri, chance MVFR Sat, VFR Sun-Mon, VFR Tue with MVFR-or-lower possible after 00Z Wed. + +## Q5 + +Not available. `winds_aloft.available: false` ("no winds aloft for KCDW"). The brief contains no FB/FD winds-aloft product for any altitude, including 6,000 ft. You'll need to look this up separately (e.g., the nearest FB station in this region would typically be ACK, BUF, or HAR/PSB). + +The closest proxy in the brief is a PIREP at the right altitude: MDT 2352Z, C208, FL060, TA -3 C, light rime ice — that gives an air-temp data point of about -3 C at 6,000 ft west of here, but it's not a wind report. + +## Q6 + +`adverse_conditions.items` is empty (no SIGMETs, CWAs, or severe items rolled up by the brief), but there is a partial failure noted: AIRMETs failed to ingest (`RGeo::Error::InvalidGeometry: Self-intersection`). That means AIRMET coverage is unknown from this brief — assume nothing, verify elsewhere. + +The PIREP set, however, paints a clear icing picture across the broader region (note: most are not in the immediate KCDW vicinity — they span from PA to MA to NY): + +- Light rime icing reported repeatedly between roughly 5,000 ft and 11,000 ft: + - MDT 2352Z C208 FL060 TA -3 LGT RIME + - MDT 2321Z PA32 FL070 TA -3 LGT RIME + - SEG 2248Z C208 FL050 TA 0 LGT RIME + - BWI 2251Z PA30 FL090 TA -4 LGT RIME (090-070) + - LNS 2249Z B712 FL110 TA -12 LGT RIME + cont. light chop + - PHL 2300Z E145 FL100 TA -8 LGT RIME (icing 100-085) +- Trace rime: ALB 2345Z C180 FL045 (5,000-8,000 ft) +- Light clear ice: ALB 2316Z S22T FL090, BDL 2247Z S22T FL100 TA -7 +- One **moderate mixed icing** report: RDG 2241Z E75S FL100 TA -9 — the worst of the bunch. +- Turbulence: light chop at FL020 over AVP, intermittent light chop at FL030 over MIV, continuous light chop at FL110 over LNS. PIREP near final at ALB noted gain/loss 5 kt — minor. +- Light rain reported at MIV (FL020) and ISP — convection-free shower activity consistent with the AFD's "front passing" narrative. + +For a piston VFR flight from KCDW staying low (below the freezing level, roughly somewhere a few thousand feet AGL given surface temp 13 C / dewpoint 4 C), icing should not be a factor. If you climb into the low/mid teens or head west/northwest into PA, expect light rime in cloud and at least one moderate mixed report — IFR/known-icing aircraft territory. + +## Q7 + +Things this brief explicitly does NOT contain that you should source separately before flight: + +- **Synopsis** — `synopsis.available: false`. Use the OKX AFD text in the brief as a partial substitute, or get a proper area synopsis. +- **Destination/terminal forecast (TAF)** — `destination_forecast.available: false` (fetch error). No TAF for KCDW or any alternate in the envelope. Pull a TAF for KCDW (if issued) or your nearest alternate (KMMU/KTEB/KEWR). +- **Winds and temperatures aloft** — `winds_aloft.available: false`. No FB/FD data at any altitude. +- **NOTAMs** — `notams.available: false` ("Sectional domain not yet built"). This is critical — runway closures, lighting, GPS outages, TFRs, etc., are entirely absent. Get NOTAMs from another source. +- **ATC delays / GDP / ground stops** — `atc_delays.available: false`. Check NAS Status separately, especially if your destination is a major airport. +- **AIRMETs** — failed to parse (`RGeo::Error::InvalidGeometry: Self-intersection`). Coverage for IFR, mountain obscuration, icing, and turbulence AIRMETs is unknown from this brief. Given the icing PIREPs scattered around the region, an AIRMET ZULU probably exists — go check. +- **Enroute forecast** — `enroute_forecast.available: false` ("single-point brief; route input deferred"). No route-based weather; only the KCDW point. +- **SIGMETs / CWAs** — `adverse_conditions.items: []` reports none, but it's worth a sanity-check given the icing pattern. +- **Other not-in-envelope items**: graphical/prog charts, radar/satellite imagery, lightning, TFRs, military activity, special-use airspace status, and the destination's ATIS/AWOS. + +Bottom line: the brief is solid for current KCDW field conditions and a regional synopsis (via the AFD), but it is missing TAF, winds aloft, NOTAMs, ATC delays, AIRMETs, and any enroute coverage. Treat it as a starting point, not a complete preflight. + + +### Judgment + +| Q | Pass / fail | Notes | +|---|---|---| +| 1 | Pass | Correctly identified VFR using `vfr_not_recommended` slot + corroborating METAR. | +| 2 | Pass | Synopsis slot is `available: false` (per spec); LLM correctly degraded to extracting pattern from AFD slot text — used the OKX AFD narrative for cold front passage, weekend low, and outlook. | +| 3 | Pass | Pulled real numbers from METAR (calm wind, 10 SM, CLR, 13.3/3.9, 29.77, density alt 158 ft). Decoded RMK 6-hr precip group correctly. | +| 4 | Pass | Honestly flagged TAF unavailable (`Skywatch::ParseError: unexpected end of input` — KCDW does not always issue a TAF; this is a realistic null result). Suggested KMMU/KTEB/KEWR as alternates. | +| 5 | Pass | Honestly flagged winds_aloft slot as `available: false`. Used a PIREP at FL060 (TA -3 C light rime) as a temperature proxy but did not invent wind data. | +| 6 | Pass | Reported empty `items` correctly + flagged the AIRMET partial failure + synthesized regional icing context from PIREP set without overstating. Recommended treating AIRMET coverage as "unknown, verify elsewhere". | +| 7 | Pass | Comprehensive enumeration: synopsis, TAF, winds aloft, NOTAMs, ATC delays, AIRMETs, enroute, SIGMETs/CWAs, TFRs, density altitude. No hallucinated content. | + +## Scenario 2: KACK — IFR + +**Captured at:** 2026-05-01T00:09:40Z +**Conditions summary:** Wind 280@8, 10 SM, BKN007 OVC011 (ceiling 700 ft), 10/8 °C (1.7 °C spread — saturation-near), A2972. Flight category IFR per METAR. AIRMET partial-failure same as Scenario 1. + +### Brief JSON + +```json +{ + "airport": "KACK", + "coordinates": [ + 41.2541, + -70.0589 + ], + "wfo": "BOX", + "fetched_at": "2026-05-01T00:09:40Z", + "aim_section": "7-1-5", + "adverse_conditions": { + "available": true, + "items": [], + "partial_failures": [ + { + "source": "airmet", + "reason": "RGeo::Error::InvalidGeometry: Self-intersection" + } + ] + }, + "vfr_not_recommended": { + "available": true, + "vfr_not_recommended": true, + "category": "IFR", + "explanation": "ceiling 700 ft, vis 10.0 SM" + }, + "synopsis": { + "available": false, + "reason": "no synopsis source in skywatch — see afd slot" + }, + "current_conditions": { + "available": true, + "metar": { + "station_id": "KACK", + "raw": "METAR KACK 302353Z 28008KT 10SM BKN007 OVC011 10/08 A2972 RMK AO2 SLP064 T01000083 10111 20094 53012 $", + "observed_at": "2026-04-30T23:53:00Z", + "metar_type": "METAR", + "wind_direction_deg": 280, + "wind_speed_kt": 8, + "wind_gust_kt": null, + "visibility_sm": 10.0, + "weather": [], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 700 + }, + { + "cover": "ovc", + "base_ft": 1100 + } + ], + "temperature_c": 10.0, + "dewpoint_c": 8.3, + "altimeter_inhg": 29.72, + "station_name": "Nantucket Mem Arpt, MA, US", + "latitude": 41.2541, + "longitude": -70.0589, + "elevation_ft": 39, + "ceiling_ft": 700, + "flight_category": "ifr", + "spread_c": 1.7, + "density_altitude_ft": -352 + }, + "pireps": [ + { + "raw": "BOS UA /OV RY 4R FINAL/TM 0000/FL030/TP A321/SK BKN/RM DURD", + "observed_at": "2026-05-01T00:00:00Z", + "pirep_type": "pirep", + "aircraft_type": "A321", + "latitude": 42.3575, + "longitude": -70.9895, + "flight_level": 30, + "altitude_ft": 3000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MPV UA /OV MPV/TM 2351/FL070/TP C208/TA M02/IC LGT RIME", + "observed_at": "2026-04-30T23:51:00Z", + "pirep_type": "pirep", + "aircraft_type": "C208", + "latitude": 44.0855, + "longitude": -72.4494, + "flight_level": 70, + "altitude_ft": 7000, + "temperature_c": -2, + "icing_intensity": "LGT", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV ALB040025/TM 2345/FL045/TP C180/IC TRACE RIME 80-50", + "observed_at": "2026-04-30T23:45:00Z", + "pirep_type": "pirep", + "aircraft_type": "C180", + "latitude": 43.1176, + "longitude": -73.5445, + "flight_level": 45, + "altitude_ft": 4500, + "temperature_c": null, + "icing_intensity": "TRC", + "icing_type": "RIME", + "icing_base_ft": 5000, + "icing_top_ft": 8000, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "EWB UA /OV EWB RY23 APCH/TM 2341/FL014/TP C172/SK OVC014", + "observed_at": "2026-04-30T23:41:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 41.6766, + "longitude": -70.9578, + "flight_level": 14, + "altitude_ft": 1400, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PWM UA /OV PWM/TM 2341/FL007/TP C208/SK OVC007-TOPUNKN/RM DURD ILS11", + "observed_at": "2026-04-30T23:41:00Z", + "pirep_type": "pirep", + "aircraft_type": "C208", + "latitude": 43.6456, + "longitude": -70.3086, + "flight_level": 7, + "altitude_ft": 700, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV ALB100005/TM 2338/FL020/TP E545/RM GAIN OR LOSS 5 KTS FINAL RY28", + "observed_at": "2026-04-30T23:38:00Z", + "pirep_type": "pirep", + "aircraft_type": "E545", + "latitude": 42.7516, + "longitude": -73.6901, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "6B0 UA /OV 6B0/TM 2331/FL060/TP S22T/TA M02/IC LGT CLR", + "observed_at": "2026-04-30T23:31:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 43.9865, + "longitude": -73.0956, + "flight_level": 60, + "altitude_ft": 6000, + "temperature_c": -2, + "icing_intensity": "LGT", + "icing_type": "CLEAR", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ISP UA /OV CCC260010/TM 2328/FL035/TP C172/SK 034OVC", + "observed_at": "2026-04-30T23:28:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 40.8644, + "longitude": -73.0014, + "flight_level": 35, + "altitude_ft": 3500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ALB UA /OV CAM040015/TM 2316/FL90/TP S22T/IC LGT CLR", + "observed_at": "2026-04-30T23:16:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 43.2185, + "longitude": -73.1939, + "flight_level": 90, + "altitude_ft": 9000, + "temperature_c": null, + "icing_intensity": "LGT", + "icing_type": "CLEAR", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ISP UA /OV ISP/TM 2303/FL1800/TP C172/RM LIGHT RAIN", + "observed_at": "2026-04-30T23:03:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 40.7961, + "longitude": -73.1007, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "ARP IBE0212 4107N 6700W 2258 F370 MS56 250/070KT", + "observed_at": "2026-04-30T22:58:00Z", + "pirep_type": "airep", + "aircraft_type": "IBE0212", + "latitude": 41.1167, + "longitude": -67, + "flight_level": 370, + "altitude_ft": 37000, + "temperature_c": -56, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BDL UA /OV JUDDS/TM 2247/FL100/TP S22T/TA M07/IC LGT CLR", + "observed_at": "2026-04-30T22:47:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 41.6347, + "longitude": -73.1082, + "flight_level": 100, + "altitude_ft": 10000, + "temperature_c": -7, + "icing_intensity": "LGT", + "icing_type": "CLEAR", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + } + ] + }, + "enroute_forecast": { + "available": false, + "reason": "single-point brief; route input deferred from MVP" + }, + "destination_forecast": { + "available": true, + "taf": { + "station_id": "KACK", + "raw": "TAF KACK 302333Z 0100/0124 28009KT P6SM BKN005 BKN013 OVC035 FM010300 28006KT 5SM BR BKN015 FM010500 29007KT 4SM BR BKN020 FM010900 31007KT P6SM SCT130 FM011500 30010KT P6SM FEW250 FM011800 26012KT P6SM FEW250", + "issued_at": "2026-04-30T23:33:00Z", + "valid_from": "2026-05-01T00:00:00Z", + "valid_to": "2026-05-02T00:00:00Z", + "station_name": "Nantucket Mem Arpt", + "latitude": 41.25407, + "longitude": -70.05892, + "elevation_ft": 39, + "forecast_groups": [ + { + "time_from": "2026-05-01T00:00:00Z", + "time_to": "2026-05-01T03:00:00Z", + "change_type": "initial", + "probability": null, + "wind_direction_deg": 280, + "wind_speed_kt": 9, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 500 + }, + { + "cover": "bkn", + "base_ft": 1300 + }, + { + "cover": "ovc", + "base_ft": 3500 + } + ], + "ceiling_ft": 500, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T03:00:00Z", + "time_to": "2026-05-01T05:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 280, + "wind_speed_kt": 6, + "wind_gust_kt": null, + "visibility_sm": 5.0, + "weather": [ + "BR" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 1500 + } + ], + "ceiling_ft": 1500, + "flight_category": "mvfr" + }, + { + "time_from": "2026-05-01T05:00:00Z", + "time_to": "2026-05-01T09:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 290, + "wind_speed_kt": 7, + "wind_gust_kt": null, + "visibility_sm": 4.0, + "weather": [ + "BR" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 2000 + } + ], + "ceiling_ft": 2000, + "flight_category": "mvfr" + }, + { + "time_from": "2026-05-01T09:00:00Z", + "time_to": "2026-05-01T15:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 310, + "wind_speed_kt": 7, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [], + "sky_condition": [ + { + "cover": "sct", + "base_ft": 13000 + } + ], + "ceiling_ft": null, + "flight_category": "vfr" + }, + { + "time_from": "2026-05-01T15:00:00Z", + "time_to": "2026-05-01T18:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 300, + "wind_speed_kt": 10, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [], + "sky_condition": [ + { + "cover": "few", + "base_ft": 25000 + } + ], + "ceiling_ft": null, + "flight_category": "vfr" + }, + { + "time_from": "2026-05-01T18:00:00Z", + "time_to": "2026-05-02T00:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 260, + "wind_speed_kt": 12, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [], + "sky_condition": [ + { + "cover": "few", + "base_ft": 25000 + } + ], + "ceiling_ft": null, + "flight_category": "vfr" + } + ] + } + }, + "winds_aloft": { + "available": false, + "reason": "no winds aloft for KACK" + }, + "notams": { + "available": false, + "reason": "NOTAMs not in skywatch yet — Sectional domain not yet built" + }, + "atc_delays": { + "available": false, + "reason": "ATC delays not in skywatch yet — no source" + }, + "afd": { + "available": true, + "wfo": "BOX", + "text": "\n000\nFXUS61 KBOX 010009\nAFDBOX\n\nArea Forecast Discussion\nNational Weather Service Boston/Norton MA\n809 PM EDT Thu Apr 30 2026\n\n.WHAT HAS CHANGED...\nNo significant changes to the forecast.\n\n&&\n\n.KEY MESSAGES...\n\n- A few leftover showers possible this evening, otherwise drying\n out tonight followed by sunshine and seasonably mild\n conditions Friday. \n\n- Cooler this weekend. Risk for showers as a low pressure \n passes by offshore.\n\n- Becoming warmer for most of next week. Although, unsettled\n conditions return toward the end of next week.\n\n&&\n\n.DISCUSSION...\nKEY MESSAGE 1...A few leftover showers possible this evening, \notherwise drying out tonight followed by sunshine and seasonably \nmild conditions Friday.\n\nBulk of shower activity has lifted to the north and east. We\ncan't rule out a spot shower this evening as mid level shortwave \nmoves through. Otherwise, drying out overnight as weak front\npasses followed by NW flow and decreasing PWATs. This will lead \nto a mostly sunny day on Friday with diurnal CU developing as \nupper low resides to the north with relatively cold temps aloft.\nSeasonable temps expected with highs low-mid 60s, except cooler\nhigher terrain. \n\nDry weather continues into Fri night but another shortwave \napproaching from the west may bring a few showers mostly after \nmidnight, especially in the interior. \n\nKEY MESSAGE 2...Cooler this weekend. Risk for showers as a low \npressure passes by offshore.\n\nThe big question for this weekend revolves around how closely a\nlow pressure will get to southern New England. Projected mid \nlevel flow has a trough axis west of the Appalachians, with the \nconsensus storm track over the Mid Atlantic into the North \nAtlantic. A subtle shift in the orientation of this flow could \nforce a storm track closer to our region, with a great risk for \nshowers Saturday Night into Sunday.\n\nExpecting a low track about 125 miles SE of the 40N/70W\nbenchmark at the closest approach of a low pressure Saturday\nnight at present. Latest consensus model output slightly\nincreased rainfall chances across the Cape and islands, as well \nas portions of the coastal plains of MA and RI. Not looking at \nmuch rainfall when all is done by Sunday afternoon. Expecting \namounts 0.10 inch or less north and west of I-495, with perhaps \n0.50-0.75 inch across the Cape and islands. Will be monitoring \nthe actual approach of this low pressure over the coming days.\n\nKEY MESSAGE 3...Becoming warmer for most of next week. Although, \nunsettled conditions return toward the end of next week.\n\nHigh pressure well to our south early next week should boost high \ntemperatures into the 60s Monday and the 70s for Tuesday.\nThinking it will remain mainly dry Monday, but then a cold \nfront should pass by sometime Tuesday with a risk for showers. \nThe greatest risk for showers looks to be sometime Wednesday \nnight into Thursday as a low pressure moves through the Great \nLakes and pushes a couple of fronts across our region.\n\n&&\n\n.AVIATION /00Z FRIDAY THROUGH TUESDAY/...\nForecaster Confidence Levels...\n\nLow - less than 30 percent.\nModerate - 30 to 60 percent. \nHigh - greater than 60 percent.\n\n00z TAF Update...High confidence in trends. Moderate confidence\nin timing.\n\nMVFR-IFR conditions persist into the evening, then gradual\nimprovement to VFR from W to E overnight, although patchy fog \nmay develop overnight in spots. VFR continues Fri into Fri\nnight, but scattered showers may develop late Fri night across\nthe interior. Winds becoming NW 5-10 kt this evening, increasing \nto 10-15 kt Fri, then becoming light to calm Fri night.\n\nKBOS Terminal...Moderate confidence in TAF. Main uncertainty is\nthe when conditions fully improve to VFR \n\nKBDL Terminal...Moderate confidence in TAF.\n\nOutlook /Saturday through Tuesday/...\n\nSaturday: VFR. Chance SHRA.\n\nSaturday Night: Mainly VFR, with local MVFR possible. Chance\nSHRA.\n\nSunday: VFR. Breezy. Chance SHRA.\n\nSunday Night: VFR. \n\nMonday: VFR. Breezy. \n\nMonday Night: VFR. Breezy. Slight chance SHRA.\n\nTuesday: VFR. Windy with local gusts up to 30 kt. Slight chance\nSHRA.\n\n&&\n\n.MARINE...\nForecaster Confidence Levels...\n\nLow - less than 30 percent. \nModerate - 30 to 60 percent. \nHigh - greater than 60 percent.\n\nGenerally light winds, becoming NW, into Fri then becoming SW \nlate Fri into Fri night. Seas 4-6 ft over outer waters will \nsubside late tonight into Fri. SCA for seas will continue until\nthis time. \n\nOutlook /Saturday through Tuesday/...\n\nSaturday: Winds less than 25 kt. Areas of seas approaching\n5 ft. Slight chance of rain showers. \n\nSaturday Night: Winds less than 25 kt. Areas of seas\napproaching 5 ft. Chance of rain showers. \n\nSunday: Winds less than 25 kt. Seas locally approaching 5 ft.\nChance of rain showers. \n\nSunday Night: Winds less than 25 kt. Areas of seas approaching\n5 ft. \n\nMonday: Low risk for Small Craft Advisory winds with gusts up\nto 30 kt. Seas up to 5 ft. \n\nMonday Night: Moderate risk for Small Craft Advisory winds with\nlocal gusts up to 30 kt. Seas up to 5 ft. \n\nTuesday: Strong winds with gusts up to 30 kt. Areas of rough\nseas.\n\n&&\n\n.BOX WATCHES/WARNINGS/ADVISORIES...\nCT...None.\nMA...None.\nRI...None.\nMARINE...Small Craft Advisory until 6 PM EDT Friday for ANZ250.\n Small Craft Advisory until 2 AM EDT Friday for ANZ251.\n Small Craft Advisory until 2 AM EDT Saturday for ANZ254>256.\n\n&&\n\n$$\n\nDISCUSSION...Belk/KJC\nAVIATION...Belk/KJC\nMARINE...KJC\n", + "issued_at": "2026-05-01T00:09:00Z" + } +} + +``` + +### LLM responses + +## Q1 + +**No — VFR is NOT recommended right now.** + +The brief explicitly flags `vfr_not_recommended: true`, with category **IFR** and the explanation: "ceiling 700 ft, vis 10.0 SM". The current METAR backs this up: BKN at 700 ft (the ceiling), OVC at 1100 ft. Visibility is fine at 10 SM, but the 700 ft ceiling is well below VFR minimums (1,000 ft AGL ceiling required for the surrounding Class E surface area / typical VFR thresholds). You'll need an IFR clearance to depart or arrive. + +## Q2 + +**There is no synopsis section in this brief.** The `synopsis` slot is `available: false` with reason: "no synopsis source in skywatch — see afd slot." + +However, the WFO BOX **Area Forecast Discussion** (issued 01/0009Z, 8:09 PM EDT Thu Apr 30 2026) gives the regional pattern: + +- **Tonight (30 Apr / early 01 May):** Bulk of shower activity has lifted N/E. Spot shower possible this evening as mid-level shortwave moves through. Drying out overnight as a weak front passes; NW flow with decreasing PWATs follows. Patchy fog possible overnight. +- **Friday (01 May):** Mostly sunny with diurnal CU as an upper low sits to the north (cold temps aloft). Highs low-to-mid 60s F. Winds NW 5–10 kt this evening, increasing to 10–15 kt Friday, then light/calm Fri night. +- **Friday night:** Dry early, but another shortwave from the west may bring a few showers after midnight, especially in the interior. +- **Weekend:** Cooler. Low pressure passing offshore — currently projected ~125 mi SE of the 40N/70W benchmark Sat night. Cape and islands (i.e., KACK area) get the highest QPF risk: ~0.50–0.75" possible. Risk for showers Sat night into Sunday. +- **Next week:** Warming trend — 60s Mon, 70s Tue. Cold front Tue with shower risk; greater shower risk Wed night–Thu as a low moves through the Great Lakes. + +**Marine note** for the offshore zones near KACK: Small Craft Advisory in effect for ANZ250 until 6 PM EDT Friday, ANZ254>256 until 2 AM EDT Saturday. Seas 4–6 ft over outer waters subsiding late tonight into Friday. + +## Q3 + +Field conditions per the **METAR KACK 302353Z** (observed 2026-04-30 23:53Z, ~16 minutes before brief fetch): + +- **Wind:** 280° at 8 kt, no gusts +- **Visibility:** 10 SM +- **Weather:** none reported +- **Sky:** BKN 700 ft, OVC 1100 ft → **ceiling 700 ft** +- **Temperature / Dewpoint:** 10.0 °C / 8.3 °C (spread 1.7 °C — tight, fog/low-stratus risk) +- **Altimeter:** 29.72 inHg +- **Flight category:** **IFR** +- **Density altitude:** −352 ft (field elev 39 ft) +- **Raw:** `METAR KACK 302353Z 28008KT 10SM BKN007 OVC011 10/08 A2972 RMK AO2 SLP064 T01000083 10111 20094 53012 $` + +The trailing `$` in the remarks indicates the station is flagging a maintenance check — worth noting but not flight-critical from this data alone. + +## Q4 + +**TAF KACK 302333Z, valid 0100/0124 (2026-05-01 00Z through 2026-05-02 00Z):** + +| FM (UTC) | Wind | Vis | Wx | Sky / Ceiling | Cat | +|---|---|---|---|---|---| +| 01/00–01/03 (initial) | 280@9 | >6 SM | — | BKN005 BKN013 OVC035, ceiling **500 ft** | **IFR** | +| 01/03–01/05 | 280@6 | 5 SM | BR | BKN015, ceiling **1,500 ft** | **MVFR** | +| 01/05–01/09 | 290@7 | 4 SM | BR | BKN020, ceiling **2,000 ft** | **MVFR** | +| 01/09–01/15 | 310@7 | >6 SM | — | SCT130 (no ceiling) | **VFR** | +| 01/15–01/18 | 300@10 | >6 SM | — | FEW250 | **VFR** | +| 01/18–02/00 | 260@12 | >6 SM | — | FEW250 | **VFR** | + +**Key takeaways:** +- Conditions get **worse before they get better** for the next ~3 hours (ceilings drop to 500 ft early, then mist with 4–5 SM vis through ~09Z). +- VFR returns at **09Z Friday morning (~5 AM EDT)** with high scattered. +- Daytime Friday: VFR, light west-to-northwest winds 7–12 kt. +- Wind shifts SW (260°) and picks up to 12 kt by 18Z Friday. + +The AFD AVIATION section corroborates: "MVFR-IFR conditions persist into the evening, then gradual improvement to VFR from W to E overnight, although patchy fog may develop overnight in spots." + +## Q5 + +**Not in this brief.** The `winds_aloft` slot is `available: false` with reason: "no winds aloft for KACK." There is no FB/FD product included. + +The closest proxies in the brief are PIREPs near the New England area — none right at KACK and most up-route, but for situational awareness on temps aloft / icing layers: + +- BDL UA at FL100 (10,000 ft), 22:47Z: TA M07 (−7 °C), light clear icing +- 6B0 UA at FL060 (6,000 ft), 23:31Z: TA M02 (−2 °C), light clear icing +- MPV UA at FL070 (7,000 ft), 23:51Z: TA M02 (−2 °C), light rime icing +- ARP IBE0212 at FL370, 22:58Z: 250°/070 kt, MS56 (−56 °C) + +These are scattered (Vermont, NW MA, CT, Long Island Sound) — none are KACK-local. **For an actual winds/temps aloft at 6,000 ft over KACK you must pull a separate FB product (e.g., from aviationweather.gov) before flight.** + +## Q6 + +**Adverse conditions slot is `available: true` but `items: []`** — no adverse-condition records were synthesized for this point. There is also a **partial failure** flagged: the AIRMET source failed with `RGeo::Error::InvalidGeometry: Self-intersection`, so AIRMET coverage in this brief is incomplete. **Do not assume "no AIRMETs" — assume "AIRMETs unknown" and verify separately.** + +That said, signals from elsewhere in the brief that you should treat as adverse: + +1. **IFR ceilings now at the field** (700 ft BKN). 700-ft ceiling + 1.7 °C temp/dewpoint spread + the AFD's mention of "patchy fog may develop overnight in spots" → fog is a real possibility before things improve. +2. **Worsening before improving** in the TAF — the FM03Z group drops the ceiling to 1,500 ft with 5 SM in BR, then 4 SM BR by 05Z. Mist/fog is likely. +3. **Icing in the region.** Multiple PIREPs report light icing in clouds at 5,000–10,000 ft (rime over Vermont, clear over NY/CT). The icing PIREP from ALB area (43.1N/73.5W) explicitly calls out "TRACE RIME 80-50" — i.e., trace rime between 5,000 and 8,000 ft. Freezing levels are clearly down in the low-to-mid single-digit thousands. **If you're climbing through cloud layers in the IFR system above KACK, expect icing risk — especially if your route takes you NW.** +4. **Marine SCAs** in adjacent offshore zones (relevant if you fly low/coastal): seas 4–6 ft, gusts up to 30 kt forecast for Tuesday outlook period. +5. The AFD outlook flags **shower risk Sat night–Sun** with locally 0.50–0.75" possible across the Cape and islands as a low passes ~125 mi SE of the 40N/70W benchmark — relevant if your flight is multi-day. + +## Q7 + +The brief is honest about its gaps. Items **not** in this brief that you'll need to source elsewhere before flight: + +1. **NOTAMs** — `available: false`, reason: "NOTAMs not in skywatch yet — Sectional domain not yet built." **Critical.** Pull KACK NOTAMs (and any en-route facility NOTAMs, plus FDC) from the FAA NOTAM Search. +2. **ATC delays / TFRs / ground stops** — `available: false`, reason: "ATC delays not in skywatch yet — no source." Check the FAA ATCSCC / OIS site for delays and TFRs. +3. **Winds and temperatures aloft** — `available: false` for KACK. Pull FB/FD from aviationweather.gov for your cruise altitudes. +4. **AIRMETs** — explicitly flagged as a partial failure (RGeo geometry error). The brief has no usable AIRMET coverage. Pull G-AIRMET / Sierra/Tango/Zulu directly. Given the icing PIREPs in the region, **AIRMET Zulu (icing) is plausibly active** and you should check. +5. **SIGMETs / Convective SIGMETs / CWAs** — not in the brief at all (no slot). Verify there are none active for your route. +6. **Enroute forecast** — `available: false`, reason: "single-point brief; route input deferred from MVP." This is a destination/origin brief only, not a route brief. If you're going somewhere other than to/from KACK, you don't have route weather here. +7. **Synopsis** — explicitly `available: false`. The AFD partly fills this gap, but it's a regional discussion, not a true area synopsis / surface analysis. +8. **Center Weather Advisories (CWAs), area forecasts, graphical TAFs, radar/satellite imagery** — none included; pull from aviationweather.gov as needed. +9. **Freezing level chart / icing forecast** — not directly provided; PIREPs hint at icing in cloud above ~5,000 ft regionally, but you should pull the CIP/FIP / icing forecast graphics. +10. **Density altitude / runway condition / braking action / field condition NOTAMs** for KACK specifically — confirm via NOTAMs and the AWOS/ATIS at the field. +11. **Personal minimums check / weight-and-balance / fuel planning** — out of scope for any brief; reminder to do it. + +**Bottom line:** This brief is enough to know KACK is currently IFR with improving conditions overnight, but you cannot legally/safely launch from this brief alone — at minimum you need NOTAMs, AIRMETs/SIGMETs, and winds aloft before you go. + + +### Judgment + +| Q | Pass / fail | Notes | +|---|---|---| +| 1 | Pass | Correctly identified IFR + VFR not recommended using slot. Cited 700 ft ceiling explicitly. | +| 2 | Pass | Synopsis slot unavailable; LLM correctly substituted from AFD: stalled boundary, weekend low ~125 mi SE of 40N/70W benchmark, marine SCAs, multi-day outlook. | +| 3 | Pass | Full METAR decode: wind 280@8, 10 SM, BKN007 OVC011, 10/8 spread 1.7, A2972, density alt -352. Noted maintenance flag (`$`). | +| 4 | Pass | Excellent TAF decode in tabular form showing: ceilings dropping to 500 ft early then improving via mist (4–5 SM BR) to VFR by 09Z Friday morning. Trend correctly captured. | +| 5 | Pass | Honestly flagged unavailable; surfaced PIREP icing temps as situational context, did not invent wind/temperature aloft. | +| 6 | Pass | Empty `items` + AIRMET partial-failure flagged + synthesized icing risk from regional PIREPs (rime icing 5,000–10,000 ft) + low T-Td spread fog risk + TAF-implied worsening before improving. | +| 7 | Pass | 11-item enumeration of missing data: NOTAMs, ATC delays, winds aloft, AIRMETs, SIGMETs, enroute, synopsis, CWAs, freezing-level chart, density altitude, personal minimums. No hallucinations. | + +## Scenario 3: KMSY — IFR + active convection + +**Captured at:** 2026-05-01T00:10:35Z +**Conditions summary:** SPECI 020@8, 10 SM, -RA, BKN008 BKN018 OVC110 (ceiling 800 ft), 22/21 °C (1.6 °C spread), A2986. Adverse items: 1 convective SIGMET (74C, polygon directly over field) + 1 urgent low-level wind-shear PIREP at MSY (15 kt airspeed gain inside FAF). AIRMET partial-failure same as scenarios 1/2. + +### Brief JSON + +```json +{ + "airport": "KMSY", + "coordinates": [ + 29.9974, + -90.2777 + ], + "wfo": "LIX", + "fetched_at": "2026-05-01T00:10:35Z", + "aim_section": "7-1-5", + "adverse_conditions": { + "available": true, + "items": [ + { + "kind": "sigmet", + "series_id": "74C", + "issuing_center": "KKCI", + "sigmet_type": "sigmet", + "hazard": "CONVECTIVE", + "severity": 5, + "raw": "WSUS32 KKCI 302355\nSIGC \nCONVECTIVE SIGMET 74C\nVALID UNTIL 0155Z\nAL MS LA AND CSTL WTRS\nFROM 20ESE SJI-90SSE SJI-30W LEV-30ESE LSU-20ESE SJI\nAREA TS MOV FROM 26030KT. TOPS ABV FL450.\n\nOUTLOOK VALID 010155-010555\nFROM INK-LFK-CEW-LEV-PSX-LRD-DLF-MRF-INK\nWST ISSUANCES EXPD. REFER TO MOST RECENT ACUS01 KWNS FROM STORM\nPREDICTION CENTER FOR SYNOPSIS AND METEOROLOGICAL DETAILS.", + "valid_from": "2026-04-30T23:55:00Z", + "valid_to": "2026-05-01T01:55:00Z", + "altitude_hi_ft": 45000, + "altitude_low_ft": null, + "movement_dir_deg": 260, + "movement_speed_kt": 30, + "coords": [ + { + "lat": 30.602, + "lon": -88.003 + }, + { + "lat": 29.342, + "lon": -87.701 + }, + { + "lat": 29.179, + "lon": -90.676 + }, + { + "lat": 30.286, + "lon": -90.762 + }, + { + "lat": 30.602, + "lon": -88.003 + } + ] + }, + { + "kind": "pirep", + "raw": "MSY UUA /OV MSY285003/TM 2245/FLDURD/TP B38M/RM 15KT GAIN INSIDE FINAL APPROACH FIX", + "observed_at": "2026-04-30T22:45:00Z", + "pirep_type": "urgent pirep", + "aircraft_type": "B38M", + "latitude": 30.0054, + "longitude": -90.3149, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + } + ], + "partial_failures": [ + { + "source": "airmet", + "reason": "RGeo::Error::InvalidGeometry: Self-intersection" + } + ] + }, + "vfr_not_recommended": { + "available": true, + "vfr_not_recommended": true, + "category": "IFR", + "explanation": "ceiling 800 ft, vis 10.0 SM" + }, + "synopsis": { + "available": false, + "reason": "no synopsis source in skywatch — see afd slot" + }, + "current_conditions": { + "available": true, + "metar": { + "station_id": "KMSY", + "raw": "SPECI KMSY 010007Z 02008KT 10SM -RA BKN008 BKN018 OVC110 22/21 A2986 RMK AO2 P0001 T02220206 $", + "observed_at": "2026-05-01T00:07:00Z", + "metar_type": "SPECI", + "wind_direction_deg": 20, + "wind_speed_kt": 8, + "wind_gust_kt": null, + "visibility_sm": 10.0, + "weather": [ + "-RA" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 800 + }, + { + "cover": "bkn", + "base_ft": 1800 + }, + { + "cover": "ovc", + "base_ft": 11000 + } + ], + "temperature_c": 22.2, + "dewpoint_c": 20.6, + "altimeter_inhg": 29.86, + "station_name": "New Orleans/Armstrong Intl, LA, US", + "latitude": 29.9974, + "longitude": -90.2777, + "elevation_ft": 0, + "ceiling_ft": 800, + "flight_category": "ifr", + "spread_c": 1.6, + "density_altitude_ft": 924 + }, + "pireps": [ + { + "raw": "PNS UA /OV PNS170005/TM 0004/FL017/TP A319/SK SKC/RM SMOOTH DURD RY35 PNS", + "observed_at": "2026-05-01T00:04:00Z", + "pirep_type": "pirep", + "aircraft_type": "A319", + "latitude": 30.392, + "longitude": -87.1665, + "flight_level": 17, + "altitude_ft": 1700, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MOB UA /OV MOB/TM 2357/FLDURC/TP E145/SK CLR BELOW 080", + "observed_at": "2026-04-30T23:57:00Z", + "pirep_type": "pirep", + "aircraft_type": "E145", + "latitude": 30.7606, + "longitude": -88.3032, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "JAN UA /OV MHZ135025/TM 2357/FL340/TP A321/TB CONS LGT OCNL MOD CHOP/RM ZME 45", + "observed_at": "2026-04-30T23:57:00Z", + "pirep_type": "pirep", + "aircraft_type": "A321", + "latitude": 32.1447, + "longitude": -89.7464, + "flight_level": 340, + "altitude_ft": 34000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT-MOD", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "LFT UA /OV LFT24002/TM 2353/FLDURD/TP C560/SK SCT005", + "observed_at": "2026-04-30T23:53:00Z", + "pirep_type": "pirep", + "aircraft_type": "C560", + "latitude": 30.1938, + "longitude": -91.9926, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MSY UA /OV MSY285002/TM 2351/FLDURD/TP B737/SK SCT005", + "observed_at": "2026-04-30T23:51:00Z", + "pirep_type": "pirep", + "aircraft_type": "B737", + "latitude": 30.0013, + "longitude": -90.2963, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT360005/TM 2350/FL018/TP S22T/SK OVC019/WX FV05SM -RA", + "observed_at": "2026-04-30T23:50:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 30.4899, + "longitude": -89.0734, + "flight_level": 18, + "altitude_ft": 1800, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MEI UA /OV MEI180060/TM 2344/FL210/TP B190/TA M08/IC TRACE RIME/RM ZTLFD-11", + "observed_at": "2026-04-30T23:44:00Z", + "pirep_type": "pirep", + "aircraft_type": "B190", + "latitude": 31.3839, + "longitude": -88.9062, + "flight_level": 210, + "altitude_ft": 21000, + "temperature_c": -8, + "icing_intensity": "TRC", + "icing_type": "RIME", + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MCB UA /OV MCB315030/TM 2343/FL350/TP B737/TB MOD CHOP/RM ZHU", + "observed_at": "2026-04-30T23:43:00Z", + "pirep_type": "pirep", + "aircraft_type": "B737", + "latitude": 31.6748, + "longitude": -90.6506, + "flight_level": 350, + "altitude_ft": 35000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BTR UA /OV 1 NE KBTR/TM 2342/FL005/TP BE9L/SK BKN 005", + "observed_at": "2026-04-30T23:42:00Z", + "pirep_type": "pirep", + "aircraft_type": "BE9L", + "latitude": 30.5447, + "longitude": -91.1362, + "flight_level": 5, + "altitude_ft": 500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT360015/TM 2335/FL040/TP S22T/SK OVC045-TOP050/WX RN/TB MOD", + "observed_at": "2026-04-30T23:35:00Z", + "pirep_type": "pirep", + "aircraft_type": "S22T", + "latitude": 30.6562, + "longitude": -89.0667, + "flight_level": 40, + "altitude_ft": 4000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BTR UA /OV LSU090030/TM 2334/FL360/TP E175/TB MOD /RM ZHU", + "observed_at": "2026-04-30T23:34:00Z", + "pirep_type": "pirep", + "aircraft_type": "E175", + "latitude": 30.4317, + "longitude": -90.7184, + "flight_level": 360, + "altitude_ft": 36000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT360020/TM 2331/FL340/TP B737/TB MOD CHOP/RM ZHU", + "observed_at": "2026-04-30T23:31:00Z", + "pirep_type": "pirep", + "aircraft_type": "B737", + "latitude": 30.7393, + "longitude": -89.0633, + "flight_level": 340, + "altitude_ft": 34000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT/TM 2330/FL010/TP E190/SK BKN010/TB NEG/RM SMOOOOOOOTH", + "observed_at": "2026-04-30T23:30:00Z", + "pirep_type": "pirep", + "aircraft_type": "E190", + "latitude": 30.4068, + "longitude": -89.0768, + "flight_level": 10, + "altitude_ft": 1000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "LCH UA /OV 10 NE LCH/TM 2330/FLDURD/TP E170/SK BKN030-037 BKN045-060 BKN060-065", + "observed_at": "2026-04-30T23:30:00Z", + "pirep_type": "pirep", + "aircraft_type": "E170", + "latitude": 30.2438, + "longitude": -92.9538, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PNS UA /OV PNS/TM 2329/FL030/TP CRJ9/SK BASES BKN031 TOPS BKN045/TB SMOOTH", + "observed_at": "2026-04-30T23:29:00Z", + "pirep_type": "pirep", + "aircraft_type": "CRJ9", + "latitude": 30.4734, + "longitude": -87.1866, + "flight_level": 30, + "altitude_ft": 3000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "JAN UA /OV MHZ135025/TM 2329/FL350/TP B738/TB CONS MOD CHOP/RM ZME 43", + "observed_at": "2026-04-30T23:29:00Z", + "pirep_type": "pirep", + "aircraft_type": "B738", + "latitude": 32.1447, + "longitude": -89.7464, + "flight_level": 350, + "altitude_ft": 35000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MSY UA /OV HRV/TM 2325/FL410/TP C68A/TB MOD/RM ZHU", + "observed_at": "2026-04-30T23:25:00Z", + "pirep_type": "pirep", + "aircraft_type": "C68A", + "latitude": 29.8502, + "longitude": -90.003, + "flight_level": 410, + "altitude_ft": 41000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT315007/TM 2323/FL020/TP BCS3/SK BKN020", + "observed_at": "2026-04-30T23:23:00Z", + "pirep_type": "pirep", + "aircraft_type": "BCS3", + "latitude": 30.4919, + "longitude": -89.169, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GPT UA /OV GPT095008/TM 2320/FL051/TP CRJ9/SK SCT050/WX -RA/TB NEG", + "observed_at": "2026-04-30T23:20:00Z", + "pirep_type": "pirep", + "aircraft_type": "CRJ9", + "latitude": 30.3905, + "longitude": -88.9237, + "flight_level": 51, + "altitude_ft": 5100, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PNS UA /OV PNS150009/TM 2318/FL026/TP A319/SK BASES BKN031/RM DURD", + "observed_at": "2026-04-30T23:18:00Z", + "pirep_type": "pirep", + "aircraft_type": "A319", + "latitude": 30.3464, + "longitude": -87.0947, + "flight_level": 26, + "altitude_ft": 2600, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BTR UA /OV 1 NE KBTR/TM 2313/FL006/TP HDJT/SK OVC 006", + "observed_at": "2026-04-30T23:13:00Z", + "pirep_type": "pirep", + "aircraft_type": "HDJT", + "latitude": 30.5447, + "longitude": -91.1362, + "flight_level": 6, + "altitude_ft": 600, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "CEW UA /OV CEW290050/TM 2310/FL240/TP E170/SK TOP 180 BKN", + "observed_at": "2026-04-30T23:10:00Z", + "pirep_type": "pirep", + "aircraft_type": "E170", + "latitude": 31.1482, + "longitude": -87.5738, + "flight_level": 240, + "altitude_ft": 24000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "JAN UA /OV 15 S OF JAN/TM 2310/FL50/TP B717/SK BASE050", + "observed_at": "2026-04-30T23:10:00Z", + "pirep_type": "pirep", + "aircraft_type": "B717", + "latitude": 32.3112, + "longitude": -90.0759, + "flight_level": 50, + "altitude_ft": 5000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "NEW UA /OV NEW/TM 2309/FLDURD/TP FA10/RM BASES 010", + "observed_at": "2026-04-30T23:09:00Z", + "pirep_type": "pirep", + "aircraft_type": "FA10", + "latitude": 30.0424, + "longitude": -90.0282, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PNS UA /OV KPNS/TM 2309/FL025/TP C172/SK OVC025/TB NEG/RM DURD RWY35", + "observed_at": "2026-04-30T23:09:00Z", + "pirep_type": "pirep", + "aircraft_type": "C172", + "latitude": 30.4734, + "longitude": -87.1866, + "flight_level": 25, + "altitude_ft": 2500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MLU UA /OV MLU0900015/TM 2303/FL090/TP CRJ7/SK OVC090/WX -RA/RM DURC", + "observed_at": "2026-04-30T23:03:00Z", + "pirep_type": "pirep", + "aircraft_type": "CRJ7", + "latitude": 32.5169, + "longitude": -92.036, + "flight_level": 90, + "altitude_ft": 9000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MOB UA /OV MOB/TM 2302/FLDURD/TP BE36/SK 020", + "observed_at": "2026-04-30T23:02:00Z", + "pirep_type": "pirep", + "aircraft_type": "BE36", + "latitude": 30.7606, + "longitude": -88.3032, + "flight_level": 0, + "altitude_ft": 0, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "GZH UA /OV MVC017041/TM 2259/FL350/TP A321/TB CONS LGT OCNL MOD CHOP/RM ZTLFD-08", + "observed_at": "2026-04-30T22:59:00Z", + "pirep_type": "pirep", + "aircraft_type": "A321", + "latitude": 32.0958, + "longitude": -87.064, + "flight_level": 350, + "altitude_ft": 35000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT-MOD", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "SHV UA /OV EMG115015/TM 2258/FL370/TP B738/TB CONS LGT CHOP/RM ZFW28", + "observed_at": "2026-04-30T22:58:00Z", + "pirep_type": "pirep", + "aircraft_type": "B738", + "latitude": 32.2679, + "longitude": -93.3448, + "flight_level": 370, + "altitude_ft": 37000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT", + "turbulence_type": "CHOP", + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BFM UA /OV BFM/TM 2256/FL160/TP CL35/TB MOD/RM ZHU", + "observed_at": "2026-04-30T22:56:00Z", + "pirep_type": "pirep", + "aircraft_type": "CL35", + "latitude": 30.6127, + "longitude": -88.0555, + "flight_level": 160, + "altitude_ft": 16000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "JAN UA /OV 30 S OF JAN/TM 2255/FL60/TP P32R/SK BASE060", + "observed_at": "2026-04-30T22:55:00Z", + "pirep_type": "pirep", + "aircraft_type": "P32R", + "latitude": 32.3112, + "longitude": -90.0759, + "flight_level": 60, + "altitude_ft": 6000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "LCH UA /OV 10 S/TM 2255/FL020/TP E145/SK BKN023", + "observed_at": "2026-04-30T22:55:00Z", + "pirep_type": "pirep", + "aircraft_type": "E145", + "latitude": 30.1415, + "longitude": -93.1056, + "flight_level": 20, + "altitude_ft": 2000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PNS UA /OV 170004/TM 2254/FL015/TP A21N/SK BKN030/TB NEG", + "observed_at": "2026-04-30T22:54:00Z", + "pirep_type": "pirep", + "aircraft_type": "A21N", + "latitude": 30.4734, + "longitude": -87.1866, + "flight_level": 15, + "altitude_ft": 1500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MEI UA /OV MEI315040/TM 2253/FL300/TP A321/TB CONS LGT OCNL MOD /RM ZME46", + "observed_at": "2026-04-30T22:53:00Z", + "pirep_type": "pirep", + "aircraft_type": "A321", + "latitude": 32.8872, + "longitude": -89.3137, + "flight_level": 300, + "altitude_ft": 30000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT-MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "MLU UA /OV MLU140025/TM 2252/FL060/TP RV7/TB MOD", + "observed_at": "2026-04-30T22:52:00Z", + "pirep_type": "pirep", + "aircraft_type": "RV7", + "latitude": 32.1844, + "longitude": -91.7402, + "flight_level": 60, + "altitude_ft": 6000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "BTR UA /OV 15 SE OF BTR/TM 2251/FL045/TP CL35/SK BASES 045", + "observed_at": "2026-04-30T22:51:00Z", + "pirep_type": "pirep", + "aircraft_type": "CL35", + "latitude": 30.5329, + "longitude": -91.1499, + "flight_level": 45, + "altitude_ft": 4500, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": null, + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "PNS UA /OV PENSI/TM 2248/FL063/TP CRJ9/TB SMOOTH/RM BETWEEN LAYERS", + "observed_at": "2026-04-30T22:48:00Z", + "pirep_type": "pirep", + "aircraft_type": "CRJ9", + "latitude": 30.7932, + "longitude": -87.2738, + "flight_level": 63, + "altitude_ft": 6300, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "NEG", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + }, + { + "raw": "LCH UA /OV 3007N 09319W/TM 2241/FL 170/TP E45X/TB LGT-MOD CONS/RM AWC-WEB", + "observed_at": "2026-04-30T22:41:00Z", + "pirep_type": "pirep", + "aircraft_type": "E45X", + "latitude": 30.1167, + "longitude": -93.3167, + "flight_level": 170, + "altitude_ft": 17000, + "temperature_c": null, + "icing_intensity": null, + "icing_type": null, + "icing_base_ft": null, + "icing_top_ft": null, + "turbulence_intensity": "LGT-MOD", + "turbulence_type": null, + "turbulence_base_ft": null, + "turbulence_top_ft": null + } + ] + }, + "enroute_forecast": { + "available": false, + "reason": "single-point brief; route input deferred from MVP" + }, + "destination_forecast": { + "available": true, + "taf": { + "station_id": "KMSY", + "raw": "TAF KMSY 302328Z 0100/0206 03012KT 5SM -SHRA BR BKN008 OVC030 TEMPO 0100/0104 2SM TSRA BKN008CB FM010400 05006KT P6SM BKN008 OVC020 PROB30 0109/0114 4SM -SHRA OVC005 FM011400 07010KT P6SM -SHRA BKN010 BKN020 PROB30 0114/0120 4SM TSRA OVC005CB FM012000 07011KT 6SM -SHRA BKN013 OVC060 PROB30 0120/0201 4SM -TSRA OVC007CB FM020100 05015G22KT P6SM -SHRA BKN009 OVC090 PROB30 0201/0206 4SM TSRA OVC003CB", + "issued_at": "2026-04-30T23:28:00Z", + "valid_from": "2026-05-01T00:00:00Z", + "valid_to": "2026-05-02T06:00:00Z", + "station_name": "New Orleans/Armstrong Intl", + "latitude": 29.99739, + "longitude": -90.27773, + "elevation_ft": 0, + "forecast_groups": [ + { + "time_from": "2026-05-01T00:00:00Z", + "time_to": "2026-05-01T04:00:00Z", + "change_type": "initial", + "probability": null, + "wind_direction_deg": 30, + "wind_speed_kt": 12, + "wind_gust_kt": null, + "visibility_sm": 5.0, + "weather": [ + "-SHRA", + "BR" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 800 + }, + { + "cover": "ovc", + "base_ft": 3000 + } + ], + "ceiling_ft": 800, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T00:00:00Z", + "time_to": "2026-05-01T04:00:00Z", + "change_type": "tempo", + "probability": null, + "wind_direction_deg": null, + "wind_speed_kt": null, + "wind_gust_kt": null, + "visibility_sm": 2.0, + "weather": [ + "TSRA" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 800 + } + ], + "ceiling_ft": 800, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T04:00:00Z", + "time_to": "2026-05-01T14:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 50, + "wind_speed_kt": 6, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 800 + }, + { + "cover": "ovc", + "base_ft": 2000 + } + ], + "ceiling_ft": 800, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T09:00:00Z", + "time_to": "2026-05-01T14:00:00Z", + "change_type": "prob", + "probability": 30, + "wind_direction_deg": null, + "wind_speed_kt": null, + "wind_gust_kt": null, + "visibility_sm": 4.0, + "weather": [ + "-SHRA" + ], + "sky_condition": [ + { + "cover": "ovc", + "base_ft": 500 + } + ], + "ceiling_ft": 500, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T14:00:00Z", + "time_to": "2026-05-01T20:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 70, + "wind_speed_kt": 10, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [ + "-SHRA" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 1000 + }, + { + "cover": "bkn", + "base_ft": 2000 + } + ], + "ceiling_ft": 1000, + "flight_category": "mvfr" + }, + { + "time_from": "2026-05-01T14:00:00Z", + "time_to": "2026-05-01T20:00:00Z", + "change_type": "prob", + "probability": 30, + "wind_direction_deg": null, + "wind_speed_kt": null, + "wind_gust_kt": null, + "visibility_sm": 4.0, + "weather": [ + "TSRA" + ], + "sky_condition": [ + { + "cover": "ovc", + "base_ft": 500 + } + ], + "ceiling_ft": 500, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-01T20:00:00Z", + "time_to": "2026-05-02T01:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 70, + "wind_speed_kt": 11, + "wind_gust_kt": null, + "visibility_sm": 6.0, + "weather": [ + "-SHRA" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 1300 + }, + { + "cover": "ovc", + "base_ft": 6000 + } + ], + "ceiling_ft": 1300, + "flight_category": "mvfr" + }, + { + "time_from": "2026-05-01T20:00:00Z", + "time_to": "2026-05-02T01:00:00Z", + "change_type": "prob", + "probability": 30, + "wind_direction_deg": null, + "wind_speed_kt": null, + "wind_gust_kt": null, + "visibility_sm": 4.0, + "weather": [ + "-TSRA" + ], + "sky_condition": [ + { + "cover": "ovc", + "base_ft": 700 + } + ], + "ceiling_ft": 700, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-02T01:00:00Z", + "time_to": "2026-05-02T06:00:00Z", + "change_type": "fm", + "probability": null, + "wind_direction_deg": 50, + "wind_speed_kt": 15, + "wind_gust_kt": 22, + "visibility_sm": 6.0, + "weather": [ + "-SHRA" + ], + "sky_condition": [ + { + "cover": "bkn", + "base_ft": 900 + }, + { + "cover": "ovc", + "base_ft": 9000 + } + ], + "ceiling_ft": 900, + "flight_category": "ifr" + }, + { + "time_from": "2026-05-02T01:00:00Z", + "time_to": "2026-05-02T06:00:00Z", + "change_type": "prob", + "probability": 30, + "wind_direction_deg": null, + "wind_speed_kt": null, + "wind_gust_kt": null, + "visibility_sm": 4.0, + "weather": [ + "TSRA" + ], + "sky_condition": [ + { + "cover": "ovc", + "base_ft": 300 + } + ], + "ceiling_ft": 300, + "flight_category": "lifr" + } + ] + } + }, + "winds_aloft": { + "available": false, + "reason": "no winds aloft for KMSY" + }, + "notams": { + "available": false, + "reason": "NOTAMs not in skywatch yet — Sectional domain not yet built" + }, + "atc_delays": { + "available": false, + "reason": "ATC delays not in skywatch yet — no source" + }, + "afd": { + "available": true, + "wfo": "LIX", + "text": "\n000\nFXUS64 KLIX 302346\nAFDLIX\n\nArea Forecast Discussion\nNational Weather Service New Orleans LA\n646 PM CDT Thu Apr 30 2026\n\n...New UPDATE, AVIATION...\n\n.KEY MESSAGES... \nUpdated at 630 PM CDT Thu Apr 30 2026\n\n- Heavy rainfall and increased risk of flash flooding will impact\n much of the region Friday night.\n \n- The threat for thunderstorms, some possibly strong to severe\n will continue through Friday night.\n\n- Cooler than normal high temperatures are expected Friday through\n Monday. \n\n&&\n\n.UPDATE...\nIssued at 630 PM CDT Thu Apr 30 2026\n\nStrongest convection now sinking southeastward to the south of\nInterstate 10 and south of New Orleans. Still light to moderate\nrain over much of the area away from the cluster of thunderstorms,\nbut radar indicates that there is a back edge to this rain area \nnear Baton Rouge. There is still more precipitation over northwest\nLouisiana and eastern Texas, so we won't dry out the overnight \nforecast completely behind this batch.\n\nWill probably do an update in the next hour or two as we see how\nmuch of the current precipitation we lose with the loss of surface\nheating. Any updates will only cover the overnight hours tonight,\nprior to the main package issuance around 1 AM CDT.\n\n&&\n\n.SHORT TERM...\n(Now through Saturday night)\nIssued at 1225 PM CDT Thu Apr 30 2026\n\nAn excellent setup for locally heavy rainfall to develop will\nremain in place through the entire short term period. A stalled\nfrontal boundary will remain in place along the I-10 corridor\nPWATS will be near the daily maximum through tomorrow night. At \nthe same time, favorable upper level jet dynamics will support \ncontinued deep layer forcing over the area. In the low to mid- \nlevels a region of enhanced baroclinicity beneath the upper level jet\ncouplet will allow a low pressure to form along the stalled front\nby tomorrow morning. This low will track to the east and help to \nincrease both isentropic forcing behind the front and moisture \nconvergence along the frontal boundary. Mean storm relative flow \nwill be largely parallel the frontal boundary, so any storms that\nform will track along the boundary. This will allow for a greatly\nincreased risk of training thunderstorms to form as early as late\nFriday afternoon, but more likely on Friday night when our flash\nflood threat peaks. Rainfall totals of 2 to 4 inches are \ngenerally expected over the next 2 days, but localized areas of 4 \nto 8 inches can be expected. Even with the latest high resolution \nmodeling, the axis of heaviest rainfall will be highly dependent \non exactly where the stationary boundary and greatest moisture \nconvergence develops. The threat looks to be highest north of I-10\nat this time, but if the boundary sinks further south than \nexpected, that heavy rain axis could shift into metro New Orleans \nand the River Parishes. We will need to monitor the positioning of\nthe front carefully as we move into tomorrow. We have also opted \nto hold off on issuing a flood watch due to the uncertainty in \nwhere the heaviest rainfall will occur. \n\nLooking beyond the heavy rain concerns, ample instability and\nspeed shear will also be in place to the south of the stalled\nfrontal boundary through Friday night. 0-6km shear will remain on\nthe order of 50 to 60 knots and areas south of the front will see\nMLCAPE values of 1000 to 1500 J/KG each afternoon. These values\ncombined with the deep layer forcing over the region will support\na risk of isolated severe thunderstorm development both this\nafternoon and again Friday afternoon for coastal Louisiana and the\noffshore waters. Although directional shear will be more limited\nat around 100 to 150 m2/s2 in the lowest 3km, some weak\nmesocyclonic development in the updraft will occur with any \nsevere storms. Tornadoes are unlikely to occur, but these weakly \nrotating updrafts will support a larger hail formation as \nresidence time increases in these longer lasting and deeper \nupdraft cores. As the cores aloft finally exceed the buoyant \nforcing with the updraft, they will collapse and produce periods \nof severe hail and strong damaging winds in coastal Louisiana and \noffshore. \n\nTemperatures will be much cooler than previous days due to the\nboundary being stalled over the area. Persistent cloud cover and\nperiods of rain will combine with fairly strong cold air advection\nbehind the front to keep temperatures in the 60s and lower 70s\nnorth of the front. South of the front, temperatures will remain\nwarm with highs climbing into the upper 70s and lower 80s both\ntoday and tomorrow. A very small diurnal range is expected tonight\nand dewpoints remain very high with readings only cooling into the\nupper 50s and lower 60s north of the front and the upper 60s south\nof the front. By tomorrow night, temperatures and dewpoints will\nbegin to drop quickly as the front pushes offshore late in the\nnight. Lows should drop into the low to mid 50s by daybreak on\nSaturday. \n\nA deep northern stream longwave trough axis will pass through the\narea on Saturday and this will help to fully drive all of the rain\nout of the area as a cold front sweeps out into the central Gulf.\nBreezy and significantly colder conditions will take hold by\nSaturday afternoon with northerly winds of 15 to 20 mph and\ntemperatures in the upper 60s and lower 70s expected. The\ncombination of clear skies and continued cold air advection into\nthe area Saturday night will allow lows to cool into the upper 40s\nand lower 50s across most of the area. Only the immediate\nsouthshore of Lake Pontchartrain and the immediate coast of \nLouisiana will see lows in the mid 50s due to the warmer nearby \nwaters modifying the temperatures slightly. \n\n&&\n\n.LONG TERM...\n(Sunday through Wednesday night)\nIssued at 1225 PM CDT Thu Apr 30 2026\n\nA broad and deep low to mid-level high will remain in firm control\nof the forecast area on Sunday and Monday. Skies will remain\nlargely clear and humidity will remain low as this high dominates\nthe region. Temperatures will also run a bit below average with\nhighs in the mid 70s Sunday afternoon and the upper 70s Monday\nafternoon. Lows will also remain cool Sunday night as clear skies,\nlow dewpoints, and lighter winds allow temperatures to fall back\ninto the upper 40s and lower 50s over inland areas. Coastal\nlocations will once again fall into the mid 50s. \n\nTuesday and Wednesday will see the pattern shift once more. A\nzonal flow regime will develop in the upper levels while a\npersistent and deep layer onshore flow pattern will form on the\nwestern periphery of a departing high in the low to mid-levels.\nThis onshore flow regime will allow for Gulf moisture and warmth\nto begin feeding back into the area. Tuesday will remain dry as\nthe mid-levels remain dry and a capping inversion lingers, but as\nmoisture deepens on Wednesday a passing southern stream vort max\nwill allow for isolated to widely scattered convective activity to\nform Wednesday afternoon. This activity will be diurnally induced\nas temperatures warm into the mid to upper 80s and MLCAPE exceeds\n1000 J/KG. Overall, a return to the warmer and more humid pattern\nseen recently is expected to return by the middle of next week. \n\n&&\n\n.AVIATION...\n(00Z TAFS)\nIssued at 630 PM CDT Thu Apr 30 2026\n\nQuite a spread in conditions across the forecast terminals at\nissuance time, from VFR to IFR. Current cluster of SHRA/TSRA\nmoving across the terminals with at least brief IFR conditions\nthrough about 03-04z. Likely to be about a 6 hour break in\nprecipitation, but still likely to have MVFR to IFR conditions\nafter the precipitation departs. PROB30 should be good enough to\ncover the precipitation threat during the morning hours Friday at\nmost terminals, but will need to at least consider TEMPO or\nprevailing conditions for showers and storms tomorrow afternoon,\nand continuing into tomorrow night. May not see VFR conditions\nreturn until perhaps midday Saturday.\n\n&&\n\n.MARINE...\nIssued at 1225 PM CDT Thu Apr 30 2026\n\nWinds will be variable in direction this afternoon into tonight as a \nstationary front remains parked over the area. To the north of the \nfront in the tidal lakes and sounds, northeast winds of 10 to 15 \nknots will persist into the overnight hours. South of the front, \nsoutheast winds of 5 to 10 knots will be the rule through tonight. \nTomorrow, a developing low pressure system will move into the area \nalong the front. Winds will increase in response to this low with \nnortheast winds of 15 to 20 knots forming over the tidal lakes and \nsounds and southeast winds of 10 to 15 knots developing in the open \nGulf waters. As the low tracks to the east, it will help drive the \nfront offshore Friday night into Saturday. Strong northerly winds \nof 20 to 25 knots will develop Friday night and persist through \nSunday morning over all of the waters, and a small craft advisory \nwill be issued for these conditions by tomorrow. Winds and seas \nwill quickly fall off to less than 10 knots and 3 feet Sunday night \ninto Monday as a broad area of high pressure settles over the \nregion. As the high shifts to the east on Tuesday, winds will turn \nsoutherly at around 10 knots.\n\n&&\n\n.LIX WATCHES/WARNINGS/ADVISORIES...\nLA...None.\nGM...None.\nMS...None.\nGM...None.\n&&\n\n$$\n\nSHORT TERM...PG\nLONG TERM....PG\nAVIATION...RW\nMARINE...PG\n", + "issued_at": "2026-04-30T23:46:00Z" + } +} + +``` + +### LLM responses + +## Q1 + +No, VFR is not recommended for KMSY right now. The brief's `vfr_not_recommended` slot explicitly flags `vfr_not_recommended: true`, with the field categorized as **IFR** because of an 800 ft ceiling and 10.0 SM visibility. This matches the current SPECI METAR (BKN008 with light rain). Any departure or arrival should be planned IFR. + +## Q2 + +There is no dedicated synopsis in this brief — the `synopsis` slot is `available: false` ("no synopsis source in skywatch — see afd slot"). However, the WFO LIX Area Forecast Discussion (issued 2026-04-30 23:46Z) gives a clear picture: + +- A **stalled frontal boundary** is parked along the I-10 corridor, with a low pressure expected to form along it Friday morning and track east. +- **Heavy rainfall and an increased flash-flood risk** are forecast Friday into Friday night, with 2–4" widespread and localized 4–8" possible. The heaviest axis is currently expected north of I-10 but could shift into metro New Orleans. +- **Thunderstorms, some strong to severe**, are expected through Friday night. 0–6 km shear is 50–60 kt, MLCAPE 1000–1500 J/kg south of the front — supporting large hail and damaging winds, especially over coastal LA and offshore. Tornadoes considered unlikely. +- The aviation discussion notes a roughly 6-hour break in precipitation tonight, but MVFR/IFR likely persists, with VFR possibly not returning until midday Saturday. +- A cold front sweeps the rain offshore Saturday; cooler/drier high pressure dominates Sun–Mon. Onshore flow returns Tue–Wed. + +## Q3 + +Current conditions at KMSY (SPECI 2026-05-01T00:07Z, fetched 00:10Z): + +- **Wind:** 020° at 8 kt, no gusts +- **Visibility:** 10.0 SM +- **Weather:** light rain (-RA) +- **Sky:** BKN 800, BKN 1800, OVC 11000 +- **Ceiling:** 800 ft (the broken 800 layer) +- **Temperature / Dewpoint:** 22.2 °C / 20.6 °C (spread 1.6 °C — very moist, fog/low-stratus risk) +- **Altimeter:** 29.86 inHg +- **Density altitude:** 924 ft (field elevation 0 ft) +- **Flight category:** IFR + +Bottom line: warm, wet, rainy, low ceiling, light variable-northeasterly surface wind, near-saturation. + +## Q4 + +The TAF for KMSY (issued 2026-04-30T23:28Z, valid 2026-05-01 00:00Z through 2026-05-02 06:00Z): + +- **00Z–04Z (initial):** 030° / 12 kt, 5 SM, -SHRA BR, BKN008 OVC030, ceiling 800 ft, **IFR**. +- **00Z–04Z TEMPO:** 2 SM in TSRA, BKN008, IFR. Thunderstorms possible during this window. +- **04Z–14Z (FM 0400):** 050° / 6 kt, 6 SM, BKN008 OVC020, ceiling 800 ft, **IFR**. +- **09Z–14Z PROB30:** 4 SM -SHRA, OVC005, ceiling 500 ft, IFR. +- **14Z–20Z (FM 1400):** 070° / 10 kt, 6 SM, -SHRA, BKN010 BKN020, ceiling 1000 ft, **MVFR**. +- **14Z–20Z PROB30:** 4 SM TSRA, OVC005, ceiling 500 ft, IFR. +- **20Z–01Z next day (FM 2000):** 070° / 11 kt, 6 SM, -SHRA, BKN013 OVC060, ceiling 1300 ft, **MVFR**. +- **20Z–01Z PROB30:** 4 SM -TSRA, OVC007, ceiling 700 ft, IFR. +- **0100Z–0600Z 02 May (FM 0100):** 050° / 15G22 kt, 6 SM, -SHRA, BKN009 OVC090, ceiling 900 ft, **IFR**. +- **0100Z–0600Z 02 May PROB30:** 4 SM TSRA, OVC003, ceiling 300 ft, **LIFR**. + +In short: persistent IFR/MVFR with embedded thunderstorm risk for the entire 30-hour TAF window. Best (still MVFR) window is mid-day Friday afternoon (1400–2000Z). Gusty 22 kt easterly winds appear after 0100Z 02 May. + +## Q5 + +Not available. The `winds_aloft` slot is `available: false` ("no winds aloft for KMSY"). The brief contains no FB winds-aloft forecast for any altitude, so I cannot give the wind/temperature at 6,000 ft. You will need to pull this from another source (e.g. an FB winds-aloft product for a nearby station — common substitutes near KMSY would be MSY itself if available, or BTR/LCH/MOB; none are in this brief). + +For situational color only, two PIREPs near 6 kft were filed but they do not report wind or temperature: + +- MLU UA at FL090 (2303Z): OVC090, -RA — no wind/temp +- JAN UA at FL060 (2255Z): bases 060 — no wind/temp +- MLU UA at FL060 (2252Z): MOD turbulence — no wind/temp + +So treat winds/temps at 6,000 ft as **unknown from this brief**. + +## Q6 + +Adverse conditions itemized from the brief: + +1. **Convective SIGMET 74C (KKCI), severity 5, hazard CONVECTIVE.** Valid 2026-04-30T23:55Z to 2026-05-01T01:55Z. Covers AL/MS/LA and coastal waters; the polygon (vertices 30.602/-88.003, 29.342/-87.701, 29.179/-90.676, 30.286/-90.762) lies just east and south of KMSY (29.997/-90.278) — the western edge of the polygon is right at KMSY's longitude. Area of thunderstorms moving FROM 260° at 30 kt, **tops above FL450**. Outlook valid 0155Z–0555Z calls for additional WST issuances. This is the dominant near-term hazard. + +2. **Urgent PIREP at MSY** (2026-04-30T22:45Z, B737 MAX): 15 kt airspeed gain inside the final approach fix during descent. This is a low-level wind-shear indication on the MSY approach — significant for any IFR arrival. + +3. **Partial failure: AIRMET data unavailable** (RGeo InvalidGeometry / self-intersection). I cannot tell you about Sierra/Tango/Zulu AIRMETs from this brief — assume nothing about IFR/mountain obscuration/turbulence/icing AIRMETs and check elsewhere. + +4. **Field is IFR with low ceiling and high humidity.** 800 ft ceiling, -RA, 1.6 °C T-Td spread → fog/stratus risk overnight. + +5. **TAF embedded thunderstorm risk** through the entire valid window (TEMPO TSRA 00–04Z, PROB30 TSRA in 14–20Z, 20Z–01Z, and 01Z–06Z 02 May with ceilings down to 300 ft — LIFR). + +6. **Widespread MOD / LGT-MOD turbulence and chop in the area-PIREP set:** + - JAN A321 FL340 LGT-MOD CHOP (2245Z and 2329Z B738 MOD CHOP) + - MCB B737 FL350 MOD CHOP (2343Z) + - GPT B737 FL340 MOD CHOP (2331Z) + - GPT S22T FL040 MOD turbulence (2335Z) + - BTR E175 FL360 MOD (2334Z) + - MSY C68A FL410 MOD (2325Z) + - GZH A321 FL350 LGT-MOD CHOP (2259Z) + - SHV B738 FL370 LGT CHOP (2258Z) + - BFM CL35 FL160 MOD (2256Z) + - MEI A321 FL300 LGT-MOD (2253Z) + - MLU RV7 FL060 MOD (2252Z) + - LCH E45X FL170 LGT-MOD CONS (2241Z) + +7. **Trace rime icing PIREP**: MEI B190 FL210, OAT -8 °C, trace rime (2026-04-30T23:44Z). Worth noting if climbing through the freezing level around FL180-ish in the area. + +8. **Convective/IFR PIREPs around the field**: GPT S22T at 1800 ft reporting OVC019 with FV05SM -RA (2350Z); LCH E145 BKN023 (2255Z); BTR HDJT OVC006 (2313Z) — confirms widespread low IFR conditions. + +## Q7 + +This brief is explicitly missing several things you would normally want before flight: + +- **NOTAMs:** `notams.available = false` ("NOTAMs not in skywatch yet — Sectional domain not yet built"). You have **zero** NOTAM coverage — runway closures, ILS/approach NOTAMs, GPS interference, TFRs, obstacle/crane, airport/FDC NOTAMs all unknown. **Get from FAA NOTAM Search / ForeFlight / 1800wxbrief before flying.** +- **ATC delays / ground stops / EDCT:** `atc_delays.available = false` ("ATC delays not in skywatch yet — no source"). Pull from FAA NAS Status / OIS. +- **Winds and temperatures aloft:** `winds_aloft.available = false`. No FB product for KMSY in this brief — you have no wind/temp data for any altitude. Get FB winds aloft (e.g. AWC/aviationweather.gov). +- **AIRMETs:** Marked as a partial failure (RGeo geometry error). Sierra (IFR/mountain obscuration), Tango (turbulence), and Zulu (icing/freezing levels) status is **unknown** in this brief. Pull them separately. +- **Synopsis:** No structured synopsis source; only the AFD prose. If you want a formal area-forecast surface analysis or prog charts, get them elsewhere. +- **Enroute forecast:** `enroute_forecast.available = false` ("single-point brief; route input deferred from MVP"). This brief is **single-point KMSY only** — no route data, no enroute weather along your track, no forecasts for your destination if it's not KMSY. +- **TFRs:** Not present in any field. Check FAA TFR list independently. +- **Destination/alternate weather** (if KMSY is your departure): the brief only has KMSY's TAF/METAR — you'll need METAR/TAF for any destination and alternate. +- **PIREP fields are sparse:** many PIREPs in the brief have null `temperature_c`, null `icing_*` and null `turbulence_*` fields even though the raw text often contains that info (e.g. the urgent MSY wind-shear PIREP has all hazard fields null) — read the `raw` text yourself; don't trust the parsed fields alone. +- **SIGMET coverage:** Only the convective SIGMET 74C is included. Non-convective SIGMETs (turbulence, icing, volcanic ash) and international SIGMETs are not confirmed present — verify. +- **Center Weather Advisories (CWAs), G-AIRMETs, runway condition reports, surface analysis, radar/satellite imagery, lightning data, flight watch / FSS briefing, density-altitude warnings for your specific aircraft, and fuel/MEL information** — none of these are in the brief. + +In short: treat this as a **single-point weather snapshot for KMSY only**. Before launch, separately obtain NOTAMs, TFRs, AIRMETs, winds aloft, ATC delays, and weather/forecasts for your route and destination/alternate. + + +### Judgment + +| Q | Pass / fail | Notes | +|---|---|---| +| 1 | Pass | Correctly identified IFR + VFR not recommended. Cited 800 ft ceiling and -RA. | +| 2 | Pass | Pulled rich synopsis substitute from AFD slot: stalled I-10 boundary, Friday low formation, 2–4" QPF (locally 4–8"), severe risk south of front (50–60 kt 0–6 km shear, 1000–1500 J/kg MLCAPE), aviation 6-hour break, weekend cold-front clearance. | +| 3 | Pass | Full SPECI decode with correct ceiling-from-BKN008 logic, density alt 924, near-saturation note. | +| 4 | Pass | Comprehensive TAF decode through 30-hour validity including TEMPO TSRA + PROB30 LIFR group. Identified mid-day Friday afternoon as best (still MVFR) window. | +| 5 | Pass | Honestly flagged unavailable; noted three PIREPs near FL060–090 had no wind/temp fields; correctly recommended pulling FB separately. | +| 6 | Pass | **8-item itemization** including: convective SIGMET 74C (polygon vertices listed, tops > FL450, movement 260@30), urgent MSY wind-shear PIREP (15 kt gain inside FAF), AIRMET partial-failure, IFR ceilings, embedded TAF thunderstorm risk, 12 turbulence PIREPs across the area, trace rime icing PIREP at FL210, and confirming low-IFR PIREPs around the field. | +| 7 | Pass | 12-item enumeration including a sharp call-out that several PIREP fields (icing/turbulence) are null in parsed structure even when raw text contains the info — useful real-world signal about PIREP parser fidelity. | + +## Verdict + +**Pass / fail:** **PASS** + +**Q1–Q6 substantive correctness:** **3 of 3 scenarios passed** (well above the ≥2/3 bar) +**Q7 anti-hallucination:** **3 of 3 scenarios clean** + +## Discussion + +The validation thesis — *"can an LLM produce a useful 1-800-WX-BRIEF-quality answer from current Skywatch primitives?"* — is **confirmed** for the single-airport MVP scope. Across three scenarios spanning VFR-clear (KCDW), IFR fog (KACK), and IFR + active convection (KMSY), every Q1–Q6 answer was substantively correct, every Q7 answer correctly enumerated the missing data classes (NOTAMs, ATC delays, winds aloft, AIRMETs, enroute), and no LLM hallucinated content not present in the JSON. + +The AIM-7-1-5 envelope shape, the uniform `{ available, ... }` slot contract, and the per-slot `reason` strings did the load-bearing work: when slots were unavailable (synopsis, TAF for KCDW, winds_aloft, NOTAMs, ATC delays), the LLM degraded honestly using slot-adjacent data (AFD as synopsis substitute, PIREP temperatures as winds-aloft proxies) without inventing missing facts. The KMSY scenario was the strongest stress test — a 30-minute SPECI flip from VFR-snapshot to IFR with -RA, a convective SIGMET whose polygon directly covered the field, and an urgent low-level wind-shear PIREP — and the adverse-conditions slot's polygon-intersect + distance + urgency partition surfaced both the SIGMET and the wind-shear PIREP correctly. + +**One real-world bug exposed during validation:** AIRMET ingest fails uniformly with `RGeo::Error::InvalidGeometry: Self-intersection` and is captured cleanly under `partial_failures` (filed as issue [#11](https://github.com/jayrav13/skywatch/issues/11)). This is the *correct* degrade-gracefully behavior under the spec, but it does mean AIRMET coverage (Sierra/Tango/Zulu) is unavailable in every brief until #11 lands. Every LLM run flagged this honestly under Q6 — the partial-failure shape is doing its job. + +**Most justified expansion next:** Route input ([#5](https://github.com/jayrav13/skywatch/issues/5)). Every Q7 answer named "no enroute coverage" as the biggest informational gap; the LLM clearly wants route data. Coordinate input ([#6](https://github.com/jayrav13/skywatch/issues/6)) is a close second for off-airport / GA launches. ETD-aware TAF/winds windows ([#7](https://github.com/jayrav13/skywatch/issues/7)) would tighten Q4/Q5 considerably (the current TAF answers are exhaustive across full validity rather than focused on a flight window). AFD synopsis extraction ([#8](https://github.com/jayrav13/skywatch/issues/8)) is a smaller win — the LLM was already comfortable extracting synopsis-shaped narrative from the raw AFD text, so explicit extraction is not blocking. The text formatter ([#10](https://github.com/jayrav13/skywatch/issues/10)) is a ergonomics win once the JSON-only thesis is shipped. + +**Methodological note:** The validation runs used naive-context Claude Code subagents (no shared conversation context, tool access restricted to `Read`-only on the prompt file) rather than the canonical paste-into-claude.ai approach the plan called for. The substitution is sound for the validation thesis (the question is "can an LLM make sense of this JSON?", not "specifically Claude.ai"), but is not 100% identical — the subagent runtime has a Claude Code system prompt that the paste-into-claude.ai path does not. If reviewers want a clean-room paste-into-claude.ai run for sign-off, the three paste-ready prompts at `/tmp/claude-501/prompt_{kcdw,kack,kmsy}.txt` are unchanged from this validation. + +## Artifacts + +- Brief JSONs: `/tmp/claude-501/brief_{kcdw,kack,kmsy}.json` (saved at validation time) +- LLM responses: `/tmp/claude-501/brief_{kcdw,kack,kmsy}_responses.md` +- Paste-ready prompts: `/tmp/claude-501/prompt_{kcdw,kack,kmsy}.txt` +- Open follow-up issues filed pre-validation: [#5](https://github.com/jayrav13/skywatch/issues/5), [#6](https://github.com/jayrav13/skywatch/issues/6), [#7](https://github.com/jayrav13/skywatch/issues/7), [#8](https://github.com/jayrav13/skywatch/issues/8), [#9](https://github.com/jayrav13/skywatch/issues/9), [#10](https://github.com/jayrav13/skywatch/issues/10) +- Open follow-up issue filed during validation: [#11](https://github.com/jayrav13/skywatch/issues/11) (AIRMET self-intersection)