notam 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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