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
6 changes: 4 additions & 2 deletions lib/skywatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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:)
Expand Down
175 changes: 167 additions & 8 deletions lib/skywatch/brief/analysis/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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 " \
Expand All @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
90 changes: 90 additions & 0 deletions lib/skywatch/brief/analysis/route_corridor.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 8 additions & 5 deletions lib/skywatch/brief/models/brief.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(*)
Expand Down
Loading