diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 544a0cb..105202d 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -44,6 +44,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/analysis/route_corridor' require_relative 'skywatch/brief/models/brief' require_relative 'skywatch/brief/analysis/composer' @@ -143,8 +144,9 @@ def smoke(at:) Nimbus::Sources::Smoke.new.fetch(at: at) end - def brief(airport: nil, at: nil, departing_at: nil) - Brief::Analysis::Composer.new.compose(airport: airport, at: at, departing_at: departing_at) + def brief(airport: nil, at: nil, departing_at: nil, from: nil, to: nil) + Brief::Analysis::Composer.new.compose(airport: airport, at: at, departing_at: departing_at, + from: from, to: to) end def crosswind(station_id, runway_heading:) diff --git a/lib/skywatch/brief/analysis/composer.rb b/lib/skywatch/brief/analysis/composer.rb index 389c403..dd9a135 100644 --- a/lib/skywatch/brief/analysis/composer.rb +++ b/lib/skywatch/brief/analysis/composer.rb @@ -9,6 +9,7 @@ class Composer # rubocop:disable Metrics/ClassLength ADVERSE_RADIUS_NM = 100 STORM_REPORT_LOOKBACK_HOURS = 6 NEAR_STATION_THRESHOLD_NM = 25 + CORRIDOR_SPACING_NM = 25 # rubocop:disable Metrics/ParameterLists def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, @@ -34,13 +35,24 @@ def initialize(metar_source: Skywatch::Briefer::Sources::Metar.new, end # rubocop:enable Metrics/ParameterLists - def compose(airport: nil, at: nil, departing_at: nil) - case [airport.nil?, at.nil?] - 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:' + # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity + def compose(airport: nil, at: nil, departing_at: nil, from: nil, to: nil) + route = !from.nil? || !to.nil? + + if route + raise ArgumentError, 'cannot mix from:/to: with at:' if at + raise ArgumentError, 'must specify both from: and to: for a route brief' if from.nil? || to.nil? + + compose_for_route(from, to, departing_at: departing_at) + else + case [airport.nil?, at.nil?] + 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 end + # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity private @@ -63,6 +75,35 @@ def compose_for_coords(req_lat, req_lon, departing_at: nil) departing_at: departing_at) end + def compose_for_route(from, to, departing_at: nil) # rubocop:disable Metrics/MethodLength + from_metar = fetch_metar_or_raise(from) + from_lat, from_lon = AirportLocator.coordinates_from_metar(from_metar) + + to_metar = fetch_metar_or_raise(to) + to_lat, to_lon = AirportLocator.coordinates_from_metar(to_metar) + + dist = RouteCorridor.distance_nm(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon) + bearing = RouteCorridor.bearing_deg(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon) + + destination_field = { + airport: to.upcase, + coordinates: [to_lat, to_lon], + distance_nm: dist, + bearing_deg: bearing.round(1) + } + + enroute = build_enroute(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon, + from_airport: from.upcase, departing_at: departing_at) + + build_brief(airport_id: from.upcase, requested_lat: from_lat, requested_lon: from_lon, + metar: from_metar, note: nil, departing_at: departing_at, + destination_airport: to.upcase, destination_field: destination_field, + enroute_forecast: enroute) + 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 " \ @@ -74,12 +115,16 @@ def nearest_station_note(station_id, distance_nm) end # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists - def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:, departing_at: nil) + def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:, departing_at: nil, + destination_airport: nil, destination_field: nil, enroute_forecast: 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) + # For route briefs, TAF is for the destination; for single-point, TAF is for the airport. + taf_airport = destination_airport || airport_id + Models::Brief.new( airport: airport_id, coordinates: [requested_lat, requested_lon], @@ -93,10 +138,12 @@ def build_brief(airport_id:, requested_lat:, requested_lon:, metar:, note:, depa 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, departing_at: departing_at) }, + destination_forecast: wrap('TAF') { build_destination(taf_airport, 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 + note: note, + destination: destination_field, + enroute_forecast: enroute_forecast ) end # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists @@ -166,6 +213,118 @@ def build_adverse(lat:, lon:, urgent_pireps:, pirep_attempt:) end # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def build_enroute(from_lat:, from_lon:, to_lat:, to_lon:, from_airport:, departing_at:) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists + waypoints = RouteCorridor.waypoints(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon, + spacing_nm: CORRIDOR_SPACING_NM) + total_nm = RouteCorridor.distance_nm(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon) + bearing = RouteCorridor.bearing_deg(from_lat: from_lat, from_lon: from_lon, + to_lat: to_lat, to_lon: to_lon) + + all_sigmets = attempt { @sigmet_source.fetch } + all_airmets = attempt { @airmet_source.fetch } + all_storms = attempt { recent_storms(@storm_source.fetch) } + all_pireps = attempt { @pirep_source.fetch(from_airport, radius_nm: total_nm.ceil) } + + sigmet_items = corridor_polygon_items(all_sigmets[:value] || [], waypoints, 'sigmet') + airmet_items = corridor_polygon_items(all_airmets[:value] || [], waypoints, 'airmet') + pirep_items = corridor_within_items(all_pireps[:value] || [], waypoints, 'pirep') + storm_items = corridor_within_items(all_storms[:value] || [], waypoints, 'storm_report') + smoke_items = corridor_smoke_items(waypoints, 'smoke') + alert_items = corridor_alert_items(waypoints, 'convective_alert') + + partial_failures = [] + partial_failures << { source: 'sigmet', reason: all_sigmets[:error] } if all_sigmets[:error] + partial_failures << { source: 'airmet', reason: all_airmets[:error] } if all_airmets[:error] + partial_failures << { source: 'storm_report', reason: all_storms[:error] } if all_storms[:error] + partial_failures << { source: 'pirep', reason: all_pireps[:error] } if all_pireps[:error] + partial_failures.concat(smoke_items[:failures]) + partial_failures.concat(alert_items[:failures]) + + items = (sigmet_items + airmet_items + pirep_items + + storm_items + smoke_items[:items] + alert_items[:items]) + .uniq { |i| enroute_dedupe_key(i) } + + { + available: true, + items: items, + partial_failures: partial_failures, + corridor: { + waypoints: waypoints.size, + spacing_nm: CORRIDOR_SPACING_NM, + distance_nm: total_nm, + bearing_deg: bearing.round(1) + } + } + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def enroute_dedupe_key(item) + kind = item[:kind] + # polygon products: stable id from tag/id fields + case kind + when 'sigmet', 'airmet' + [kind, item[:hazard] || item[:tag] || item[:id] || item.hash] + when 'convective_alert' + [kind, item[:id] || item[:event] || item.hash] + else + # point products: dedupe by kind + raw or observed_at + [kind, item[:raw] || item[:observed_at] || item[:time] || item.hash] + end + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def corridor_polygon_items(products, waypoints, kind) + matching = products.select do |product| + waypoints.any? { |lat, lon| AdverseFilter.covers?(product, lat, lon) } + end + matching.map { |p| { kind: kind }.merge(p.to_h) } + end + + def corridor_within_items(items, waypoints, kind) + matching = items.select do |item| + waypoints.any? do |lat, lon| + Skywatch::Radar::Analysis::Proximity.distance_nm(lat, lon, item.latitude, item.longitude) <= + CORRIDOR_SPACING_NM + end + end + matching.map { |i| { kind: kind }.merge(i.to_h) } + end + + def corridor_smoke_items(waypoints, kind) + collect_per_waypoint_items(waypoints, kind) { |lat, lon| @smoke_source.fetch(at: [lat, lon]) } + end + + def corridor_alert_items(waypoints, kind) + collect_per_waypoint_items(waypoints, kind) { |lat, lon| @alerts_source.fetch(at: [lat, lon]) } + end + + def collect_per_waypoint_items(waypoints, kind) # rubocop:disable Metrics/MethodLength + seen_hashes = [] + items = [] + failures = [] + + waypoints.each do |lat, lon| + result = attempt { yield(lat, lon) } + if result[:error] + failures << { source: kind, reason: result[:error] } + else + (result[:value] || []).each do |obj| + h = obj.to_h + next if seen_hashes.include?(h) + + seen_hashes << h + items << { kind: kind }.merge(h) + end + end + end + + { items: items, failures: failures } + end + def recent_storms(storms) cutoff = Time.now.utc - (STORM_REPORT_LOOKBACK_HOURS * 3600) storms.select { |s| s.time && s.time >= cutoff } diff --git a/lib/skywatch/brief/analysis/route_corridor.rb b/lib/skywatch/brief/analysis/route_corridor.rb new file mode 100644 index 0000000..3e1e7d5 --- /dev/null +++ b/lib/skywatch/brief/analysis/route_corridor.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Skywatch + module Brief + module Analysis + module RouteCorridor + EARTH_RADIUS_NM = 3440.065 + + # Returns array of [lat, lon] pairs (in degrees) along the great-circle path, + # sampled at most spacing_nm apart, including both endpoints. + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def self.waypoints(from_lat:, from_lon:, to_lat:, to_lon:, spacing_nm: 25) + lat1 = from_lat * Math::PI / 180 + lon1 = from_lon * Math::PI / 180 + lat2 = to_lat * Math::PI / 180 + lon2 = to_lon * Math::PI / 180 + + d_rad = haversine_distance_rad(lat1, lon1, lat2, lon2) + total_nm = d_rad * EARTH_RADIUS_NM + + n = [1, (total_nm / spacing_nm).ceil].max + + (0..n).map do |i| + fraction = i.to_f / n + rlat, rlon = interpolate(lat1, lon1, lat2, lon2, fraction, d_rad) + [(rlat * 180 / Math::PI).round(6), (rlon * 180 / Math::PI).round(6)] + end + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + # Initial bearing (degrees, 0-360) from (from_lat, from_lon) to (to_lat, to_lon). + # rubocop:disable Metrics/AbcSize + def self.bearing_deg(from_lat:, from_lon:, to_lat:, to_lon:) + lat1 = from_lat * Math::PI / 180 + lon1 = from_lon * Math::PI / 180 + lat2 = to_lat * Math::PI / 180 + lon2 = to_lon * Math::PI / 180 + + dlon = lon2 - lon1 + y = Math.sin(dlon) * Math.cos(lat2) + x = (Math.cos(lat1) * Math.sin(lat2)) - (Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlon)) + theta = Math.atan2(y, x) * 180 / Math::PI + (theta + 360) % 360 + end + # rubocop:enable Metrics/AbcSize + + # Total great-circle distance in nautical miles. + def self.distance_nm(from_lat:, from_lon:, to_lat:, to_lon:) + lat1 = from_lat * Math::PI / 180 + lon1 = from_lon * Math::PI / 180 + lat2 = to_lat * Math::PI / 180 + lon2 = to_lon * Math::PI / 180 + d_rad = haversine_distance_rad(lat1, lon1, lat2, lon2) + (d_rad * EARTH_RADIUS_NM).round(1) + end + + # Spherical linear interpolation between two points given as radians. + # d is the pre-computed angular distance (radians). + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists + def self.interpolate(lat1, lon1, lat2, lon2, fraction, angular_dist = nil) + angular_dist ||= haversine_distance_rad(lat1, lon1, lat2, lon2) + return [lat1, lon1] if angular_dist.zero? || fraction.zero? + return [lat2, lon2] if (fraction - 1.0).abs < Float::EPSILON + + a = Math.sin((1 - fraction) * angular_dist) / Math.sin(angular_dist) + b = Math.sin(fraction * angular_dist) / Math.sin(angular_dist) + x = (a * Math.cos(lat1) * Math.cos(lon1)) + (b * Math.cos(lat2) * Math.cos(lon2)) + y = (a * Math.cos(lat1) * Math.sin(lon1)) + (b * Math.cos(lat2) * Math.sin(lon2)) + z = (a * Math.sin(lat1)) + (b * Math.sin(lat2)) + rlat = Math.atan2(z, Math.sqrt((x * x) + (y * y))) + rlon = Math.atan2(y, x) + [rlat, rlon] + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists + + # Haversine angular distance in radians between two points given in radians. + # rubocop:disable Metrics/AbcSize + def self.haversine_distance_rad(lat1, lon1, lat2, lon2) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = (Math.sin(dlat / 2)**2) + (Math.cos(lat1) * Math.cos(lat2) * (Math.sin(dlon / 2)**2)) + 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + end + # rubocop:enable Metrics/AbcSize + + private_class_method :interpolate, :haversine_distance_rad + end + end + end +end diff --git a/lib/skywatch/brief/models/brief.rb b/lib/skywatch/brief/models/brief.rb index 1d483ef..dd8a486 100644 --- a/lib/skywatch/brief/models/brief.rb +++ b/lib/skywatch/brief/models/brief.rb @@ -32,13 +32,13 @@ class Brief attr_reader :airport, :coordinates, :wfo, :fetched_at, :adverse_conditions, :vfr_not_recommended, :current_conditions, :destination_forecast, :winds_aloft, :afd, :note, - :departing_at + :departing_at, :destination, :enroute_forecast # 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, departing_at: nil) + note: nil, departing_at: nil, destination: nil, enroute_forecast: nil) @airport = airport @coordinates = coordinates @wfo = wfo @@ -51,10 +51,12 @@ def initialize(airport:, coordinates:, wfo:, fetched_at:, @afd = afd @note = note @departing_at = departing_at + @destination = destination + @enroute_forecast = enroute_forecast end # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength - # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def to_h base = { airport: airport, @@ -67,16 +69,17 @@ def to_h vfr_not_recommended: vfr_not_recommended, synopsis: SYNOPSIS_UNAVAILABLE, current_conditions: current_conditions, - enroute_forecast: ENROUTE_UNAVAILABLE, + enroute_forecast: @enroute_forecast || ENROUTE_UNAVAILABLE, destination_forecast: destination_forecast, winds_aloft: winds_aloft, notams: NOTAMS_UNAVAILABLE, atc_delays: ATC_DELAYS_UNAVAILABLE, afd: afd } + base = base.merge(destination: destination) if destination note ? base.merge(note: note) : base end - # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize def to_json(*) to_h.to_json(*) diff --git a/lib/skywatch/cli.rb b/lib/skywatch/cli.rb index 29e0c25..69a6727 100644 --- a/lib/skywatch/cli.rb +++ b/lib/skywatch/cli.rb @@ -20,10 +20,14 @@ class CLI < Thor 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)' + method_option :to, type: :string, + desc: 'Destination airport ID for a route brief (e.g. KACY)' # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def brief(target) etd = parse_etd(options[:departing_at]) - result = if target.include?(',') + result = if options[:to] + Skywatch.brief(from: target, to: options[:to], departing_at: etd) + elsif target.include?(',') lat, lon = target.split(',', 2).map { |s| Float(s.strip) } Skywatch.brief(at: [lat, lon], departing_at: etd) else diff --git a/spec/brief/analysis/composer_spec.rb b/spec/brief/analysis/composer_spec.rb index da15cd8..7dac236 100644 --- a/spec/brief/analysis/composer_spec.rb +++ b/spec/brief/analysis/composer_spec.rb @@ -337,4 +337,158 @@ expect(kinds).to include('sigmet') end end + + context 'route input (from: + to:)' do + let(:metar_kacy) do + Skywatch::Briefer::Models::Metar.new( + station_id: 'KACY', latitude: 39.457, longitude: -74.577, + visibility_sm: 10, sky_condition: [{ cover: :few, base_ft: 5000 }] + ) + end + + before do + # from: KCDW fetches metar (already set via let(:metar)) + # to: KACY fetches metar_kacy + allow(metar_source).to receive(:fetch).with('KCDW').and_return([metar]) + allow(metar_source).to receive(:fetch).with('KACY').and_return([metar_kacy]) + end + + it 'raises when from: is given without to:' do + expect { composer.compose(from: 'KCDW') } + .to raise_error(ArgumentError, /from.*to|to.*from/i) + end + + it 'raises when to: is given without from:' do + expect { composer.compose(to: 'KACY') } + .to raise_error(ArgumentError, /from.*to|to.*from/i) + end + + it 'raises when from:/to: are mixed with at:' do + expect { composer.compose(from: 'KCDW', to: 'KACY', at: [40.5, -74.0]) } + .to raise_error(ArgumentError, /at/) + end + + it 'returns a Brief anchored at the from airport' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + expect(brief.airport).to eq('KCDW') + expect(brief.coordinates).to eq([40.875, -74.282]) + end + + it 'populates destination field with to airport metadata' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + dest = brief.destination + expect(dest).not_to be_nil + expect(dest[:airport]).to eq('KACY') + expect(dest[:coordinates]).to eq([39.457, -74.577]) + expect(dest[:distance_nm]).to be_a(Numeric) + expect(dest[:bearing_deg]).to be_a(Numeric) + end + + it 'includes destination field in to_h for route brief' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + expect(brief.to_h).to include(:destination) + expect(brief.to_h[:destination][:airport]).to eq('KACY') + end + + it 'does not include destination in to_h for single-airport brief' do + brief = composer.compose(airport: 'KCDW') + expect(brief.to_h).not_to include(:destination) + end + + it 'computes southerly bearing from KCDW to KACY' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + bearing = brief.destination[:bearing_deg] + expect(bearing).to be_between(170, 210) + end + + it 'populates enroute_forecast with available: true' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + enroute = brief.enroute_forecast + expect(enroute[:available]).to be true + end + + it 'includes corridor metadata in enroute_forecast' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + corridor = brief.enroute_forecast[:corridor] + expect(corridor[:waypoints]).to be > 1 + expect(corridor[:spacing_nm]).to eq(25) + expect(corridor[:distance_nm]).to be_a(Numeric) + expect(corridor[:bearing_deg]).to be_a(Numeric) + end + + it 'enroute_forecast has items and partial_failures keys' do + brief = composer.compose(from: 'KCDW', to: 'KACY') + enroute = brief.enroute_forecast + expect(enroute).to include(:items, :partial_failures) + end + + it 'uses the to-airport TAF for destination_forecast' do + taf_kacy = Skywatch::Briefer::Models::Taf.new( + station_id: 'KACY', raw: 'TAF KACY ...', issued_at: Time.utc(2026, 5, 1, 11), + valid_from: Time.utc(2026, 5, 1, 12), valid_to: Time.utc(2026, 5, 2, 12), + forecast_groups: [] + ) + allow(taf_source).to receive(:fetch).with('KACY').and_return([taf_kacy]) + allow(taf_source).to receive(:fetch).with('KCDW').and_return([]) + + brief = composer.compose(from: 'KCDW', to: 'KACY') + expect(brief.destination_forecast[:available]).to be true + expect(brief.destination_forecast[:taf][:station_id]).to eq('KACY') + end + + it 'enroute_forecast includes a SIGMET that intersects the corridor' do + # Build a wide sigmet that covers the entire KCDW-KACY corridor + corridor_sigmet = Skywatch::Briefer::Models::Sigmet.new(coords: [ + Skywatch::Shared::Position.new(lat: 41.5, lon: -75.5), + Skywatch::Shared::Position.new(lat: 41.5, lon: -73.5), + Skywatch::Shared::Position.new(lat: 38.5, lon: -73.5), + Skywatch::Shared::Position.new(lat: 38.5, lon: -75.5), + Skywatch::Shared::Position.new(lat: 41.5, lon: -75.5) + ]) + allow(sigmet_source).to receive(:fetch).and_return([corridor_sigmet]) + brief = composer.compose(from: 'KCDW', to: 'KACY') + kinds = brief.enroute_forecast[:items].map { |i| i[:kind] } + expect(kinds).to include('sigmet') + end + + it 'deduplicates sigmets that cover multiple waypoints' do + corridor_sigmet = Skywatch::Briefer::Models::Sigmet.new(coords: [ + Skywatch::Shared::Position.new(lat: 41.5, lon: -75.5), + Skywatch::Shared::Position.new(lat: 41.5, lon: -73.5), + Skywatch::Shared::Position.new(lat: 38.5, lon: -73.5), + Skywatch::Shared::Position.new(lat: 38.5, lon: -75.5), + Skywatch::Shared::Position.new(lat: 41.5, lon: -75.5) + ]) + allow(sigmet_source).to receive(:fetch).and_return([corridor_sigmet]) + brief = composer.compose(from: 'KCDW', to: 'KACY') + sigmet_items = brief.enroute_forecast[:items].select { |i| i[:kind] == 'sigmet' } + expect(sigmet_items.size).to eq(1) + end + + it 'records partial_failure in enroute_forecast when sigmet source raises' do + allow(sigmet_source).to receive(:fetch).and_raise(StandardError, 'sigmet down') + brief = composer.compose(from: 'KCDW', to: 'KACY') + failures = brief.enroute_forecast[:partial_failures] + expect(failures).to include(hash_including(source: 'sigmet')) + end + + it 'records partial_failure in enroute_forecast when airmet source raises' do + allow(airmet_source).to receive(:fetch).and_raise(StandardError, 'airmet down') + brief = composer.compose(from: 'KCDW', to: 'KACY') + failures = brief.enroute_forecast[:partial_failures] + expect(failures).to include(hash_including(source: 'airmet')) + end + + it 'adverse_conditions is still origin-point-based (not corridor-based)' do + # Confirm adverse_conditions is computed for KCDW origin, not the corridor + brief = composer.compose(from: 'KCDW', to: 'KACY') + expect(brief.adverse_conditions[:available]).to be true + end + + it 'passes departing_at through to the route brief' do + etd = Time.utc(2026, 5, 1, 16, 0, 0) + brief = composer.compose(from: 'KCDW', to: 'KACY', departing_at: etd) + expect(brief.departing_at).to eq(etd) + end + end end diff --git a/spec/brief/analysis/route_corridor_spec.rb b/spec/brief/analysis/route_corridor_spec.rb new file mode 100644 index 0000000..be0a436 --- /dev/null +++ b/spec/brief/analysis/route_corridor_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Skywatch::Brief::Analysis::RouteCorridor do + # KCDW: 40.875, -74.282 + # KACY: 39.457, -74.577 (~100 nm south) + # KJFK: 40.641, -73.778 (~30 nm east of KCDW) + + describe '.waypoints' do + it 'always includes the start and end points' do + pts = described_class.waypoints(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577) + expect(pts.first).to eq([40.875, -74.282]) + expect(pts.last).to eq([39.457, -74.577]) + end + + it 'returns at least 2 waypoints even for a very short route' do + pts = described_class.waypoints(from_lat: 40.875, from_lon: -74.282, + to_lat: 40.876, to_lon: -74.282) + expect(pts.size).to be >= 2 + end + + it 'produces waypoints spaced at most spacing_nm apart' do + pts = described_class.waypoints(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577, + spacing_nm: 25) + pts.each_cons(2) do |a, b| + dist = Skywatch::Radar::Analysis::Proximity.distance_nm(a[0], a[1], b[0], b[1]) + expect(dist).to be <= 26 # slight float tolerance + end + end + + it 'returns correct count for ~86 nm route with 25 nm spacing' do + # KCDW to KACY is about 86 nm → ceil(86/25) = 4 segments → 5 waypoints + pts = described_class.waypoints(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577, + spacing_nm: 25) + # 4 segments + 1 = 5 waypoints (allow slight floating point variation) + expect(pts.size).to be_between(4, 6) + end + + it 'returns 2 waypoints (start + end) for a route shorter than spacing' do + # A 10-nm route with 25-nm spacing should give ceil(10/25) = 1 segment → 2 pts + # 10 nm ≈ 0.167 degrees of latitude north-south + pts = described_class.waypoints(from_lat: 40.000, from_lon: -74.000, + to_lat: 40.167, to_lon: -74.000, + spacing_nm: 25) + expect(pts.size).to eq(2) + end + + it 'intermediate points lie between origin and destination' do + pts = described_class.waypoints(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577, + spacing_nm: 25) + # All latitudes should be between from and to (roughly south-trending route) + min_lat = [40.875, 39.457].min + max_lat = [40.875, 39.457].max + lats = pts.map(&:first) + lats.each do |lat| + expect(lat).to be_between(min_lat - 0.01, max_lat + 0.01) + end + end + end + + describe '.bearing_deg' do + it 'returns ~90 for due-east route' do + # Moving east: same latitude, increasing longitude + bearing = described_class.bearing_deg(from_lat: 40.0, from_lon: -74.0, + to_lat: 40.0, to_lon: -73.0) + expect(bearing).to be_within(2).of(90) + end + + it 'returns ~270 for due-west route' do + bearing = described_class.bearing_deg(from_lat: 40.0, from_lon: -74.0, + to_lat: 40.0, to_lon: -75.0) + expect(bearing).to be_within(2).of(270) + end + + it 'returns ~0 (or 360) for due-north route' do + bearing = described_class.bearing_deg(from_lat: 39.0, from_lon: -74.0, + to_lat: 40.0, to_lon: -74.0) + # bearing should be near 0/360 + expect([bearing, (bearing - 360).abs].min).to be_within(2).of(0) + end + + it 'returns ~180 for due-south route' do + bearing = described_class.bearing_deg(from_lat: 40.0, from_lon: -74.0, + to_lat: 39.0, to_lon: -74.0) + expect(bearing).to be_within(2).of(180) + end + + it 'returns value in [0, 360) range' do + bearing = described_class.bearing_deg(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577) + expect(bearing).to be >= 0 + expect(bearing).to be < 360 + end + + it 'returns southerly bearing for KCDW to KACY' do + # KACY is south-southwest of KCDW + bearing = described_class.bearing_deg(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577) + expect(bearing).to be_between(170, 210) + end + end + + describe '.distance_nm' do + it 'returns 0 for same point' do + expect(described_class.distance_nm(from_lat: 40.0, from_lon: -74.0, + to_lat: 40.0, to_lon: -74.0)).to eq(0.0) + end + + it 'returns approximately the right distance between KCDW and KACY (~86 nm)' do + dist = described_class.distance_nm(from_lat: 40.875, from_lon: -74.282, + to_lat: 39.457, to_lon: -74.577) + expect(dist).to be_within(5).of(86) + end + end +end diff --git a/spec/brief/cli_spec.rb b/spec/brief/cli_spec.rb index e3c5efe..3e80c07 100644 --- a/spec/brief/cli_spec.rb +++ b/spec/brief/cli_spec.rb @@ -42,6 +42,40 @@ .to raise_error(SystemExit) { |e| expect(e.status).not_to eq(0) } end + context '--to option (route brief)' do + let(:route_brief) do + instance_double( + Skywatch::Brief::Models::Brief, + to_h: { airport: 'KCDW', destination: { airport: 'KACY' }, aim_section: '7-1-5' } + ) + end + + it 'passes from: and to: when --to is given' do + expect(Skywatch).to receive(:brief) + .with(from: 'KCDW', to: 'KACY', departing_at: nil) + .and_return(route_brief) + output = capture_stdout { Skywatch::CLI.start(%w[brief KCDW --to KACY]) } + parsed = JSON.parse(output) + expect(parsed['destination']['airport']).to eq('KACY') + end + + it 'passes departing_at with --to and --departing-at' do + etd = Time.parse('2026-05-01T16:00:00Z') + expect(Skywatch).to receive(:brief) + .with(from: 'KCDW', to: 'KACY', departing_at: etd) + .and_return(route_brief) + capture_stdout do + Skywatch::CLI.start(%w[brief KCDW --to KACY --departing-at 2026-05-01T16:00:00Z]) + end + end + + it 'exits non-zero on ArgumentError (e.g. mixed from:/to: with at:)' do + allow(Skywatch).to receive(:brief).and_raise(ArgumentError, 'cannot mix from:/to: with at:') + expect { Skywatch::CLI.start(%w[brief KCDW --to KACY]) } + .to raise_error(SystemExit) { |e| expect(e.status).not_to eq(0) } + end + end + context '--departing-at option' do let(:etd) { Time.parse('2026-05-01T16:00:00Z') } diff --git a/spec/brief/models/brief_spec.rb b/spec/brief/models/brief_spec.rb index 78e2d53..4b8d9d9 100644 --- a/spec/brief/models/brief_spec.rb +++ b/spec/brief/models/brief_spec.rb @@ -134,4 +134,89 @@ expect(etd_brief.to_h[:departing_at]).to eq('2026-05-01T14:30:00Z') end end + + describe 'destination (route briefs)' do + let(:destination_field) do + { airport: 'KACY', coordinates: [39.457, -74.577], distance_nm: 86.2, bearing_deg: 192.3 } + end + + let(:route_brief) do + 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, + destination: destination_field + ) + end + + it 'is nil by default on non-route briefs' do + expect(brief.destination).to be_nil + end + + it 'is omitted from to_h when nil (non-route brief)' do + expect(brief.to_h).not_to include(:destination) + end + + it 'is included in to_h when set (route brief)' do + hash = route_brief.to_h + expect(hash).to include(:destination) + expect(hash[:destination][:airport]).to eq('KACY') + expect(hash[:destination][:coordinates]).to eq([39.457, -74.577]) + expect(hash[:destination][:distance_nm]).to eq(86.2) + expect(hash[:destination][:bearing_deg]).to eq(192.3) + end + end + + describe 'enroute_forecast (route briefs)' do + it 'defaults to ENROUTE_UNAVAILABLE constant when not given' do + expect(brief.to_h[:enroute_forecast]).to eq( + available: false, + reason: 'single-point brief; route input deferred from MVP' + ) + end + + it 'uses the provided enroute_forecast when set' do + enroute_data = { available: true, items: [], partial_failures: [], corridor: { waypoints: 4 } } + route_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, + enroute_forecast: enroute_data + ) + expect(route_brief.enroute_forecast).to eq(enroute_data) + expect(route_brief.to_h[:enroute_forecast]).to eq(enroute_data) + end + + it 'does not use the constant when enroute_forecast is explicitly provided' do + enroute_data = { available: true, items: [], partial_failures: [], corridor: {} } + route_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, + enroute_forecast: enroute_data + ) + expect(route_brief.to_h[:enroute_forecast]).not_to include(:reason) + end + end end diff --git a/spec/brief_spec.rb b/spec/brief_spec.rb index b2d08dd..cd348a5 100644 --- a/spec/brief_spec.rb +++ b/spec/brief_spec.rb @@ -9,18 +9,27 @@ 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, departing_at: nil).and_return(brief) + expect(composer).to receive(:compose) + .with(airport: 'KCDW', at: nil, departing_at: nil, from: nil, to: 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], departing_at: nil).and_return(brief) + expect(composer).to receive(:compose) + .with(airport: nil, at: [40.688, -74.174], departing_at: nil, from: nil, to: 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(composer).to receive(:compose) + .with(airport: 'KCDW', at: nil, departing_at: etd, from: nil, to: nil).and_return(brief) expect(described_class.brief(airport: 'KCDW', departing_at: etd)).to be(brief) end + + it 'passes from: and to: through to composer for a route brief' do + expect(composer).to receive(:compose) + .with(airport: nil, at: nil, departing_at: nil, from: 'KCDW', to: 'KACY').and_return(brief) + expect(described_class.brief(from: 'KCDW', to: 'KACY')).to be(brief) + end end