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.
@@ -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