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,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