From 183825f7ddea05726ee755b703e12885d79dbbb0 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Fri, 1 May 2026 21:24:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(brief):=20support=20coordinate=20input=20?= =?UTF-8?q?=E2=80=94=20Skywatch.brief(at:=20[lat,=20lon])=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds coordinate input to Skywatch.brief alongside the existing airport: kw. For coord queries, the composer: - finds the nearest reporting station via a bbox METAR query around the requested point (Metar#fetch_nearest) - uses the requested coordinates for WFO lookup, AFD selection, and adverse-conditions polygon containment - uses the nearest station's airport ID for TAF, winds aloft, and PIREPs - attaches a `note:` describing the data source + distance, with a WARNING when the nearest station is more than 25 nm away CLI accepts either an airport ID (KCDW) or "LAT,LON" (40.688,-74.174): skywatch brief KCDW skywatch brief 40.688,-74.174 Closes #6. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + lib/skywatch.rb | 4 +- lib/skywatch/brief/analysis/composer.rb | 60 ++++++++++++++++++++----- lib/skywatch/brief/models/brief.rb | 13 +++--- lib/skywatch/briefer/sources/metar.rb | 18 ++++++++ lib/skywatch/cli.rb | 13 ++++-- spec/brief/analysis/composer_spec.rb | 50 +++++++++++++++++++++ spec/brief/cli_spec.rb | 16 +++++++ spec/brief/models/brief_spec.rb | 23 ++++++++++ spec/brief_spec.rb | 16 ++++--- spec/briefer/sources/metar_spec.rb | 38 ++++++++++++++++ 11 files changed, 225 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d81d8b5..99ea4bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ skywatch nimbus storms --type tornado --near 40.688,-74.174 --radius 100 skywatch nimbus convection 40.688 -74.174 skywatch nimbus smoke 40.688 -74.174 skywatch brief KCDW +skywatch brief 40.688,-74.174 skywatch radar track UAL1234 skywatch radar flights 37.62 -122.38 ``` diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 2f9b334..7be118f 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -143,8 +143,8 @@ def smoke(at:) Nimbus::Sources::Smoke.new.fetch(at: at) end - def brief(airport:) - Brief::Analysis::Composer.new.compose(airport: airport) + def brief(airport: nil, at: nil) + Brief::Analysis::Composer.new.compose(airport: airport, at: at) end def crosswind(station_id, runway_heading:) diff --git a/lib/skywatch/brief/analysis/composer.rb b/lib/skywatch/brief/analysis/composer.rb index b2fc056..09106bd 100644 --- a/lib/skywatch/brief/analysis/composer.rb +++ b/lib/skywatch/brief/analysis/composer.rb @@ -8,6 +8,7 @@ module Analysis class Composer # rubocop:disable Metrics/ClassLength ADVERSE_RADIUS_NM = 100 STORM_REPORT_LOOKBACK_HOURS = 6 + NEAR_STATION_THRESHOLD_NM = 25 # rubocop:disable Metrics/ParameterLists def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, @@ -33,32 +34,69 @@ def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, end # rubocop:enable Metrics/ParameterLists - def compose(airport:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def compose(airport: nil, at: nil) + case [airport.nil?, at.nil?] + when [false, true] then compose_for_airport(airport) + when [true, false] then compose_for_coords(*at) + else raise ArgumentError, 'must specify exactly one of airport: or at:' + end + end + + private + + def compose_for_airport(airport) metar = fetch_metar_or_raise(airport) lat, lon = AirportLocator.coordinates_from_metar(metar) - wfo = wrap_wfo(lat, lon) + build_brief(airport_id: airport.upcase, requested_lat: lat, requested_lon: lon, + metar: metar, note: nil) + end + + def compose_for_coords(req_lat, req_lon) + metar = @metar_source.fetch_nearest(lat: req_lat, lon: req_lon) + raise Skywatch::Error, "no METAR reporting station near #{req_lat},#{req_lon}" if metar.nil? + + distance = Skywatch::Radar::Analysis::Proximity.distance_nm( + req_lat, req_lon, metar.latitude, metar.longitude + ) + build_brief(airport_id: metar.station_id, requested_lat: req_lat, requested_lon: req_lon, + metar: metar, note: nearest_station_note(metar.station_id, distance)) + end + + def nearest_station_note(station_id, distance_nm) + if distance_nm > NEAR_STATION_THRESHOLD_NM + "WARNING: nearest reporting station #{station_id} is #{distance_nm} nm " \ + 'from the requested point — local conditions may differ significantly' + else + "data sourced from nearest reporting station #{station_id} " \ + "(#{distance_nm} nm from requested point)" + end + end - pirep_attempt = attempt { @pirep_source.fetch(airport, radius_nm: ADVERSE_RADIUS_NM) } + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:) + wfo = wrap_wfo(requested_lat, requested_lon) + pirep_attempt = attempt { @pirep_source.fetch(airport_id, radius_nm: ADVERSE_RADIUS_NM) } partitioned = AdverseFilter.partition_pireps(pirep_attempt[:value] || []) Models::Brief.new( - airport: airport.upcase, - coordinates: [lat, lon], + airport: airport_id, + coordinates: [requested_lat, requested_lon], wfo: wfo, fetched_at: Time.now.utc, adverse_conditions: build_adverse( - lat: lat, lon: lon, urgent_pireps: partitioned[:urgent], pirep_attempt: pirep_attempt + lat: requested_lat, lon: requested_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) } + destination_forecast: wrap('TAF') { build_destination(airport_id) }, + winds_aloft: wrap('winds aloft') { build_winds(airport_id) }, + afd: wfo.nil? ? unavailable_afd_for_no_wfo : wrap('AFD') { build_afd(wfo) }, + note: note ) end - - private + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize def fetch_metar_or_raise(airport) metars = @metar_source.fetch(airport) diff --git a/lib/skywatch/brief/models/brief.rb b/lib/skywatch/brief/models/brief.rb index 1c25e6f..bfc187e 100644 --- a/lib/skywatch/brief/models/brief.rb +++ b/lib/skywatch/brief/models/brief.rb @@ -31,12 +31,13 @@ class Brief attr_reader :airport, :coordinates, :wfo, :fetched_at, :adverse_conditions, :vfr_not_recommended, - :current_conditions, :destination_forecast, :winds_aloft, :afd + :current_conditions, :destination_forecast, :winds_aloft, :afd, :note - # rubocop:disable Metrics/ParameterLists + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength def initialize(airport:, coordinates:, wfo:, fetched_at:, adverse_conditions:, vfr_not_recommended:, - current_conditions:, destination_forecast:, winds_aloft:, afd:) + current_conditions:, destination_forecast:, winds_aloft:, afd:, + note: nil) @airport = airport @coordinates = coordinates @wfo = wfo @@ -47,12 +48,13 @@ def initialize(airport:, coordinates:, wfo:, fetched_at:, @destination_forecast = destination_forecast @winds_aloft = winds_aloft @afd = afd + @note = note end - # rubocop:enable Metrics/ParameterLists + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength # rubocop:disable Metrics/MethodLength def to_h - { + base = { airport: airport, coordinates: coordinates, wfo: wfo, @@ -69,6 +71,7 @@ def to_h atc_delays: ATC_DELAYS_UNAVAILABLE, afd: afd } + note ? base.merge(note: note) : base end # rubocop:enable Metrics/MethodLength diff --git a/lib/skywatch/briefer/sources/metar.rb b/lib/skywatch/briefer/sources/metar.rb index bc7f941..8536127 100644 --- a/lib/skywatch/briefer/sources/metar.rb +++ b/lib/skywatch/briefer/sources/metar.rb @@ -6,6 +6,7 @@ module Sources class Metar ENDPOINT = '/api/data/metar' TTL = 300 + DEFAULT_NEAREST_RADIUS_NM = 100 def initialize(client: Skywatch.client) @client = client @@ -16,6 +17,23 @@ def fetch(*station_ids) data = @client.get(ENDPOINT, { ids: ids, format: 'json' }, ttl: TTL) data.map { |entry| Skywatch::Briefer::Models::Metar.from_awc(entry) } end + + def fetch_nearest(lat:, lon:, radius_nm: DEFAULT_NEAREST_RADIUS_NM) + metars = fetch_in_bbox(lat: lat, lon: lon, radius_nm: radius_nm) + return nil if metars.empty? + + metars.min_by { |m| Skywatch::Radar::Analysis::Proximity.distance_nm(lat, lon, m.latitude, m.longitude) } + end + + private + + def fetch_in_bbox(lat:, lon:, radius_nm:) + box = Skywatch::Radar::Analysis::Proximity.bbox(lat, lon, radius_nm: radius_nm) + bbox_param = "#{box[:lamin]},#{box[:lomin]},#{box[:lamax]},#{box[:lomax]}" + data = @client.get(ENDPOINT, { bbox: bbox_param, format: 'json' }, ttl: TTL) + data.map { |entry| Skywatch::Briefer::Models::Metar.from_awc(entry) } + .reject { |m| m.latitude.nil? || m.longitude.nil? } + end end end end diff --git a/lib/skywatch/cli.rb b/lib/skywatch/cli.rb index 6d063a7..7288660 100644 --- a/lib/skywatch/cli.rb +++ b/lib/skywatch/cli.rb @@ -17,11 +17,16 @@ 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) + desc 'brief TARGET', 'AIM 7-1-5 weather brief — TARGET is an airport ID (KCDW) or coordinates (LAT,LON)' + def brief(target) + result = if target.include?(',') + lat, lon = target.split(',', 2).map { |s| Float(s.strip) } + Skywatch.brief(at: [lat, lon]) + else + Skywatch.brief(airport: target) + end puts JSON.pretty_generate(result.to_h) - rescue Skywatch::Error => e + rescue ArgumentError, Skywatch::Error => e warn "Error: #{e.message}" exit 1 end diff --git a/spec/brief/analysis/composer_spec.rb b/spec/brief/analysis/composer_spec.rb index 0649fca..0721c06 100644 --- a/spec/brief/analysis/composer_spec.rb +++ b/spec/brief/analysis/composer_spec.rb @@ -183,4 +183,54 @@ expect { composer.compose(airport: 'KZZZ') }.to raise_error(Skywatch::ApiError) end end + + context 'coordinate input' do + it 'requires exactly one of airport: or at:' do + expect { composer.compose }.to raise_error(ArgumentError, /airport|at/) + expect { composer.compose(airport: 'KCDW', at: [40.0, -74.0]) } + .to raise_error(ArgumentError, /airport|at/) + end + + it 'composes a brief from the nearest reporting station when at: is given' do + allow(metar_source).to receive(:fetch_nearest) + .with(lat: 40.875, lon: -74.282).and_return(metar) + brief = composer.compose(at: [40.875, -74.282]) + expect(brief.airport).to eq('KCDW') + expect(brief.coordinates).to eq([40.875, -74.282]) + expect(brief.note).to include('KCDW') + end + + it 'flags the note when nearest station is more than 25 nm away' do + far_metar = Skywatch::Briefer::Models::Metar.new( + station_id: 'KFAR', latitude: 41.5, longitude: -75.0, + visibility_sm: 10, sky_condition: [{ cover: :few, base_ft: 5000 }] + ) + allow(metar_source).to receive(:fetch_nearest).and_return(far_metar) + brief = composer.compose(at: [40.5, -74.0]) + expect(brief.note).to match(/nearest reporting station/i) + expect(brief.note).to match(/\d+(\.\d+)?\s*nm/) + end + + it 'raises when no METAR is reported within the search bbox' do + allow(metar_source).to receive(:fetch_nearest).and_return(nil) + expect { composer.compose(at: [60.0, -150.0]) } + .to raise_error(Skywatch::Error, /no METAR/i) + end + + it 'uses requested coordinates (not station coordinates) for adverse-conditions polygon checks' do + # Sigmet polygon contains the requested point (40.5,-74.5) but NOT the station (40.875,-74.282) + allow(metar_source).to receive(:fetch_nearest).and_return(metar) + sigmet_at_request = Skywatch::Briefer::Models::Sigmet.new(coords: [ + Skywatch::Shared::Position.new(lat: 40.6, lon: -75.5), + Skywatch::Shared::Position.new(lat: 40.6, lon: -73.5), + Skywatch::Shared::Position.new(lat: 39.5, lon: -73.5), + Skywatch::Shared::Position.new(lat: 39.5, lon: -75.5), + Skywatch::Shared::Position.new(lat: 40.6, lon: -75.5) + ]) + allow(sigmet_source).to receive(:fetch).and_return([sigmet_at_request]) + brief = composer.compose(at: [40.5, -74.5]) + kinds = brief.adverse_conditions[:items].map { |i| i[:kind] } + expect(kinds).to include('sigmet') + end + end end diff --git a/spec/brief/cli_spec.rb b/spec/brief/cli_spec.rb index 918649e..bb8f146 100644 --- a/spec/brief/cli_spec.rb +++ b/spec/brief/cli_spec.rb @@ -26,6 +26,22 @@ end end + it 'parses LAT,LON target into a coordinate brief' do + coord_brief = instance_double( + Skywatch::Brief::Models::Brief, + to_h: { airport: 'KCDW', coordinates: [40.688, -74.174], aim_section: '7-1-5' } + ) + allow(Skywatch).to receive(:brief).with(at: [40.688, -74.174]).and_return(coord_brief) + output = capture_stdout { Skywatch::CLI.start(['brief', '40.688,-74.174']) } + parsed = JSON.parse(output) + expect(parsed['coordinates']).to eq([40.688, -74.174]) + end + + it 'exits non-zero when LAT,LON cannot be parsed as floats' do + expect { Skywatch::CLI.start(['brief', '40.688,not-a-number']) } + .to raise_error(SystemExit) { |e| expect(e.status).not_to eq(0) } + end + def capture_stdout old = $stdout $stdout = StringIO.new diff --git a/spec/brief/models/brief_spec.rb b/spec/brief/models/brief_spec.rb index aedb6f2..d337702 100644 --- a/spec/brief/models/brief_spec.rb +++ b/spec/brief/models/brief_spec.rb @@ -81,4 +81,27 @@ it 'serializes to JSON' do expect { JSON.parse(brief.to_json) }.not_to raise_error end + + describe 'note (coord-query metadata)' do + it 'is omitted from to_h when nil (default)' do + expect(brief.to_h).not_to include(:note) + end + + it 'is included in to_h when set' do + coord_brief = described_class.new( + airport: 'KCDW', + coordinates: [40.7, -74.2], + wfo: 'OKX', + fetched_at: Time.utc(2026, 4, 27, 18, 32, 14), + adverse_conditions: { available: true, items: [], partial_failures: [] }, + vfr_not_recommended: slot_available, + current_conditions: slot_available, + destination_forecast: slot_available, + winds_aloft: slot_available, + afd: slot_available, + note: 'data sourced from KCDW (12.0 nm from requested point)' + ) + expect(coord_brief.to_h[:note]).to eq('data sourced from KCDW (12.0 nm from requested point)') + end + end end diff --git a/spec/brief_spec.rb b/spec/brief_spec.rb index 8a6b7c0..561ce56 100644 --- a/spec/brief_spec.rb +++ b/spec/brief_spec.rb @@ -3,12 +3,18 @@ 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) + let(:composer) { instance_double(Skywatch::Brief::Analysis::Composer) } + let(:brief) { instance_double(Skywatch::Brief::Models::Brief) } + + before { allow(Skywatch::Brief::Analysis::Composer).to receive(:new).and_return(composer) } + it 'composes a Brief for an airport' do + expect(composer).to receive(:compose).with(airport: 'KCDW', at: nil).and_return(brief) expect(described_class.brief(airport: 'KCDW')).to be(brief) end + + it 'composes a Brief for a coordinate pair' do + expect(composer).to receive(:compose).with(airport: nil, at: [40.688, -74.174]).and_return(brief) + expect(described_class.brief(at: [40.688, -74.174])).to be(brief) + end end diff --git a/spec/briefer/sources/metar_spec.rb b/spec/briefer/sources/metar_spec.rb index e3391b9..435035b 100644 --- a/spec/briefer/sources/metar_spec.rb +++ b/spec/briefer/sources/metar_spec.rb @@ -59,4 +59,42 @@ expect(metars.first.station_id).to eq('KCDW') end end + + describe '#fetch_nearest' do + let(:requested_lat) { 40.875 } + let(:requested_lon) { -74.282 } + + def stub_bbox(response) + stub_request(:get, 'https://aviationweather.gov/api/data/metar') + .with(query: hash_including(format: 'json')) + .to_return(status: 200, body: response.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the closest METAR by great-circle distance' do + far = JSON.parse(File.read('spec/fixtures/metars/kewr_gusty.json')) # KEWR ~16nm from KCDW + near = JSON.parse(File.read('spec/fixtures/metars/kcdw.json')) # KCDW (the requested point) + stub_bbox([far, near]) + + result = source.fetch_nearest(lat: requested_lat, lon: requested_lon) + expect(result.station_id).to eq('KCDW') + end + + it 'returns nil when no stations are reported in the bbox' do + stub_bbox([]) + expect(source.fetch_nearest(lat: requested_lat, lon: requested_lon)).to be_nil + end + + it 'skips stations missing lat/lon' do + kcdw = JSON.parse(File.read('spec/fixtures/metars/kcdw.json')).merge('lat' => nil, 'lon' => nil) + stub_bbox([kcdw]) + expect(source.fetch_nearest(lat: requested_lat, lon: requested_lon)).to be_nil + end + + it 'sends a bbox query (not a station-id query)' do + stub_bbox([]) + source.fetch_nearest(lat: requested_lat, lon: requested_lon) + expect(WebMock).to have_requested(:get, 'https://aviationweather.gov/api/data/metar') + .with(query: hash_including(:bbox, format: 'json')) + end + end end