Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
4 changes: 2 additions & 2 deletions lib/skywatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
Expand Down
60 changes: 49 additions & 11 deletions lib/skywatch/brief/analysis/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions lib/skywatch/brief/models/brief.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions lib/skywatch/briefer/sources/metar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions lib/skywatch/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions spec/brief/analysis/composer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions spec/brief/cli_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions spec/brief/models/brief_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 11 additions & 5 deletions spec/brief_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions spec/briefer/sources/metar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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