notam 0.1.2 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92a3fd49f8ffbc96bcb45d19f890837af01c255d69d4c083a883c8f446e1758d
4
- data.tar.gz: 5fa45d97e28d3bed19a20ec3e841e815b93cb94f3b686172fb9abc5c50cc7118
3
+ metadata.gz: d3f0a2b936b2f99302c03166135f539d6d9aa8c90ded6da55052e72c27937dcf
4
+ data.tar.gz: de7f1ff14e4c8c5de825f88a3d83cb22b7726a41b6ed5fffa7bf99c19ac856b9
5
5
  SHA512:
6
- metadata.gz: 42503973261da835cab991b6272b1b12ac17adac8f8a670de29d7b55f5650b35cb4e38d5eddc145c78ba03f81f4247f398cb0aec82d2366205b90a6fb6c96842
7
- data.tar.gz: 9f84f55bc3b42c40e7670e72683c05b32d71a8d401a197b38481eeaa56b173d16e62e3279a40134bf48cadb0e227fb30ba68ff5766a9c96ece9d66fe8e1f3a71
6
+ metadata.gz: 53d1288241008d166fde8a71b1e13ae0cc088f62f552503f70282e2de4cca086e22b6cc80437d3dbfd911ec8056e55481a6aaa8f7221c963ab3dc99ff1799eba
7
+ data.tar.gz: 6c94aa7b09ec15c27d9af9ba4232cf8e5b39086a4444d14bda82c4f5ddd8414ee89c6d9cb53694663b36aa1a0861cb87de2bcbc16b2fe965af3bafbdf36bcc7a
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  Nothing so far
4
4
 
5
+ ## 1.0.0
6
+
7
+ #### Breaking Changes
8
+ * `NOTAM::Schedule.parse` now returns an array of `NOTAM_Schedule` instances
9
+ instead of just a single one.
10
+
11
+ #### Changes
12
+ * Edge case tolerant extraction of `PART n OF n` and `END PART n OF n` markers
13
+
14
+ #### Additions
15
+ * Support for datetime ranges (i.e. `1 APR 2000-20 MAY 2000`) as well as times
16
+ across midnight (i.e. `1 APR 1900-0500`) on D items.
17
+ * Wrap all exceptions raised while parsing items.
18
+
19
+ ## 0.1.3
20
+
21
+ #### Fixes
22
+ * Reverse accidentally flipped F and G item.
23
+
5
24
  ## 0.1.2
6
25
 
7
26
  #### Changes
data/README.md CHANGED
@@ -41,12 +41,13 @@ bundle install --trust-policy MediumSecurity
41
41
  raw_notam_text_message = <<~END
42
42
  W0902/22 NOTAMN
43
43
  Q) LSAS/QRRCA/V/BO/W/000/148/4624N00702E004
44
- A) LSAS B) 2204110900 C) 2205131400 EST
44
+ A) LSAS PART 2 OF 3 B) 2204110900 C) 2205131400 EST
45
45
  D) APR 11 SR MINUS15-1900, 20-21 26-28 MAY 03-05 10-12 0530-2100, APR
46
46
  14 22 29 MAY 06 13 0530-1400, APR 19 25 MAY 02 09 0800-2100
47
47
  E) R-AREA LS-R7 HONGRIN ACT DUE TO FRNG.
48
48
  F) GND
49
49
  G) 14800FT AMSL
50
+ END PART 2 OF 3
50
51
  CREATED: 11 Apr 2022 06:10:00
51
52
  SOURCE: LSSNYNYX
52
53
  END
@@ -75,8 +76,8 @@ The resulting hash for this example looks as follows:
75
76
  center_point: #<AIXM::XY 46.40000000N 007.03333333E>,
76
77
  radius: #<AIXM::D 4.0 nm>,
77
78
  locations: ["LSAS"],
78
- part_index: 1,
79
- part_index_max: 1,
79
+ part_index: 2,
80
+ part_index_max: 3,
80
81
  effective_at: 2022-04-11 09:00:00 UTC,
81
82
  expiration_at: 2022-05-13 14:00:00 UTC,
82
83
  estimated_expiration?: false,
@@ -106,7 +107,12 @@ A few highlights to note here:
106
107
 
107
108
  Since NOTAM may contain a certain level of redundancy, the parser does some integrity checks, fixes the payload if possible and issues a warning.
108
109
 
