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
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: nil, at: nil)
Brief::Analysis::Composer.new.compose(airport: airport, at: at)
def brief(airport: nil, at: nil, departing_at: nil)
Brief::Analysis::Composer.new.compose(airport: airport, at: at, departing_at: departing_at)
end

def crosswind(station_id, runway_heading:)
Expand Down
84 changes: 66 additions & 18 deletions lib/skywatch/brief/analysis/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,32 +34,33 @@ def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new,
end
# rubocop:enable Metrics/ParameterLists

def compose(airport: nil, at: nil)
def compose(airport: nil, at: nil, departing_at: nil)
case [airport.nil?, at.nil?]
when [false, true] then compose_for_airport(airport)
when [true, false] then compose_for_coords(*at)
when [false, true] then compose_for_airport(airport, departing_at: departing_at)
when [true, false] then compose_for_coords(*at, departing_at: departing_at)
else raise ArgumentError, 'must specify exactly one of airport: or at:'
end
end

private

def compose_for_airport(airport)
def compose_for_airport(airport, departing_at: nil)
metar = fetch_metar_or_raise(airport)
lat, lon = AirportLocator.coordinates_from_metar(metar)
build_brief(airport_id: airport.upcase, requested_lat: lat, requested_lon: lon,
metar: metar, note: nil)
metar: metar, note: nil, departing_at: departing_at)
end

def compose_for_coords(req_lat, req_lon)
def compose_for_coords(req_lat, req_lon, departing_at: nil)
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))
metar: metar, note: nearest_station_note(metar.station_id, distance),
departing_at: departing_at)
end

def nearest_station_note(station_id, distance_nm)
Expand All @@ -72,31 +73,33 @@ def nearest_station_note(station_id, distance_nm)
end
end

# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:)
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:, departing_at: nil)
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] || [])
fcst = winds_fcst_for(departing_at)

Models::Brief.new(
airport: airport_id,
coordinates: [requested_lat, requested_lon],
wfo: wfo,
fetched_at: Time.now.utc,
departing_at: departing_at,
adverse_conditions: build_adverse(
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_id) },
winds_aloft: wrap('winds aloft') { build_winds(airport_id) },
destination_forecast: wrap('TAF') { build_destination(airport_id, departing_at: departing_at) },
winds_aloft: wrap('winds aloft') { build_winds(airport_id, fcst: fcst) },
afd: wfo.nil? ? unavailable_afd_for_no_wfo : wrap('AFD') { build_afd(wfo) },
note: note
)
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists

def fetch_metar_or_raise(airport)
metars = @metar_source.fetch(airport)
Expand Down Expand Up @@ -198,17 +201,62 @@ def build_current(metar:, pirep_attempt:, informational:)
end
end

def build_destination(airport)
# rubocop:disable Metrics/MethodLength
def build_destination(airport, departing_at: nil)
tafs = @taf_source.fetch(airport)
if tafs.empty?
{ available: false, reason: "no TAF for #{airport.upcase}" }
return { available: false, reason: "no TAF for #{airport.upcase}" } if tafs.empty?

taf = tafs.first
if departing_at
group = taf.group_at(departing_at)
if group
etd_taf = taf_with_single_group(taf, group)
return { available: true, taf: etd_taf.to_h }
else
return {
available: true,
taf: taf_with_single_group(taf, taf.forecast_groups.first).to_h,
note: 'ETD outside TAF valid window; showing initial group'
}
end
end

{ available: true, taf: taf.to_h }
end
# rubocop:enable Metrics/MethodLength

# rubocop:disable Metrics/MethodLength
def taf_with_single_group(taf, group)
Skywatch::Briefer::Models::Taf.new(
station_id: taf.station_id,
raw: taf.raw,
issued_at: taf.issued_at,
valid_from: taf.valid_from,
valid_to: taf.valid_to,
station_name: taf.station_name,
latitude: taf.latitude,
longitude: taf.longitude,
elevation_ft: taf.elevation_ft,
forecast_groups: [group]
)
end
# rubocop:enable Metrics/MethodLength

def winds_fcst_for(departing_at)
return '06' if departing_at.nil?

