From e9fbdd6917ebac644b14d857ed786e21cc80a9b4 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Fri, 1 May 2026 21:34:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(brief):=20support=20ETD=20=E2=80=94=20Skyw?= =?UTF-8?q?atch.brief(...,=20departing=5Fat:)=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add departing_at: keyword to Skywatch.brief, Composer#compose, and Brief model. When an ETD is given, TAF group selection uses taf.group_at(etd) (falling back to the first group with a note if ETD is outside the valid window) and winds-aloft fetches the bulletin closest to ETD (fcst 06/12/24 based on hours from now). The CLI accepts --departing-at with Time.parse validation. Backward compat: departing_at defaults to nil; behavior is unchanged when omitted. Co-Authored-By: Claude Sonnet 4.6 --- lib/skywatch.rb | 4 +- lib/skywatch/brief/analysis/composer.rb | 84 ++++++++++++---- lib/skywatch/brief/models/brief.rb | 7 +- lib/skywatch/briefer/sources/winds_aloft.rb | 4 +- lib/skywatch/cli.rb | 20 +++- spec/brief/analysis/composer_spec.rb | 104 ++++++++++++++++++++ spec/brief/cli_spec.rb | 33 ++++++- spec/brief/models/brief_spec.rb | 30 ++++++ spec/brief_spec.rb | 10 +- 9 files changed, 266 insertions(+), 30 deletions(-) diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 7be118f..544a0cb 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: 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:) diff --git a/lib/skywatch/brief/analysis/composer.rb b/lib/skywatch/brief/analysis/composer.rb index 09106bd..389c403 100644 --- a/lib/skywatch/brief/analysis/composer.rb +++ b/lib/skywatch/brief/analysis/composer.rb @@ -34,24 +34,24 @@ 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? @@ -59,7 +59,8 @@ def compose_for_coords(req_lat, req_lon) 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) @@ -72,17 +73,19 @@ 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 @@ -90,13 +93,13 @@ def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:) 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) @@ -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 diff --git a/lib/skywatch/brief/models/brief.rb b/lib/skywatch/brief/models/brief.rb index bfc187e..1d483ef 100644 --- a/lib/skywatch/brief/models/brief.rb +++ b/lib/skywatch/brief/models/brief.rb @@ -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 @@ -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 @@ -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, diff --git a/lib/skywatch/briefer/sources/winds_aloft.rb b/lib/skywatch/briefer/sources/winds_aloft.rb index c9248c6..03fbbca 100644 --- a/lib/skywatch/briefer/sources/winds_aloft.rb +++ b/lib/skywatch/briefer/sources/winds_aloft.rb @@ -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 diff --git a/lib/skywatch/cli.rb b/lib/skywatch/cli.rb index 7288660..29e0c25 100644 --- a/lib/skywatch/cli.rb +++ b/lib/skywatch/cli.rb @@ -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 diff --git a/spec/brief/analysis/composer_spec.rb b/spec/brief/analysis/composer_spec.rb index 0721c06..da15cd8 100644 --- a/spec/brief/analysis/composer_spec.rb +++ b/spec/brief/analysis/composer_spec.rb @@ -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/) diff --git a/spec/brief/cli_spec.rb b/spec/brief/cli_spec.rb index bb8f146..e3c5efe 100644 --- a/spec/brief/cli_spec.rb +++ b/spec/brief/cli_spec.rb @@ -10,7 +10,7 @@ ) end - before { allow(Skywatch).to receive(:brief).with(airport: 'KCDW').and_return(brief) } + before { allow(Skywatch).to receive(:brief).with(airport: 'KCDW', departing_at: nil).and_return(brief) } it 'prints the brief as JSON' do output = capture_stdout { Skywatch::CLI.start(%w[brief KCDW]) } @@ -31,7 +31,7 @@ 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) + allow(Skywatch).to receive(:brief).with(at: [40.688, -74.174], departing_at: nil).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]) @@ -42,6 +42,35 @@ .to raise_error(SystemExit) { |e| expect(e.status).not_to eq(0) } end + context '--departing-at option' do + let(:etd) { Time.parse('2026-05-01T16:00:00Z') } + + it 'passes departing_at to Skywatch.brief when --departing-at is given' do + expect(Skywatch).to receive(:brief) + .with(airport: 'KCDW', departing_at: etd) + .and_return(brief) + capture_stdout { Skywatch::CLI.start(['brief', 'KCDW', '--departing-at', '2026-05-01T16:00:00Z']) } + end + + it 'passes departing_at with coordinate input' do + coord_brief = instance_double( + Skywatch::Brief::Models::Brief, + to_h: { airport: 'KCDW', coordinates: [40.688, -74.174], aim_section: '7-1-5' } + ) + expect(Skywatch).to receive(:brief) + .with(at: [40.688, -74.174], departing_at: etd) + .and_return(coord_brief) + capture_stdout do + Skywatch::CLI.start(['brief', '40.688,-74.174', '--departing-at', '2026-05-01T16:00:00Z']) + end + end + + it 'exits non-zero when --departing-at cannot be parsed' do + expect { Skywatch::CLI.start(['brief', 'KCDW', '--departing-at', 'not-a-time']) } + .to raise_error(SystemExit) { |e| expect(e.status).not_to eq(0) } + end + 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 d337702..78e2d53 100644 --- a/spec/brief/models/brief_spec.rb +++ b/spec/brief/models/brief_spec.rb @@ -104,4 +104,34 @@ expect(coord_brief.to_h[:note]).to eq('data sourced from KCDW (12.0 nm from requested point)') end end + + describe 'departing_at' do + it 'is nil by default' do + expect(brief.departing_at).to be_nil + end + + it 'is always present in to_h (even when nil)' do + expect(brief.to_h).to include(:departing_at) + expect(brief.to_h[:departing_at]).to be_nil + end + + it 'serializes as ISO8601 string when set' do + etd = Time.utc(2026, 5, 1, 14, 30, 0) + etd_brief = described_class.new( + airport: 'KCDW', + coordinates: [40.875, -74.282], + wfo: 'OKX', + fetched_at: Time.utc(2026, 5, 1, 12, 0, 0), + 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, + departing_at: etd + ) + expect(etd_brief.departing_at).to eq(etd) + expect(etd_brief.to_h[:departing_at]).to eq('2026-05-01T14:30:00Z') + end + end end diff --git a/spec/brief_spec.rb b/spec/brief_spec.rb index 561ce56..b2d08dd 100644 --- a/spec/brief_spec.rb +++ b/spec/brief_spec.rb @@ -9,12 +9,18 @@ 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(composer).to receive(:compose).with(airport: 'KCDW', at: nil, departing_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(composer).to receive(:compose).with(airport: nil, at: [40.688, -74.174], departing_at: nil).and_return(brief) expect(described_class.brief(at: [40.688, -74.174])).to be(brief) end + + it 'passes departing_at through to composer' do + etd = Time.utc(2026, 5, 1, 16, 0, 0) + expect(composer).to receive(:compose).with(airport: 'KCDW', at: nil, departing_at: etd).and_return(brief) + expect(described_class.brief(airport: 'KCDW', departing_at: etd)).to be(brief) + end end