109
- You get a `NOTAM::ParseError` in case the raw NOTAM text message fails to be parsed. If you're sure the NOTAM is correct, please [submit an issue](#development) or fix the bug and [submit a pull request](#development).
110
+ You get a `NOTAM::ParseError` in case the raw NOTAM text message fails to be parsed. This error object features two notable methods:
111
+
112
+ * `item` – the faulty item (if already available)
113
+ * `cause` – the underlying error object (if any)
114
+
115
+ If you're sure the NOTAM is correct, please [submit an issue](#development) or fix the bug and [submit a pull request](#development).
110
116
 
111
117
  See the [API documentation](https://www.rubydoc.info/gems/notam) for more.
112
118
 
@@ -114,8 +120,9 @@ See the [API documentation](https://www.rubydoc.info/gems/notam) for more.
114
120
 
115
121
  ### Anatomy of a NOTAM message
116
122
 
117
- A NOTAM message consists of a header followed by the following items:
123
+ A NOTAM message consists of the following items in order:
118
124
 
125
+ * Header: ID and type of NOTAM
119
126
  * [Q item](https://www.rubydoc.info/gems/notam/NOTAM/Q): Essential information such as purpose or center point and radius
120
127
  * [A item](https://www.rubydoc.info/gems/notam/NOTAM/A): Affected locations
121
128
  * [B item](https://www.rubydoc.info/gems/notam/NOTAM/B): When the NOTAM becomes effective
@@ -124,6 +131,9 @@ A NOTAM message consists of a header followed by the following items:
124
131
  * [E item](https://www.rubydoc.info/gems/notam/NOTAM/E): Free text description
125
132
  * [F item](https://www.rubydoc.info/gems/notam/NOTAM/F): Upper limit (optional)
126
133
  * [G item](https://www.rubydoc.info/gems/notam/NOTAM/G): Lower limit (optional)
134
+ * Footer: Any number of lines with metadata such as `CREATED` and `SOURCE`
135
+
136
+ Furthermore, oversized NOTAM may be split into several partial messages which contain with `PART n OF n` and `END PART n OF n` markers. This is an unofficial extension and therefore the markers may be found in different places such as on the A item, on the E item or even somewhere in between.
127
137
 
128
138
  ### FIR
129
139
 
@@ -190,11 +200,18 @@ For compatibility, schedule dates and times are expressed using the correspondin
190
200
  * [Day](https://www.rubydoc.info/gems/aixm/AIXM/Schedule/Day)
191
201
  * [Time](https://www.rubydoc.info/gems/aixm/AIXM/Schedule/Time)
192
202
 
203
+ Raw and parsed NOTAM schedule times differ in how the "end of day" is encoded:
204
+
205
+ NOTAM | Beginning of Day | End of Day | Remarks
206
+ -------|------------------|------------|--------
207
+ Raw | `"0000"` | `"2359"` | `"2400"` is considered illegal
208
+ Parsed | `00:00` | `24:00` | the Ruby way
209
+
193
210
  ### References
194
211
 
195
- * [ICAO Annex 15 on NOTAM](https://www.bazl.admin.ch/bazl/en/home/specialists/regulations-and-guidelines/legislation-and-directives/anhaenge-zur-konvention-der-internationalen-zivilluftfahrtorgani.html)
212
+ * [ICAO Doc 8126: Aeronautical Information Services Manual](https://www.icao.int/NACC/Documents/eDOCS/AIM/8126_unedited_en%20Jul2021.pdf)
213
+ * [EUROCONTROL Guidelines Operating Procedures AIS Dynamic Data (OPADD)](https://www.eurocontrol.int/sites/default/files/2021-07/eurocontrol-guidelines-opadd-ed4-1.pdf)
196
214
  * [NOTAM Q Codes](https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html)
197
- * [Guide de la consultation NOTAM (fr)](https://www.sia.aviation-civile.gouv.fr/pub/media/news/file/g/u/guide_de_la_consultation_notam_05-10-2017-1.pdf)
198
215
  * [NOTAM Contractions](https://www.notams.faa.gov/downloads/contractions.pdf)
199
216
  * [NOTAM format cheat sheet](http://vat-air.dk/files/ICAO%20NOTAM%20format.pdf)
200
217
  * [Introduction on Wikipedia](https://en.wikipedia.org/wiki/NOTAM)
@@ -207,12 +224,13 @@ Please [create a translation request issue](https://github.com/svoop/notam/issue
207
224
 
208
225
  ## Tests and Fixtures
209
226
 
210
- The test suite may run against live NOTAM if you set the `SPEC_SCOPE` environment variable:
227
+ The test suite may run against live NOTAM depending on whether and how you set the `SPEC_SCOPE` environment variable:
211
228
 
212
229
  ```
230
+ export SPEC_SCOPE=none # don't run against any NOTAM fixtures (default)
231
+ export SPEC_SCOPE=W0214/22 # run against given NOTAM fixture only
213
232
  export SPEC_SCOPE=all # run against all NOTAM fixtures
214
233
  export SPEC_SCOPE=all-fast # run against all NOTAM fixtures but exit on the first failure
215
- export SPEC_SCOPE=W0214/22 # run against given NOTAM fixture only
216
234
  ```
217
235
 
218
236
  The NOTAM fixtures are written to `spec/fixtures`, you can manage them using a Rake tasks:
data/lib/notam/errors.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NOTAM
4
- ParseError = Class.new(StandardError)
4
+ class ParseError < StandardError
5
+ attr_reader :item
6
+
7
+ def initialize(message, item: nil)
8
+ @item = item
9
+ super(message)
10
+ end
11
+ end
5
12
  end
data/lib/notam/item/a.rb CHANGED
@@ -9,7 +9,6 @@ module NOTAM
9
9
  \A
10
10
  A\)\s?
11
11
  (?<locations>(?:#{ICAO_RE}\s?)+)
12
- (?<parts>(?<part_index>\d+)\s+OF\s+(?<part_index_max>\d+))?
13
12
  \z
14
13
  )x.freeze
15
14
 
@@ -18,19 +17,9 @@ module NOTAM
18
17
  captures['locations'].split(/\s/)
19
18
  end
20
19
 
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
20
  # @see NOTAM::Item#merge
32
21
  def merge
33
- super(:locations, :part_index, :part_index_max)
22
+ super(:locations)
34
23
  end
35
24
 
36
25
  end
data/lib/notam/item/d.rb CHANGED
@@ -14,10 +14,12 @@ module NOTAM
14
14
  # @see NOTAM::Item#parse
15
15
  def parse
16
16
  base_date = AIXM.date(data[:effective_at])
17
- @schedules = cleanup(text).split(',').map do |string|
17
+ @schedules = text.sub(/\AD\)/, '').split(',').flat_map do |string|
18
18
  Schedule.parse(string, base_date: base_date)
19
19
  end
20
20
  self
21
+ rescue
22
+ fail! 'invalid D item'
21
23
  end
22
24
 
23
25
  # Whether the D item is active at the given time.
@@ -54,15 +56,5 @@ module NOTAM
54
56
  @five_day_base ||= [data[:effective_at], Time.now.utc.round].max
55
57
  end
56
58
 
57
- # @params string [String] string to clean up
58
- # @return [String]
59
- def cleanup(string)
60
- string
61
- .gsub(/\s+/, ' ') # collapse whitespaces to single space
62
- .gsub(/ ?([-,]) ?/, '\1') # remove spaces around dashes and commas
63
- .sub(/\AD\) /, '') # remove item identifier
64
- .strip
65
- end
66
-
67
59
  end
68
60
  end
data/lib/notam/item/f.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module NOTAM
4
4
 
5
- # The F item defines the upper limit for this NOTAM.
5
+ # The F item defines the lower limit for this NOTAM.
6
6
  class F < Item
7
7
 
8
8
  RE = %r(
@@ -17,7 +17,7 @@ module NOTAM
17
17
  )x.freeze
18
18
 
19
19
  # @return [AIXM::Z]
20
- def upper_limit
20
+ def lower_limit
21
21
  case captures['all']
22
22
  when 'UNL' then AIXM::UNLIMITED
23
23
  when 'SFC', 'GND' then AIXM::GROUND
@@ -27,7 +27,7 @@ module NOTAM
27
27
 
28
28
  # @see NOTAM::Item#merge
29
29
  def merge
30
- super(:upper_limit)
30
+ super(:lower_limit)
31
31
  end
32
32
 
33
33
  end
data/lib/notam/item/g.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module NOTAM
4
4
 
5
- # The G item defines the lower limit for this NOTAM.
5
+ # The G item defines the upper limit for this NOTAM.
6
6
  class G < Item
7
7
 
8
8
  RE = %r(
@@ -17,7 +17,7 @@ module NOTAM
17
17
  )x.freeze
18
18
 
19
19
  # @return [AIXM::Z]
20
- def lower_limit
20
+ def upper_limit
21
21
  case captures['all']
22
22
  when 'UNL' then AIXM::UNLIMITED
23
23
  when 'SFC', 'GND' then AIXM::GROUND
@@ -27,7 +27,7 @@ module NOTAM
27
27
 
28
28
  # @see NOTAM::Item#merge
29
29
  def merge
30
- super(:lower_limit)
30
+ super(:upper_limit)
31
31
  end
32
32
 
33
33
  end
@@ -59,6 +59,8 @@ module NOTAM
59
59
  super
60
60
  fail! "invalid operation" unless (new? && !captures['old_id']) || replaces || cancels
61
61
  self
62
+ rescue
63
+ fail! 'invalid Header item'
62
64
  end
63
65
 
64
66
  # @see NOTAM::Item#merge
data/lib/notam/item.rb CHANGED
@@ -91,8 +91,12 @@ module NOTAM
91
91
  # @return [self]
92
92
  def parse
93
93
  if match_data = self.class::RE.match(text)
94
- @captures = match_data.named_captures
95
- self
94
+ begin
95
+ @captures = match_data.named_captures
96
+ self
97
+ rescue
98
+ fail! "invalid #{self.class.to_s.split('::').last} item"
99
+ end
96
100
  else
97
101
  fail! 'text does not match regexp'
98
102
  end
@@ -124,7 +128,7 @@ module NOTAM
124
128
  # @param message [String] optional error message
125
129
  # @raise [NOTAM::ParseError]
126
130
  def fail!(message=nil)
127
- fail(NOTAM::ParseError, [message, text].compact.join(': '))
131
+ fail ParseError.new([message, text].compact.join(': '), item: self)
128
132
  end
129
133
 
130
134
  # @return [String]
data/lib/notam/message.rb CHANGED
@@ -4,17 +4,22 @@ module NOTAM
4
4
 
5
5
  # NOTAM messages are plain text and consist of several ordered items:
6
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)
7
+ # WDDDD/DD ... <- Header (mandatory)
8
+ # Q) ... <- Q item: context (mandatory)
9
+ # A) ... <- A item: locations (mandatory)
10
+ # B) ... <- B item: effective from (mandatory)
11
+ # C) ... <- C item: effective until (optional)
12
+ # D) ... <- D item: timesheets (optional, may contain newlines)
13
+ # E) ... <- E item: description (mandatory, may contain newlines)
14
+ # F) ... <- F item: upper limit (optional)
15
+ # G) ... <- G item: lower limit (optional)
16
16
  # CREATED: ... <- Footer (optional)
17
17
  # SOURCE: ... <- Footer (optional)
18
+ #
19
+ # Furthermore, oversized NOTAM may be split into several partial messages
20
+ # which contain with +PART n OF n+ and +END PART n OF n+ markers. This is an
21
+ # unofficial extension and therefore the markers may be found in different
22
+ # places such as on the A item, on the E item or even somewhere in between.
18
23
  class Message
19
24
 
20
25
  UNSUPPORTED_FORMATS = %r(
@@ -24,6 +29,10 @@ module NOTAM
24
29
  \w{3}\s[A-Z]\d{4}/\d{2}\sMILITARY # USA: military
25
30
  )xi.freeze
26
31
 
32
+ PART_RE = %r(
33
+ (?:END\s+)?PART\s+(?<part_index>\d+)\s+OF\s+(?<part_index_max>\d+)
34
+ )xim.freeze
35
+
27
36
  FINGERPRINTS = %w[Q) A) B) C) D) E) F) G) CREATED: SOURCE:].freeze
28
37
 
29
38
  # Raw NOTAM text message
@@ -44,10 +53,9 @@ module NOTAM
44
53
  def initialize(text)
45
54
  fail(NOTAM::ParserError, "unsupported format") unless self.class.supported_format? text
46
55
  @text, @items, @data = text, [], {}
47
- itemize(text).each do |raw_item|
56
+ itemize(departition(@text)).each do |raw_item|
48
57
  item = NOTAM::Item.new(raw_item, data: @data).parse.merge
49
58
  @items << item
50
- @data = item.data
51
59
  end
52
60
  end
53
61
 
@@ -96,6 +104,18 @@ module NOTAM
96
104
 
97
105
  private
98
106
 
107
+ # @return [String]
108
+ def departition(text)
109
+ text.gsub(PART_RE, '').tap do
110
+ if $~ # part marker found
111
+ @data.merge!(
112
+ part_index: $~[:part_index].to_i,
113
+ part_index_max: $~[:part_index_max].to_i
114
+ )
115
+ end
116
+ end
117
+ end
118
+
99
119
  # @return [Array]
100
120
  def itemize(text)
101
121
  lines = text.gsub(/\s(#{NOTAM::Item::RE})/, "\n\\1").split("\n")
@@ -7,21 +7,24 @@ module NOTAM
7
7
  # Structure to accommodate individual schedules used on D items
8
8
  class Schedule
9
9
  EVENTS = { 'SR' => :sunrise, 'SS' => :sunset }.freeze
10
+ EVENT_HOURS = { sunrise: AIXM.time('06:00'), sunset: AIXM.time('18:00') }.freeze
10
11
  OPERATIONS = { 'PLUS' => 1, 'MINUS' => -1 }.freeze
11
12
  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
13
  DAYS = { 'MON' => :monday, 'TUE' => :tuesday, 'WED' => :wednesday, 'THU' => :thursday, 'FRI' => :friday, 'SAT' => :saturday, 'SUN' => :sunday, 'DAILY' => :any, 'DLY' => :any }.freeze
13
14
 
15
+ DATE_RE = /[0-2]\d|3[01]/.freeze
16
+ DAY_RE = /#{DAYS.keys.join('|')}/.freeze
17
+ MONTH_RE = /#{MONTHS.keys.join('|')}/.freeze
14
18
  H24_RE = /(?<h24>H24)/.freeze
15
19
  HOUR_RE = /(?<hour>[01]\d|2[0-4])(?<minute>[0-5]\d)/.freeze
16
20
  OPERATIONS_RE = /#{OPERATIONS.keys.join('|')}/.freeze
17
21
  EVENT_RE = /(?<event>SR|SS)(?:\s(?<operation>#{OPERATIONS_RE})(?<delta>\d+))?/.freeze
18
22
  TIME_RE = /#{HOUR_RE}|#{EVENT_RE}/.freeze
19
23
  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
24
+ DATETIME_RE = /(?:(?<month>#{MONTH_RE}) )?(?<date>#{DATE_RE}) (?<time>#{TIME_RE})/.freeze
25
+ DATETIME_RANGE_RE = /#{DATETIME_RE}-#{DATETIME_RE}/.freeze
23
26
 
24
- H24 = (AIXM.time('00:00')..AIXM.time('24:00')).freeze
27
+ H24 = (AIXM::BEGINNING_OF_DAY..AIXM::END_OF_DAY).freeze
25
28
 
26
29
  # Active dates or days
27
30
  #
@@ -46,8 +49,7 @@ module NOTAM
46
49
 
47
50
  # @!visibility private
48
51
  def initialize(actives, times, inactives, base_date:)
49
- @actives, @times, @inactives = actives, times, inactives
50
- @base_date ||= base_date.at(day: 1)
52
+ @actives, @times, @inactives, @base_date = actives, times, inactives, base_date
51
53
  end
52
54
 
53
55
  class << self
@@ -58,23 +60,156 @@ module NOTAM
58
60
  # @param string [String] raw schedule string
59
61
  # @param base_date [Date] month and year to assume when missing (day is
60
62
  # force set to 1)
63
+ # @return [Array<NOTAM::Schedule>] array of at least one schedule object
61
64
  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
65
+ @rules, @exceptions = cleanup(string).split(/ EXC /).map(&:strip)
66
+ @base_date = base_date.at(day: 1)
67
+ case @rules
68
+ when /^#{DATETIME_RANGE_RE}$/
69
+ parse_datetimes
70
+ when /^(#{DAY_RE}|#{TIME_RANGE_RE})/
71
+ parse_days
72
+ when /^(#{DATE_RE}|#{MONTH_RE})/
73
+ parse_dates
74
+ else
75
+ fail! "unrecognized schedule"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def parse_datetimes
82
+ from, to = @rules.split(/-/).map { datetime_from(_1) }
83
+ delta = to.date - from.date
84
+ fail! "invalid datetime" if delta < 1
85
+ inactives = days_from(@exceptions)
86
+ [
87
+ new(Dates[from.date], Times[(from.time..AIXM::END_OF_DAY)], inactives, base_date: @base_date),
88
+ (new(Dates[(from.date.next..to.date.prev)], Times[H24], inactives, base_date: @base_date) if delta > 1),
89
+ new(Dates[to.date], Times[(AIXM::BEGINNING_OF_DAY..to.time)], inactives, base_date: @base_date)
90
+ ].compact
91
+ end
92
+
93
+ %i(dates days).each do |active_unit|
94
+ inactive_unit = active_unit == :days ? :dates : :days
95
+ define_method("parse_#{active_unit}") do
96
+ raw_active_unit, raw_times, unmatched = @rules.split(/((?: ?#{TIME_RANGE_RE.decapture})+)/, 3)
97
+ fail! "unrecognized part after times" unless unmatched.empty?
98
+ actives = send("#{active_unit}_from", raw_active_unit.strip)
99
+ times = times_from(raw_times.strip)
100
+ inactives = send("#{inactive_unit}_from", @exceptions)
101
+ if times.any? &method(:across_midnight?)
102
+ times.each_with_object([]) do |time, array|
103
+ if across_midnight? time
104
+ array << new(actives, [(time.first..AIXM::END_OF_DAY)], inactives, base_date: @base_date)
105
+ array << new(actives.next, [(AIXM::BEGINNING_OF_DAY..time.last)], inactives, base_date: @base_date)
106
+ else
107
+ array << new(actives, [time], inactives, base_date: @base_date)
108
+ end
109
+ end
70
110
  else
71
- actives = dates_from(raw_actives)
72
- inactives = raw_inactives ? days_from(raw_inactives) : Days.new
111
+ [new(actives, times, inactives, base_date: @base_date)]
73
112
  end
74
- initialize(actives, times, inactives, base_date: base_date)
75
- self
76
113
  end
77
114
  end
115
+
116
+ # @return [String]
117
+ def cleanup(string)
118
+ string
119
+ .gsub(/\s+/, ' ') # collapse whitespaces to single space
120
+ .gsub(/ *- */, '-') # remove spaces around dashes
121
+ .strip
122
+ end
123
+
124
+ # @return [AIXM::Schedule::DateTime]
125
+ def datetime_from(string)
126
+ parts = string.match(DATETIME_RE).named_captures
127
+ parts['year'] = @base_date.year
128
+ parts['month'] = MONTHS[parts['month']] || @base_date.month
129
+ AIXM.datetime(
130
+ AIXM.date('%4d-%02d-%02d' % parts.slice('year', 'month', 'date').values.map(&:to_i)),
131
+ AIXM.time(parts['time'])
132
+ )
133
+ end
134
+
135
+ # @return [Array<Date, Range<Date>>]
136
+ def dates_from(string)
137
+ return Dates.new if string.nil?
138
+ array, index, base_date = [], 0, @base_date.dup
139
+ while index < string.length
140
+ case string[index..]
141
+ when /\A((?<from>#{DATE_RE})-(?:(?<month>#{MONTH_RE}) )?(?<to>#{DATE_RE}))/ # range of dates
142
+ month = $~[:month] ? MONTHS.fetch($~[:month]) : base_date.month
143
+ base_date = base_date.at(month: month, wrap: true).tap do |to_base_date|
144
+ array << (base_date.at(day: $~[:from].to_i)..to_base_date.at(day: $~[:to].to_i))
145
+ end
146
+ when /\A(?<day>#{DATE_RE})/ # single date
147
+ array << base_date.at(day: $~[:day].to_i)
148
+ when /\A(?<month>#{MONTH_RE})/
149
+ base_date = base_date.at(month: MONTHS.fetch($~[:month]), wrap: true)
150
+ else
151
+ fail! "unrecognized date"
152
+ end
153
+ index += $&.length + 1
154
+ end
155
+ Dates.new(array)
156
+ end
157
+
158
+ # @return [Array<AIXM::Schedule::Day, Range<AIXM::Schedule::Day>>]
159
+ def days_from(string)
160
+ return Days.new if string.nil?
161
+ array = if string.empty? # no declared day implies any day
162
+ [AIXM::ANY_DAY]
163
+ else
164
+ string.split(' ').map do |token|
165
+ from, to = token.split('-')
166
+ if to # range of days
167
+ (AIXM.day(DAYS.fetch(from))..AIXM.day(DAYS.fetch(to)))
168
+ else # single day
169
+ AIXM.day(DAYS.fetch(from))
170
+ end
171
+ end
172
+ end
173
+ Days.new(array)
174
+ end
175
+
176
+ # @return [Array<Range<AIXM::Schedule::Time>>]
177
+ def times_from(string)
178
+ array = string.split(/ (?!#{OPERATIONS_RE})/).map { time_range_from(_1) }
179
+ Times.new(array)
180
+ end
181
+
182
+ # @return [Range<AIXM::Schedule::Time>]
183
+ def time_range_from(string)
184
+ case string
185
+ when H24_RE
186
+ H24
187
+ else
188
+ from, to = string.split('-')
189
+ (time_from(from)..time_from(to))
190
+ end
191
+ end
192
+
193
+ # @return [AIXM::Schedule::Time]
194
+ def time_from(string)
195
+ case string
196
+ when HOUR_RE
197
+ hour, minute = $~[:hour], $~[:minute]
198
+ AIXM.time([hour, minute].join(':'))
199
+ when EVENT_RE
200
+ event, operation, delta = $~[:event], $~[:operation], $~[:delta]&.to_i
201
+ AIXM.time(EVENTS.fetch(event), plus: delta ? OPERATIONS.fetch(operation) * delta : 0)
202
+ else
203
+ fail! "unrecognized time"
204
+ end
205
+ end
206
+
207
+ # @return [Boolean]
208
+ def across_midnight?(time_range)
209
+ from = time_range.first.time || EVENT_HOURS.fetch(time_range.first.event).time
210
+ to = time_range.last.time || EVENT_HOURS.fetch(time_range.last.event).time
211
+ from > to
212
+ end
78
213
  end
79
214
 
80
215
  # @return [String]
@@ -139,78 +274,6 @@ module NOTAM
139
274
  resolve(on: date, xy: xy).slice(date).times.cover? AIXM.time(at)
140
275
  end
141
276
 
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
277
  # @abstract
215
278
  class ScheduleArray < Array
216
279
  # @return [String]
@@ -231,6 +294,19 @@ module NOTAM
231
294
  def cover?(object)
232
295
  any? { object.covered_by? _1 }
233
296
  end
297
+
298
+ # Step through all elements and shift all dates or days to the next day
299
+ #
300
+ # @return [ScheduleArray]
301
+ def next
302
+ entries.map do |entry|
303
+ if entry.instance_of? Range
304
+ (entry.first.next..entry.last.next)
305
+ else
306
+ entry.next
307
+ end
308
+ end.then { self.class.new(_1) }
309
+ end
234
310
  end
235
311
 
236
312
  class Dates < ScheduleArray
@@ -240,7 +316,7 @@ module NOTAM
240
316
  def cluster
241
317
  self.class.new(
242
318
  entries
243
- .slice_when { _1.succ != _2 }
319
+ .slice_when { _1.next != _2 }
244
320
  .map { _1.count > 1 ? (_1.first.._1.last) : _1.first }
245
321
  )
246
322
  end
data/lib/notam/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NOTAM
4
- VERSION = "0.1.2".freeze
4
+ VERSION = "1.0.0".freeze
5
5
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: notam
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sven Schwyn
@@ -10,27 +10,26 @@ bindir: bin
10
10
  cert_chain:
11
11
  - |
12
12
  -----BEGIN CERTIFICATE-----
13
- MIIDcDCCAligAwIBAgIBATANBgkqhkiG9w0BAQUFADA/MQ0wCwYDVQQDDARydWJ5
14
- MRkwFwYKCZImiZPyLGQBGRYJYml0Y2V0ZXJhMRMwEQYKCZImiZPyLGQBGRYDY29t
15
- MB4XDTIxMTEwODE0MzIyM1oXDTIyMTEwODE0MzIyM1owPzENMAsGA1UEAwwEcnVi
16
- eTEZMBcGCgmSJomT8ixkARkWCWJpdGNldGVyYTETMBEGCgmSJomT8ixkARkWA2Nv
17
- bTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANwuD4geNdhpSVNJTtHb
18
- fmVAoPxmER4oyGgaVJSidn/OjU5PcpdypMI/WIxfvjfFizq6kQYAsJZbCr6fG+UN
19
- 2dZGMXcAC/uKQL5nYESjCPJ4IJP/SC+fiiEpxHQk7PNFoiUVRUieUZIAfHZAdnY3
20
- ye1/niW7ud0vwKIMrysKWxjgkE0Y6Af1QLzV/6brVRRC5MvHIzYJd8BiSP+wY1O8
21
- VElw1f6d90KEz2vaQfX7vCxrzIbvAnYiSvM0AIPy/zigTqpW6w3w4sQxQj81oQ9U
22
- 9vDYtQzXj0c9VrSLvb0DgiGug2cU2bDjA4L3cBE1szX4tbfo8syYqMq51/kTGDxW
23
- YNUCAwEAAaN3MHUwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0OBBYEFJ8r
24
- wy1HraZDqg3Khf9UonMWMtJUMB0GA1UdEQQWMBSBEnJ1YnlAYml0Y2V0ZXJhLmNv
25
- bTAdBgNVHRIEFjAUgRJydWJ5QGJpdGNldGVyYS5jb20wDQYJKoZIhvcNAQEFBQAD
26
- ggEBACI7lJKRbnRjz0T4Wb9jH4SE0A2yaHAoBzj96luVDjNyoRT3688trEZS75Sg
27
- GKfChxqKncBrSpxJ0YfWbymNHfUrKhcdSifJ/TtUrTasm6LSnJYLOnLKDO3v8eL3
28
- gRTq8a5wA7Xtijx3MJEyzdeUh7N+UMKuPps/flPgH5yabUxgxrvuhrXF7Z96nrsP
29
- EOmNMTc8H7wo4BAKfuMcI/Gh2oCf+tAhr0bGsXyBikmJ6XA45mrOMgv19M1+aMpn
30
- 1+2Y1+i+4jd1B7qxIgOLxQTNIJiwE0sqU1itFfuesfgUACS7M0IV9u9Bp4hBGNEw
31
- 5JcY2h7owdMxXIvgk1oakgldFJc=
13
+ MIIDODCCAiCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDDBhydWJ5
14
+ L0RDPWJpdGNldGVyYS9EQz1jb20wHhcNMjIxMTA2MTIzNjUwWhcNMjMxMTA2MTIz
15
+ NjUwWjAjMSEwHwYDVQQDDBhydWJ5L0RDPWJpdGNldGVyYS9EQz1jb20wggEiMA0G
16
+ CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDcLg+IHjXYaUlTSU7R235lQKD8ZhEe
17
+ KMhoGlSUonZ/zo1OT3KXcqTCP1iMX743xYs6upEGALCWWwq+nxvlDdnWRjF3AAv7
18
+ ikC+Z2BEowjyeCCT/0gvn4ohKcR0JOzzRaIlFUVInlGSAHx2QHZ2N8ntf54lu7nd
19
+ L8CiDK8rClsY4JBNGOgH9UC81f+m61UUQuTLxyM2CXfAYkj/sGNTvFRJcNX+nfdC
20
+ hM9r2kH1+7wsa8yG7wJ2IkrzNACD8v84oE6qVusN8OLEMUI/NaEPVPbw2LUM149H
21
+ PVa0i729A4IhroNnFNmw4wOC93ARNbM1+LW36PLMmKjKudf5Exg8VmDVAgMBAAGj
22
+ dzB1MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSfK8MtR62mQ6oN
23
+ yoX/VKJzFjLSVDAdBgNVHREEFjAUgRJydWJ5QGJpdGNldGVyYS5jb20wHQYDVR0S
24
+ BBYwFIEScnVieUBiaXRjZXRlcmEuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAYG2na
25
+ ye8OE2DANQIFM/xDos/E4DaPWCJjX5xvFKNKHMCeQYPeZvLICCwyw2paE7Otwk6p
26
+ uvbg2Ks5ykXsbk5i6vxDoeeOLvmxCqI6m+tHb8v7VZtmwRJm8so0eSX0WvTaKnIf
27
+ CAn1bVUggczVdNoBXw9WAILKyw9bvh3Ft740XZrR74sd+m2pGwjCaM8hzLvrVbGP
28
+ DyYhlBeRWyQKQ0WDIsiTSRhzK8HwSTUWjvPwx7SEdIU/HZgyrk0ETObKPakVu6bH
29
+ kAyiRqgxF4dJviwtqI7mZIomWL63+kXLgjOjMe1SHxfIPo/0ji6+r1p4KYa7o41v
30
+ fwIwU1MKlFBdsjkd
32
31
  -----END CERTIFICATE-----
33
- date: 2022-05-19 00:00:00.000000000 Z
32
+ date: 2022-11-18 00:00:00.000000000 Z
34
33
  dependencies:
35
34
  - !ruby/object:Gem::Dependency
36
35
  name: aixm
@@ -41,7 +40,7 @@ dependencies:
41
40
  version: '1'
42
41
  - - ">="
43
42
  - !ruby/object:Gem::Version
44
- version: 1.2.1
43
+ version: 1.3.2
45
44
  type: :runtime
46
45
  prerelease: false
47
46
  version_requirements: !ruby/object:Gem::Requirement
@@ -51,7 +50,7 @@ dependencies:
51
50
  version: '1'
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
- version: 1.2.1
53
+ version: 1.3.2
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: i18n
57
56
  requirement: !ruby/object:Gem::Requirement
@@ -258,7 +257,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
258
257
  - !ruby/object:Gem::Version
259
258
  version: '0'
260
259
  requirements: []
261
- rubygems_version: 3.3.14
260
+ rubygems_version: 3.3.26
262
261
  signing_key:
263
262
  specification_version: 4
264
263
  summary: Parser for NOTAM (Notice to Airmen) messages
metadata.gz.sig CHANGED
Binary file