hours = (departing_at - Time.now) / 3600.0
if hours <= 6
'06'
elsif hours <= 18
'12'
else
{ available: true, taf: tafs.first.to_h }
'24'
end
end

def build_winds(airport)
forecasts = @winds_source.fetch(airport)
def build_winds(airport, fcst: '06')
forecasts = @winds_source.fetch(airport, fcst: fcst)
if forecasts.empty?
{ available: false, reason: "no winds aloft for #{airport.upcase}" }
else
Expand Down
7 changes: 5 additions & 2 deletions lib/skywatch/brief/models/brief.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ class Brief

attr_reader :airport, :coordinates, :wfo, :fetched_at,
:adverse_conditions, :vfr_not_recommended,
:current_conditions, :destination_forecast, :winds_aloft, :afd, :note
:current_conditions, :destination_forecast, :winds_aloft, :afd, :note,
:departing_at

# 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:,
note: nil)
note: nil, departing_at: nil)
@airport = airport
@coordinates = coordinates
@wfo = wfo
Expand All @@ -49,6 +50,7 @@ def initialize(airport:, coordinates:, wfo:, fetched_at:,
@winds_aloft = winds_aloft
@afd = afd
@note = note
@departing_at = departing_at
end
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength

Expand All @@ -59,6 +61,7 @@ def to_h
coordinates: coordinates,
wfo: wfo,
fetched_at: fetched_at&.iso8601,
departing_at: departing_at&.iso8601,
aim_section: AIM_SECTION,
adverse_conditions: adverse_conditions,
vfr_not_recommended: vfr_not_recommended,
Expand Down
4 changes: 2 additions & 2 deletions lib/skywatch/briefer/sources/winds_aloft.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def initialize(client: Skywatch.client)
@client = client
end

def fetch(station_id, altitude_ft: nil)
body = @client.get_raw(ENDPOINT, { region: 'all', level: 'low', fcst: '06', format: 'json' }, ttl: TTL)
def fetch(station_id, altitude_ft: nil, fcst: '06')
body = @client.get_raw(ENDPOINT, { region: 'all', level: 'low', fcst: fcst, format: 'json' }, ttl: TTL)
line = find_station_line(body, station_id)
return [] unless line

Expand Down
20 changes: 18 additions & 2 deletions lib/skywatch/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,38 @@ class CLI < Thor
subcommand 'nimbus', Skywatch::Nimbus::CLI

desc 'brief TARGET', 'AIM 7-1-5 weather brief — TARGET is an airport ID (KCDW) or coordinates (LAT,LON)'
method_option :departing_at, type: :string, aliases: '--departing-at',
desc: 'Estimated time of departure (ISO8601 or any Time.parse-able format)'
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
def brief(target)
etd = parse_etd(options[:departing_at])
result = if target.include?(',')
lat, lon = target.split(',', 2).map { |s| Float(s.strip) }
Skywatch.brief(at: [lat, lon])
Skywatch.brief(at: [lat, lon], departing_at: etd)
else
Skywatch.brief(airport: target)
Skywatch.brief(airport: target, departing_at: etd)
end
puts JSON.pretty_generate(result.to_h)
rescue ArgumentError, Skywatch::Error => e
warn "Error: #{e.message}"
exit 1
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize

desc 'version', 'Print version'
def version
puts "skywatch #{Skywatch::VERSION}"
end

private

def parse_etd(raw)
return nil if raw.nil?

Time.parse(raw)
rescue ArgumentError
warn "Error: cannot parse --departing-at value: #{raw.inspect}"
exit 1
end
end
end
104 changes: 104 additions & 0 deletions spec/brief/analysis/composer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,110 @@
end
end

context 'departing_at (ETD) support' do
let(:now) { Time.utc(2026, 5, 1, 12, 0, 0) }

let(:group_now) do
Skywatch::Briefer::Models::TafGroup.new(
time_from: Time.utc(2026, 5, 1, 12, 0, 0),
time_to: Time.utc(2026, 5, 1, 15, 0, 0),
change_type: :initial, wind_direction_deg: 270, wind_speed_kt: 10,
visibility_sm: 10, sky_condition: []
)
end

let(:group_etd) do
Skywatch::Briefer::Models::TafGroup.new(
time_from: Time.utc(2026, 5, 1, 15, 0, 0),
time_to: Time.utc(2026, 5, 1, 18, 0, 0),
change_type: :fm, wind_direction_deg: 180, wind_speed_kt: 15,
visibility_sm: 5, sky_condition: []
)
end

let(:taf) do
Skywatch::Briefer::Models::Taf.new(
station_id: 'KCDW', raw: 'TAF KCDW ...', issued_at: Time.utc(2026, 5, 1, 11, 0, 0),
valid_from: Time.utc(2026, 5, 1, 12, 0, 0), valid_to: Time.utc(2026, 5, 2, 12, 0, 0),
forecast_groups: [group_now, group_etd]
)
end

let(:winds_forecast) do
Skywatch::Briefer::Models::WindsAloft.new(
station_id: 'KCDW', altitude_ft: 6000, wind_direction_deg: 270,
wind_speed_kt: 25, temperature_c: 5
)
end

before do
allow(taf_source).to receive(:fetch).and_return([taf])
end

it 'sets departing_at on the returned brief' do
etd = Time.utc(2026, 5, 1, 16, 0, 0)
brief = composer.compose(airport: 'KCDW', departing_at: etd)
expect(brief.departing_at).to eq(etd)
end

it 'selects the TAF group active at ETD for destination_forecast' do
etd = Time.utc(2026, 5, 1, 16, 0, 0) # falls in group_etd window
brief = composer.compose(airport: 'KCDW', departing_at: etd)
slot = brief.destination_forecast
expect(slot[:available]).to be true
# group_etd has wind_direction_deg 180
expect(slot[:taf][:forecast_groups].first[:wind_direction_deg]).to eq(180)
end

it 'falls back to first TAF group with a note when ETD is outside valid window' do
etd = Time.utc(2026, 5, 3, 0, 0, 0) # beyond valid_to
brief = composer.compose(airport: 'KCDW', departing_at: etd)
slot = brief.destination_forecast
expect(slot[:available]).to be true
expect(slot[:note]).to match(/ETD outside TAF valid window/)
# fallback = first group = group_now with wind_direction_deg 270
expect(slot[:taf][:forecast_groups].first[:wind_direction_deg]).to eq(270)
end

it 'uses fcst 06 when ETD is within 6 hours' do
etd = now + (3 * 3600) # 3 hours from now
expect(winds_source).to receive(:fetch).with('KCDW', fcst: '06').and_return([winds_forecast])
composer.compose(airport: 'KCDW', departing_at: etd)
end

it 'uses fcst 12 when ETD is 6-18 hours away' do
etd = now + (10 * 3600) # 10 hours from now
expect(winds_source).to receive(:fetch).with('KCDW', fcst: '12').and_return([winds_forecast])
allow(Time).to receive(:now).and_return(now)
composer.compose(airport: 'KCDW', departing_at: etd)
end

it 'uses fcst 24 when ETD is more than 18 hours away' do
etd = now + (20 * 3600) # 20 hours from now
expect(winds_source).to receive(:fetch).with('KCDW', fcst: '24').and_return([winds_forecast])
allow(Time).to receive(:now).and_return(now)
composer.compose(airport: 'KCDW', departing_at: etd)
end

it 'uses default fcst 06 when no ETD is given' do
expect(winds_source).to receive(:fetch).with('KCDW', fcst: '06').and_return([winds_forecast])
composer.compose(airport: 'KCDW')
end

it 'departing_at is nil on brief when not given' do
brief = composer.compose(airport: 'KCDW')
expect(brief.departing_at).to be_nil
end

it 'works with coordinate input too' do
etd = Time.utc(2026, 5, 1, 16, 0, 0)
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], departing_at: etd)
expect(brief.departing_at).to eq(etd)
end
end

context 'coordinate input' do
it 'requires exactly one of airport: or at:' do
expect { composer.compose }.to raise_error(ArgumentError, /airport|at/)
Expand Down
Loading