notam 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+ ParseError = Class.new(StandardError)
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The A item defines the locations (ICAO codes) affected by this NOTAM.
6
+ class A < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ A\)\s?
11
+ (?<locations>(?:#{ICAO_RE}\s?)+)
12
+ (?<parts>(?<part_index>\d+)\s+OF\s+(?<part_index_max>\d+))?
13
+ \z
14
+ )x.freeze
15
+
16
+ # @return [Array<String>]
17
+ def locations
18
+ captures['locations'].split(/\s/)
19
+ end
20
+
21
+ # @return [Integer, nil]
22
+ def part_index
23
+ captures['parts'] ? captures['part_index'].to_i : 1
24
+ end
25
+
26
+ # @return [Integer, nil]
27
+ def part_index_max
28
+ captures['parts'] ? captures['part_index_max'].to_i : 1
29
+ end
30
+
31
+ # @see NOTAM::Item#merge
32
+ def merge
33
+ super(:locations, :part_index, :part_index_max)
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The B item defines when the NOTAM goes into effect.
6
+ class B < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ B\)\s?
11
+ (?<effective_at>#{TIME_RE})
12
+ \z
13
+ )x.freeze
14
+
15
+ # @return [Time]
16
+ def effective_at
17
+ time(captures['effective_at'])
18
+ end
19
+
20
+ # @see NOTAM::Item#merge
21
+ def merge
22
+ super(:effective_at)
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The C item defines when the NOTAM expires.
6
+ class C < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ C\)\s?
11
+ (?<permanent>
12
+ PERM|
13
+ (?<expiration_at>#{TIME_RE}) \s? (?<estimated>EST)?
14
+ )
15
+ \z
16
+ )x.freeze
17
+
18
+ # @return [Time, nil]
19
+ def expiration_at
20
+ time(captures['expiration_at']) unless no_expiration?
21
+ end
22
+
23
+ # @return [Boolean]
24
+ def estimated_expiration?
25
+ !captures['estimated'].nil?
26
+ end
27
+
28
+ # @return [Boolean]
29
+ def no_expiration?
30
+ captures['permanent'] == 'PERM'
31
+ end
32
+
33
+ # @see NOTAM::Item#merge
34
+ def merge
35
+ super(:expiration_at, :estimated_expiration?, :no_expiration?)
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The D item contains timesheets to further narrow when exactly the NOTAM
6
+ # is effective.
7
+ class D < Item
8
+
9
+ # Activity schedules
10
+ #
11
+ # @return [Array<NOTAM::Schedule>]
12
+ attr_reader :schedules
13
+
14
+ # @see NOTAM::Item#parse
15
+ def parse
16
+ base_date = AIXM.date(data[:effective_at])
17
+ @schedules = cleanup(text).split(',').map do |string|
18
+ Schedule.parse(string, base_date: base_date)
19
+ end
20
+ self
21
+ end
22
+
23
+ # Whether the D item is active at the given time.
24
+ #
25
+ # @param at [Time]
26
+ # @return [Boolean]
27
+ def active?(at:)
28
+ schedules.any? { _1.active?(at: at, xy: data[:center_point]) }
29
+ end
30
+
31
+ def five_day_schedules
32
+ schedules.map do |schedule|
33
+ schedule
34
+ .slice(AIXM.date(data[:effective_at]), AIXM.date(data[:effective_at] + 4 * 86_400))
35
+ .resolve(on: data[:effective_at], xy: data[:center_point])
36
+ end.map { _1 unless _1.empty? }.compact
37
+ end
38
+
39
+ # @see NOTAM::Item#merge
40
+ def merge
41
+ super(:schedules, :five_day_schedules)
42
+ end
43
+
44
+ private
45
+
46
+ # @return [String]
47
+ def cleanup(string)
48
+ string
49
+ .gsub(/\s+/, ' ') # collapse whitespaces to single space
50
+ .gsub(/ ?([-,]) ?/, '\1') # remove spaces around dashes and commas
51
+ .sub(/\AD\) /, '') # remove item identifier
52
+ .strip
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The E item contains a textual description of what this NOTAM is all about.
6
+ class E < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ E\)\s?
11
+ (?<content>.+)
12
+ \z
13
+ )mx.freeze
14
+
15
+ def content
16
+ captures['content']
17
+ end
18
+
19
+ def translated_content
20
+ content.split(/\b/).map do |word|
21
+ (NOTAM::expand(word, translate: true) || word).upcase
22
+ end.join
23
+ end
24
+
25
+ # @see NOTAM::Item#merge
26
+ def merge
27
+ super(:content, :translated_content)
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The F item defines the upper limit for this NOTAM.
6
+ class F < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ F\)\s?
11
+ (?<all>
12
+ SFC|GND|UNL|
13
+ (?<value>\d+)\s?(?<unit>FT|M)\s?(?<base>AMSL|AGL)|
14
+ (?<unit>FL)\s?(?<value>\d+)
15
+ )
16
+ \z
17
+ )x.freeze
18
+
19
+ # @return [AIXM::Z]
20
+ def upper_limit
21
+ case captures['all']
22
+ when 'UNL' then AIXM::UNLIMITED
23
+ when 'SFC', 'GND' then AIXM::GROUND
24
+ else limit(*captures.values_at('value', 'unit', 'base'))
25
+ end
26
+ end
27
+
28
+ # @see NOTAM::Item#merge
29
+ def merge
30
+ super(:upper_limit)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The footer items contain meta information.
6
+ class Footer < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ (?<key>CREATED|SOURCE):\s*
11
+ (?<value>.+)
12
+ \z
13
+ )x.freeze
14
+
15
+ # @return [String]
16
+ def key
17
+ captures['key'].downcase.to_sym
18
+ end
19
+
20
+ # @return [String, Time]
21
+ def value
22
+ case key
23
+ when :created then Time.parse(captures['value'] + ' UTC')
24
+ else captures['value']
25
+ end
26
+ end
27
+
28
+ # @see NOTAM::Item#merge
29
+ def merge
30
+ data[key] = value
31
+ self
32
+ end
33
+
34
+ # @return [String]
35
+ def inspect
36
+ %Q(#<#{self.class} "#{truncated_text(start: 0)}">)
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The G item defines the lower limit for this NOTAM.
6
+ class G < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ G\)\s?
11
+ (?<all>
12
+ SFC|GND|UNL|
13
+ (?<value>\d+)\s?(?<unit>FT|M)\s?(?<base>AMSL|AGL)|
14
+ (?<unit>FL)\s?(?<value>\d+)
15
+ )
16
+ \z
17
+ )x.freeze
18
+
19
+ # @return [AIXM::Z]
20
+ def lower_limit
21
+ case captures['all']
22
+ when 'UNL' then AIXM::UNLIMITED
23
+ when 'SFC', 'GND' then AIXM::GROUND
24
+ else limit(*captures.values_at('value', 'unit', 'base'))
25
+ end
26
+ end
27
+
28
+ # @see NOTAM::Item#merge
29
+ def merge
30
+ super(:lower_limit)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ using AIXM::Refinements
4
+
5
+ module NOTAM
6
+
7
+ # The header item contains the NOTAM ID as well as information as to whether
8
+ # its a new NOTAM or merely replacing or cancelling another one.
9
+ class Header < Item
10
+
11
+ RE = %r(
12
+ \A
13
+ (?<id>#{ID_RE})\s+
14
+ NOTAM(?<operation>[NRC])\s*
15
+ (?<old_id>#{ID_RE.decapture})?
16
+ \z
17
+ )x.freeze
18
+
19
+ # @return [String] ID of this message
20
+ def id
21
+ captures['id']
22
+ end
23
+
24
+ # @return [String] series letter
25
+ def id_series
26
+ captures['id_series']
27
+ end
28
+
29
+ # @return [Integer] serial number
30
+ def id_number
31
+ captures['id_number'].to_i
32
+ end
33
+
34
+ # @return [Integer] year identifier
35
+ def id_year
36
+ captures['id_year'].to_i + (Date.today.year / 1000 * 1000)
37
+ end
38
+
39
+ # @return [Boolean] +true+ if this is a new message, +false+ if it
40
+ # replaces or cancels another message
41
+ def new?
42
+ captures['operation'] == 'N'
43
+ end
44
+
45
+ # @return [String, nil] message being replaced by this message or +nil+
46
+ # if this message is a new or cancelling one
47
+ def replaces
48
+ captures['old_id'] if captures['operation'] == 'R'
49
+ end
50
+
51
+ # @return [String, nil] message being cancelled by this message or +nil+
52
+ # if this message is a new or replacing one
53
+ def cancels
54
+ captures['old_id'] if captures['operation'] == 'C'
55
+ end
56
+
57
+ # @see NOTAM::Item#parse
58
+ def parse
59
+ super
60
+ fail! "invalid operation" unless (new? && !captures['old_id']) || replaces || cancels
61
+ self
62
+ end
63
+
64
+ # @see NOTAM::Item#merge
65
+ def merge
66
+ super(:id, :id_series, :id_number, :id_year, :new?, :replaces, :cancels)
67
+ end
68
+
69
+ # @return [String]
70
+ def inspect
71
+ %Q(#<#{self.class} "#{truncated_text(start: 0)}">)
72
+ end
73
+
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # The Q item provides the context such as the FIR or conditions.
6
+ class Q < Item
7
+
8
+ RE = %r(
9
+ \A
10
+ Q\)\s?
11
+ (?<fir>#{ICAO_RE})/
12
+ Q(?<subject>[A-Z]{2})(?<condition>[A-Z]{2})/
13
+ (?<traffic>IV|(?:[IVK]\s?))/
14
+ (?<purpose>NBO|BO\s?|(?:[BMK]\s{0,2}))/
15
+ (?<scope>A[EW]|(?:[AEWK]\s?))/
16
+ (?<lower_limit>\d{3})/
17
+ (?<upper_limit>\d{3})/
18
+ (?<lat_deg>\d{2})(?<lat_min>\d{2})(?<lat_dir>[NS])
19
+ (?<long_deg>\d{3})(?<long_min>\d{2})(?<long_dir>[EW])
20
+ (?<radius>\d{3})
21
+ \z
22
+ )x.freeze
23
+
24
+ # @return [String]
25
+ def fir
26
+ captures['fir']
27
+ end
28
+
29
+ # @return [Symbol]
30
+ def subject
31
+ NOTAM.subject_for(captures['subject'])
32
+ end
33
+
34
+ # @return [Symbol]
35
+ def condition
36
+ NOTAM.condition_for(captures['condition'])
37
+ end
38
+
39
+ # @return [Symbol]
40
+ def traffic
41
+ NOTAM.traffic_for(captures['traffic'].strip)
42
+ end
43
+
44
+ # @return [Array<Symbol>]
45
+ def purpose
46
+ captures['purpose'].strip.chars.map { NOTAM.purpose_for(_1) }
47
+ end
48
+
49
+ # @return [Array<Symbol>]
50
+ def scope
51
+ captures['scope'].strip.chars.map { NOTAM.scope_for(_1) }
52
+ end
53
+
54
+ # @return [AIXM::Z] lower limit (QNE flight level) or {AIXM::GROUND}
55
+ # (aka: 0ft QFE)
56
+ def lower_limit
57
+ if (limit = captures['lower_limit'].to_i).zero?
58
+ AIXM::GROUND
59
+ else
60
+ AIXM.z(captures['lower_limit'].to_i, :qne)
61
+ end
62
+ end
63
+
64
+ # @return [AIXM::Z] upper limit (QNE flight level) or {AIXM::UNLIMITED}
65
+ # (aka: FL999 QNE)
66
+ def upper_limit
67
+ AIXM.z(captures['upper_limit'].to_i, :qne)
68
+ end
69
+
70
+ # @return [AIXM::XY] approximately affected area center point
71
+ def center_point
72
+ AIXM.xy(
73
+ lat: %Q(#{captures['lat_deg']}°#{captures['lat_min']}'00"#{captures['lat_dir']}),
74
+ long: %Q(#{captures['long_deg']}°#{captures['long_min']}'00"#{captures['long_dir']})
75
+ )
76
+ end
77
+
78
+ # @return [AIXM::D] approximately affected area radius
79
+ def radius
80
+ AIXM.d(captures['radius'].to_i, :nm)
81
+ end
82
+
83
+ # @see NOTAM::Item#merge
84
+ def merge
85
+ super(:fir, :subject, :condition, :traffic, :purpose, :scope, :lower_limit, :upper_limit, :center_point, :radius)
86
+ end
87
+
88
+ end
89
+ end
data/lib/notam/item.rb ADDED
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NOTAM
4
+
5
+ # Items are the building blocks of a NOTAM message. They usually consist of
6
+ # only one line of plain text each, however, D and E items may span over
7
+ # multiple lines of plain text.
8
+ class Item
9
+
10
+ RE = /[QA-G]\)\s/.freeze
11
+
12
+ ID_RE = /(?<id_series>[A-Z])(?<id_number>\d{4})\/(?<id_year>\d{2})/.freeze
13
+ ICAO_RE = /[A-Z]{4}/.freeze
14
+ TIME_RE = /(?:\d{2})(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|[2][0-4])(?:[0-5]\d)/.freeze
15
+
16
+ # Raw NOTAM item text
17
+ #
18
+ # @return [String]
19
+ attr_reader :text
20
+
21
+ # Captures from the default item regexp +RE+
22
+ #
23
+ # @return [MatchData]
24
+ attr_reader :captures
25
+
26
+ # Parsed NOTAM message payload
27
+ #
28
+ # @return [Hash]
29
+ attr_reader :data
30
+
31
+ # Analyses the raw NOTAM item text and initialize the corresponding item
32
+ # object.
33
+ #
34
+ # @note Some NOTAM items (most notably {NOTAM::D}) depend on previous items
35
+ # for meaningful parsing and may fail if this information is not made
36
+ # available by passing the NOTAM message payload parsed so far as +data+.
37
+ #
38
+ # @example
39
+ # NOTAM::Item.new('A0135/20 NOTAMN') # => #<NOTAM::Head id="A0135/20">
40
+ # NOTAM::Item.new('B) 0208231540') # => #<NOTAM::B>
41
+ # NOTAM::Item.new('foobar') # => NOTAM::ParseError
42
+ #
43
+ # @param text [String]
44
+ # @param data [Hash]
45
+ # @return [NOTAM::Header, NOTAM::Q, NOTAM::A, NOTAM::B, NOTAM::C,
46
+ # NOTAM::D, NOTAM::E, NOTAM::F, NOTAM::G, NOTAM::Footer]
47
+ def initialize(text, data: {})
48
+ @text, @data = text.strip, data
49
+ end
50
+
51
+ class << self
52
+ # @!visibility private
53
+ def new(text, data: {})
54
+ NOTAM.const_get(type(text)).allocate.instance_eval do
55
+ initialize(text, data: data)
56
+ self
57
+ end
58
+ end
59
+
60
+ # Analyses the raw NOTAM item text and detect its type.
61
+ #
62
+ # @example
63
+ # NOTAM::Item.type('A0135/20 NOTAMN') # => :Header
64
+ # NOTAM::Item.type('B) 0208231540') # => :B
65
+ # NOTAM::Item.type('SOURCE: LFNT') # => :Footer
66
+ # NOTAM::Item.type('foobar') # => NOTAM::ParseError
67
+ #
68
+ # @raise [NOTAM::ParseError]
69
+ # @return [String]
70
+ def type(text)
71
+ case text.strip
72
+ when /\A([A-GQ])\)/ then $1
73
+ when NOTAM::Header::RE then 'Header'
74
+ when NOTAM::Footer::RE then 'Footer'
75
+ else fail(NOTAM::ParseError, 'item not recognized')
76
+ end.to_sym
77
+ end
78
+ end
79
+
80
+ # Matches the raw NOTAM item text against +RE+ and populates {#captures}.
81
+ #
82
+ # @note May be extended or overwritten in subclasses, but must always
83
+ # return +self+!
84
+ #
85
+ # @example
86
+ # NOTAM::Item.new('A0135/20 NOTAMN').parse # => #<NOTAM::Header id="A0135/20">
87
+ # NOTAM::Item.new('foobar').parse # => NOTAM::ParseError
88
+ #
89
+ # @abstract
90
+ # @raise [NOTAM::ParseError]
91
+ # @return [self]
92
+ def parse
93
+ if match_data = self.class::RE.match(text)
94
+ @captures = match_data.named_captures
95
+ self
96
+ else
97
+ fail! 'text does not match regexp'
98
+ end
99
+ end
100
+
101
+ # Merges the return values of the given methods into the +data+ hash.
102
+ #
103
+ # @note Must be extended in subclasses.
104
+ #
105
+ # @abstract
106
+ # @params methods [Array<Symbol>]
107
+ # @return [self]
108
+ def merge(*methods)
109
+ fail 'nothing to merge' unless methods.any?
110
+ methods.each { @data[_1] = send(_1) }
111
+ @data.compact!
112
+ self
113
+ end
114
+
115
+ # Type of the item
116
+ #
117
+ # @return [Symbol] either +:Header+, +:Q+, +(:A..:G)+ or +:Footer+
118
+ def type
119
+ self.class.to_s[7..].to_sym
120
+ end
121
+
122
+ # Raise {NOTAM::ParseError} along with some debugging information.
123
+ #
124
+ # @param message [String] optional error message
125
+ # @raise [NOTAM::ParseError]
126
+ def fail!(message=nil)
127
+ fail(NOTAM::ParseError, [message, text].compact.join(': '))
128
+ end
129
+
130
+ # @return [String]
131
+ def inspect
132
+ %Q(#<#{self.class} "#{truncated_text}">)
133
+ end
134
+
135
+ private
136
+
137
+ def time(timestamp)
138
+ short_year, month, day, hour, minute = timestamp.scan(/\d{2}/).map(&:to_i)
139
+ millenium = Time.now.year / 100 * 100
140
+ Time.utc(millenium + short_year, month, day, hour, minute)
141
+ end
142
+
143
+ def limit(value, unit, base)
144
+ if captures['base']
145
+ d = AIXM.d(value.to_i, unit).to_ft
146
+ AIXM.z(d.dim.round, { 'AMSL' => :qnh, 'AGL' => :qfe }[base])
147
+ else
148
+ AIXM.z(value.to_i, :qne)
149
+ end
150
+ end
151
+
152
+ def truncated_text(start: 3, length: 40)
153
+ if text.length > start + length - 1
154
+ text[start, length - 1] + '…'
155
+ else
156
+ text[start..]
157
+ end
158
+ end
159
+
160
+ end
161
+ end