notam 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +223 -0
- data/lib/locales/en.yml +947 -0
- data/lib/notam/errors.rb +5 -0
- data/lib/notam/item/a.rb +37 -0
- data/lib/notam/item/b.rb +26 -0
- data/lib/notam/item/c.rb +39 -0
- data/lib/notam/item/d.rb +56 -0
- data/lib/notam/item/e.rb +31 -0
- data/lib/notam/item/f.rb +34 -0
- data/lib/notam/item/footer.rb +40 -0
- data/lib/notam/item/g.rb +34 -0
- data/lib/notam/item/header.rb +75 -0
- data/lib/notam/item/q.rb +89 -0
- data/lib/notam/item.rb +161 -0
- data/lib/notam/message.rb +123 -0
- data/lib/notam/schedule.rb +253 -0
- data/lib/notam/translation.rb +1063 -0
- data/lib/notam/version.rb +5 -0
- data/lib/notam.rb +28 -0
- data/lib/tasks/fixtures.rake +59 -0
- data/lib/tasks/yard.rake +11 -0
- data.tar.gz.sig +0 -0
- metadata +259 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NOTAM
|
4
|
+
|
5
|
+
# NOTAM messages are plain text and consist of several ordered items:
|
6
|
+
#
|
7
|
+
# WDDDD/DD ... <- Header line (mandatory)
|
8
|
+
# Q) ... <- Q line: context (mandatory)
|
9
|
+
# A) ... <- A line: locations (mandatory)
|
10
|
+
# B) ... <- B line: effective from (mandatory)
|
11
|
+
# C) ... <- C line: effective until (optional)
|
12
|
+
# D) ... <- D line: timesheets (optional, may contain newlines)
|
13
|
+
# E) ... <- E line: description (mandatory, may contain newlines)
|
14
|
+
# F) ... <- F line: upper limit (optional)
|
15
|
+
# G) ... <- G line: lower limit (optional)
|
16
|
+
# CREATED: ... <- Footer (optional)
|
17
|
+
# SOURCE: ... <- Footer (optional)
|
18
|
+
class Message
|
19
|
+
|
20
|
+
UNSUPPORTED_FORMATS = %r(
|
21
|
+
\A
|
22
|
+
![A-Z]{3,5} | # USA: NOTAM (D), FDC etc
|
23
|
+
\w{3}\s\w{3}\s\([OU]\) | # USA: (O) and (U) NOTAM
|
24
|
+
\w{3}\s[A-Z]\d{4}/\d{2}\sMILITARY # USA: military
|
25
|
+
)xi.freeze
|
26
|
+
|
27
|
+
FINGERPRINTS = %w[Q) A) B) C) D) E) F) G) CREATED: SOURCE:].freeze
|
28
|
+
|
29
|
+
# Raw NOTAM text message
|
30
|
+
#
|
31
|
+
# @return [String]
|
32
|
+
attr_reader :text
|
33
|
+
|
34
|
+
# NOTAM item objects
|
35
|
+
#
|
36
|
+
# @return [Array<NOTAM::Item>]
|
37
|
+
attr_reader :items
|
38
|
+
|
39
|
+
# Parsed NOTAM message payload
|
40
|
+
#
|
41
|
+
# @return [Hash]
|
42
|
+
attr_reader :data
|
43
|
+
|
44
|
+
def initialize(text)
|
45
|
+
fail(NOTAM::ParserError, "unsupported format") unless self.class.supported_format? text
|
46
|
+
@text, @items, @data = text, [], {}
|
47
|
+
itemize(text).each do |raw_item|
|
48
|
+
item = NOTAM::Item.new(raw_item, data: @data).parse.merge
|
49
|
+
@items << item
|
50
|
+
@data = item.data
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def inspect
|
55
|
+
%Q(#<#{self.class} #{data[:id]}>)
|
56
|
+
end
|
57
|
+
alias :to_s :inspect
|
58
|
+
|
59
|
+
# Whether the NOTAM is active at the given time.
|
60
|
+
#
|
61
|
+
# @param at [Time]
|
62
|
+
# @return [Boolean]
|
63
|
+
def active?(at:)
|
64
|
+
(data[:effective_at]..data[:expiration_at]).include?(at) &&
|
65
|
+
(!(d_item = item(:D)) || d_item.active?(at: at))
|
66
|
+
end
|
67
|
+
|
68
|
+
# Item of the given type
|
69
|
+
#
|
70
|
+
# @param type [Symbol, nil] either +:Header+, +:Q+, +(:A..:G)+ or +:Footer+
|
71
|
+
def item(type)
|
72
|
+
items.find { _1.type == type }
|
73
|
+
end
|
74
|
+
|
75
|
+
class << self
|
76
|
+
undef_method :new
|
77
|
+
|
78
|
+
# Parse the given raw NOTAM text message to create a new {NOTAM::Message}
|
79
|
+
# object.
|
80
|
+
#
|
81
|
+
# @return [NOTAM::Message]
|
82
|
+
def parse(text)
|
83
|
+
allocate.instance_eval do
|
84
|
+
initialize(text)
|
85
|
+
self
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Whether the given raw NOTAM text message is in a supported format.
|
90
|
+
#
|
91
|
+
# @return [Boolean]
|
92
|
+
def supported_format?(text)
|
93
|
+
!text.match? UNSUPPORTED_FORMATS
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# @return [Array]
|
100
|
+
def itemize(text)
|
101
|
+
lines = text.gsub(/\s(#{NOTAM::Item::RE})/, "\n\\1").split("\n")
|
102
|
+
last_index = -1
|
103
|
+
[lines.first].tap do |array|
|
104
|
+
lines[1..].each do |line|
|
105
|
+
index = FINGERPRINTS.index(line.scan(/\A[A-Z]+?[):]/).first).to_i
|
106
|
+
if index > last_index
|
107
|
+
array << line
|
108
|
+
last_index = index
|
109
|
+
else
|
110
|
+
array.push([array.pop, line].join("\n"))
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Shortcut of NOTAM::Message.parse
|
118
|
+
#
|
119
|
+
# @see NOTAM::Message.parse
|
120
|
+
def self.parse(text)
|
121
|
+
NOTAM::Message.parse(text)
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,253 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
using AIXM::Refinements
|
4
|
+
|
5
|
+
module NOTAM
|
6
|
+
|
7
|
+
# Structure to accommodate individual schedules used on D items
|
8
|
+
class Schedule
|
9
|
+
EVENTS = { 'SR' => :sunrise, 'SS' => :sunset }.freeze
|
10
|
+
OPERATIONS = { 'PLUS' => 1, 'MINUS' => -1 }.freeze
|
11
|
+
MONTHS = { 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12 }.freeze
|
12
|
+
DAYS = { 'MON' => :monday, 'TUE' => :tuesday, 'WED' => :wednesday, 'THU' => :thursday, 'FRI' => :friday, 'SAT' => :saturday, 'SUN' => :sunday, 'DAILY' => :any, 'DLY' => :any }.freeze
|
13
|
+
|
14
|
+
H24_RE = /(?<h24>H24)/.freeze
|
15
|
+
HOUR_RE = /(?<hour>[01]\d|2[0-4])(?<minute>[0-5]\d)/.freeze
|
16
|
+
OPERATIONS_RE = /#{OPERATIONS.keys.join('|')}/.freeze
|
17
|
+
EVENT_RE = /(?<event>SR|SS)(?:\s(?<operation>#{OPERATIONS_RE})(?<delta>\d+))?/.freeze
|
18
|
+
TIME_RE = /#{HOUR_RE}|#{EVENT_RE}/.freeze
|
19
|
+
TIME_RANGE_RE = /#{TIME_RE}-#{TIME_RE}|#{H24_RE}/.freeze
|
20
|
+
DATE_RE = /[0-2]\d|3[01]/.freeze
|
21
|
+
DAY_RE = /#{DAYS.keys.join('|')}/.freeze
|
22
|
+
MONTH_RE = /#{MONTHS.keys.join('|')}/.freeze
|
23
|
+
|
24
|
+
H24 = (AIXM.time('00:00')..AIXM.time('24:00')).freeze
|
25
|
+
|
26
|
+
# Active dates or days
|
27
|
+
#
|
28
|
+
# @note If {#active} lists dates, then {#inactive} must list days and
|
29
|
+
# vice versa.
|
30
|
+
#
|
31
|
+
# @return [Array<NOTAM::Schedule::Dates>, Array<NOTAM::Schedule::Days>]
|
32
|
+
attr_reader :actives
|
33
|
+
|
34
|
+
# Active times
|
35
|
+
#
|
36
|
+
# @return [Array<NOTAM::Schedule::Times>]
|
37
|
+
attr_reader :times
|
38
|
+
|
39
|
+
# Inactive dates or days
|
40
|
+
#
|
41
|
+
# @note If {#inactive} lists dates, then {#active} must list days and
|
42
|
+
# vice versa.
|
43
|
+
#
|
44
|
+
# @return [Array<NOTAM::Schedule::Dates>, Array<NOTAM::Schedule::Days>]
|
45
|
+
attr_reader :inactives
|
46
|
+
|
47
|
+
# @!visibility private
|
48
|
+
def initialize(actives, times, inactives, base_date:)
|
49
|
+
@actives, @times, @inactives = actives, times, inactives
|
50
|
+
@base_date ||= base_date.at(day: 1)
|
51
|
+
end
|
52
|
+
|
53
|
+
class << self
|
54
|
+
private :new
|
55
|
+
|
56
|
+
# Parse the schedule part of a D item.
|
57
|
+
#
|
58
|
+
# @param string [String] raw schedule string
|
59
|
+
# @param base_date [Date] month and year to assume when missing (day is
|
60
|
+
# force set to 1)
|
61
|
+
def parse(string, base_date:)
|
62
|
+
raw_actives, raw_times, raw_inactives = string.split(/((?: ?#{TIME_RANGE_RE.decapture})+)/).map(&:strip)
|
63
|
+
raw_inactives = raw_inactives&.sub(/^EXC /, '')
|
64
|
+
allocate.instance_eval do
|
65
|
+
@base_date = base_date.at(day: 1)
|
66
|
+
times = times_from(raw_times)
|
67
|
+
if raw_actives.empty? || raw_actives.match?(DAY_RE) # actives are days
|
68
|
+
actives = days_from(raw_actives)
|
69
|
+
inactives = raw_inactives ? dates_from(raw_inactives) : Dates.new
|
70
|
+
else
|
71
|
+
actives = dates_from(raw_actives)
|
72
|
+
inactives = raw_inactives ? days_from(raw_inactives) : Days.new
|
73
|
+
end
|
74
|
+
initialize(actives, times, inactives, base_date: base_date)
|
75
|
+
self
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# @return [String]
|
81
|
+
def inspect
|
82
|
+
attr = %i(actives times inactives).map { "#{_1}: #{send(_1)}" }
|
83
|
+
%Q(#<#{self.class} #{attr.join(', ')}>)
|
84
|
+
end
|
85
|
+
alias :to_s :inspect
|
86
|
+
|
87
|
+
# Whether the schedule contains any actives
|
88
|
+
#
|
89
|
+
# @return [Boolean]
|
90
|
+
def empty?
|
91
|
+
actives.empty?
|
92
|
+
end
|
93
|
+
|
94
|
+
# Extract a sub-schedule for the given time window.
|
95
|
+
#
|
96
|
+
# @note {#inactives} of sub-schedules are always empty which guarantees
|
97
|
+
# they can be translated to AIXM or OFMX.
|
98
|
+
#
|
99
|
+
# @param from [AIXM::Schedule::Date] beginning date
|
100
|
+
# @param to [AIXM::Schedule::Date] end date (defaults to +from+)
|
101
|
+
# @return [NOTAM::Schedule]
|
102
|
+
def slice(from, to=nil)
|
103
|
+
sliced_actives = Dates.new
|
104
|
+
(from..(to || from)).each do |date|
|
105
|
+
sliced_actives << date if actives.cover?(date) && !inactives.cover?(date)
|
106
|
+
end
|
107
|
+
self.class.send(:new, sliced_actives.cluster, times, Days.new, base_date: @base_date)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Resolve all events in {#times} for the given date and geographic location.
|
111
|
+
#
|
112
|
+
# @note The resolved times are rounded up (sunrise) or down (sunset) to the
|
113
|
+
# next 5 minutes.
|
114
|
+
#
|
115
|
+
# @see AIXM::Schedule::Time#resolve
|
116
|
+
# @param on [AIXM::Date] date
|
117
|
+
# @param xy [AIXM::XY] geographic location
|
118
|
+
# @return [NOTAM::Schedule]
|
119
|
+
def resolve(on:, xy:)
|
120
|
+
resolved_times = times.map do |time|
|
121
|
+
case time
|
122
|
+
when Range
|
123
|
+
(time.first.resolve(on: on, xy: xy, round: 5)..time.last.resolve(on: on, xy: xy, round: 5))
|
124
|
+
else
|
125
|
+
time.resolve(on: on, xy: xy, round: 5)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
self.class.send(:new, actives, Times.new(resolved_times), inactives, base_date: @base_date)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Whether the schedule is active at the given time.
|
132
|
+
#
|
133
|
+
# @see AIXM::Schedule::Time#resolve
|
134
|
+
# @param at [Time]
|
135
|
+
# @param xy [AIXM::XY] geographic location
|
136
|
+
# @return [NOTAM::Schedule]
|
137
|
+
def active?(at:, xy:)
|
138
|
+
date = AIXM.date(at)
|
139
|
+
resolve(on: date, xy: xy).slice(date).times.cover? AIXM.time(at)
|
140
|
+
end
|
141
|
+
|
142
|
+
private
|
143
|
+
|
144
|
+
# @return [Array<Date, Range<Date>>]
|
145
|
+
def dates_from(string)
|
146
|
+
array, index, base_date = [], 0, @base_date.dup
|
147
|
+
while index < string.length
|
148
|
+
case string[index..]
|
149
|
+
when /\A((?<from>#{DATE_RE})-(?:(?<month>#{MONTH_RE}) )?(?<to>#{DATE_RE}))/ # range of dates
|
150
|
+
month = $~[:month] ? MONTHS.fetch($~[:month]) : base_date.month
|
151
|
+
base_date = base_date.at(month: month, wrap: true).tap do |to_base_date|
|
152
|
+
array << (base_date.at(day: $~[:from].to_i)..to_base_date.at(day: $~[:to].to_i))
|
153
|
+
end
|
154
|
+
when /\A(?<day>#{DATE_RE})/ # single date
|
155
|
+
array << base_date.at(day: $~[:day].to_i)
|
156
|
+
when /\A(?<month>#{MONTH_RE})/
|
157
|
+
base_date = base_date.at(month: MONTHS.fetch($~[:month]), wrap: true)
|
158
|
+
else
|
159
|
+
fail! "unrecognized date"
|
160
|
+
end
|
161
|
+
index += $&.length + 1
|
162
|
+
end
|
163
|
+
Dates.new(array)
|
164
|
+
end
|
165
|
+
|
166
|
+
# @return [Array<AIXM::Schedule::Day, Range<AIXM::Schedue::Day>>]
|
167
|
+
def days_from(string)
|
168
|
+
array = if string.empty? # no declared day implies any day
|
169
|
+
[AIXM.day(:any)]
|
170
|
+
else
|
171
|
+
string.split(' ').map do |token|
|
172
|
+
from, to = token.split('-')
|
173
|
+
if to # range of days
|
174
|
+
(AIXM.day(DAYS.fetch(from))..AIXM.day(DAYS.fetch(to)))
|
175
|
+
else # single day
|
176
|
+
AIXM.day(DAYS.fetch(from))
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
Days.new(array)
|
181
|
+
end
|
182
|
+
|
183
|
+
# @return [Array<Range<AIXM::Schedule::Time>>]
|
184
|
+
def times_from(string)
|
185
|
+
array = string.split(/ (?!#{OPERATIONS_RE})/).map { time_range_from(_1) }
|
186
|
+
Times.new(array)
|
187
|
+
end
|
188
|
+
|
189
|
+
# @return [Range<AIXM::Schedule::Time>]
|
190
|
+
def time_range_from(string)
|
191
|
+
case string
|
192
|
+
when H24_RE
|
193
|
+
H24
|
194
|
+
else
|
195
|
+
from, to = string.split('-')
|
196
|
+
(time_from(from)..time_from(to))
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# @return [AIXM::Schedule::Time]
|
201
|
+
def time_from(string)
|
202
|
+
case string
|
203
|
+
when HOUR_RE
|
204
|
+
hour, minute = $~[:hour], $~[:minute]
|
205
|
+
AIXM.time([hour, minute].join(':'))
|
206
|
+
when EVENT_RE
|
207
|
+
event, operation, delta = $~[:event], $~[:operation], $~[:delta]&.to_i
|
208
|
+
AIXM.time(EVENTS.fetch(event), plus: delta ? OPERATIONS.fetch(operation) * delta : 0)
|
209
|
+
else
|
210
|
+
fail! "unrecognized time"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# @abstract
|
215
|
+
class ScheduleArray < Array
|
216
|
+
# @return [String]
|
217
|
+
def inspect
|
218
|
+
%Q(#<#{self.class} #{to_s}>)
|
219
|
+
end
|
220
|
+
|
221
|
+
# @return [String]
|
222
|
+
def to_s
|
223
|
+
'[' + entries.map { _1.to_s }.join(', ') + ']'
|
224
|
+
end
|
225
|
+
|
226
|
+
# Whether the given object is covered by this schedule array
|
227
|
+
#
|
228
|
+
# @param object [AIXM::Schedule::Date, AIXM::Schedule::Day,
|
229
|
+
# AIXM::Schedule::Time]
|
230
|
+
# @return [Boolean]
|
231
|
+
def cover?(object)
|
232
|
+
any? { object.covered_by? _1 }
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
class Dates < ScheduleArray
|
237
|
+
# Convert subsequent entries to ranges
|
238
|
+
#
|
239
|
+
# @return [AIXM::Schedule::Dates]
|
240
|
+
def cluster
|
241
|
+
self.class.new(
|
242
|
+
entries
|
243
|
+
.slice_when { _1.succ != _2 }
|
244
|
+
.map { _1.count > 1 ? (_1.first.._1.last) : _1.first }
|
245
|
+
)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
class Days < ScheduleArray; end
|
250
|
+
class Times < ScheduleArray; end
|
251
|
+
end
|
252
|
+
|
253
|
+
end
|