notam 0.1.3 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b793dbd67af43de50525171950c176860cb7095b865e2362d1aaf31c4a4e737c
4
- data.tar.gz: e3915a6680b06eb3a43270012dfb552b84443605a21dafd44f7c72597973a3de
3
+ metadata.gz: 45b4ce39f423833013299133d3b54d3ee759d701aa6a53d0075e8546bd48dfa2
4
+ data.tar.gz: f4a86ad18546f654becd05db944a2c9b22213f4095794c20dc911dccdfa93be8
5
5
  SHA512:
6
- metadata.gz: cccba536bce34a893bdc717ba2b4983bf09e66d499acc33cec4ec949a52bb4685319c36fe99c30ee73ba82e91d33a8c5446a96f7ba18abf468c5fd6dc147daa1
7
- data.tar.gz: 1acee0ea83919ed99d2825d81ea34d1e0fba3464a31c68706e870baa8e72c8e2e05c5ac282410bf09cde59be5a01b809c8fe780771c12197bee7331b13d9318b
6
+ metadata.gz: c62bd682d3849093fb96d887f16933159945ecc60edae31da0415cc35f8c2bd4e30655d336a5134eddc0cb9443e7b492aae99f3868dad09c47d3731d7f69bb17
7
+ data.tar.gz: a52c3ccb305e863d5cd8dc4bd2ff345406ca9a73ae4493cbc4445b8f7f3d63ab98371f219689b236e2c0257aab503b86700b1a770abd2bebc3a1fbd3a92a0e3a
checksums.yaml.gz.sig CHANGED
@@ -1 +1,3 @@
1
- �p:��a>t����>E�i8� �����K[�W�А�} kLw�:v3��n��{hIE���0i\/���"C ǿtN�mӛza�sژI�i�"�7ص}���r3s����s Keu�� �������G�E� z���NW� 8zt��*����e��A]1�JwfKͥJ ���i#�CN3C�b���Xr�7 ��,���P��x�{ zlO�#��UCխu<�7�׊�b����&�1���W�����
1
+ ���3Ƈ�3�*�P���3g��X�).5W��}!ҕ� �ږ!�pTmش���vz���4m���ڤU���]XHFK���a��i��r�Dj��`������9f��$$�����?z/����]o��Z5��L/��Tbt8jπ����������3>ε�1 C��9z#�E��ޅ�y�r5a�+��,
2
+ ���љt� u���A$2)�D�Vh��B��^���4,��(�T�I���jHV��
3
+ �ϛ�5�@�u�
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  Nothing so far
4
4
 
5
+ ## 1.1.0
6
+
7
+ #### Additions
8
+ * Extract subject group and condition group on Q item
9
+
10
+ ## 1.0.0
11
+
12
+ #### Breaking Changes
13
+ * `NOTAM::Schedule.parse` now returns an array of `NOTAM_Schedule` instances
14
+ instead of just a single one.
15
+
16
+ #### Changes
17
+ * Edge case tolerant extraction of `PART n OF n` and `END PART n OF n` markers
18
+
19
+ #### Additions
20
+ * Support for datetime ranges (i.e. `1 APR 2000-20 MAY 2000`) as well as times
21
+ across midnight (i.e. `1 APR 1900-0500`) on D items.
22
+ * Wrap all exceptions raised while parsing items.
23
+
5
24
  ## 0.1.3
6
25
 
7
26
  #### Fixes
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
@@ -65,7 +66,9 @@ The resulting hash for this example looks as follows:
65
66
  id_year: 2022,
66
67
  new?: true,
67
68
  fir: "LSAS",
69
+ subject_group: :airspace_restrictions,
68
70
  subject: :restricted_area,
71
+ condition_group: :changes,
69
72
  condition: :activated,
70
73
  traffic: :vfr,
71
74
  purpose: [:operational_significance, :flight_operations],
@@ -75,8 +78,8 @@ The resulting hash for this example looks as follows:
75
78
  center_point: #<AIXM::XY 46.40000000N 007.03333333E>,
76
79
  radius: #<AIXM::D 4.0 nm>,
77
80
  locations: ["LSAS"],
78
- part_index: 1,
79
- part_index_max: 1,
81
+ part_index: 2,
82
+ part_index_max: 3,
80
83
  effective_at: 2022-04-11 09:00:00 UTC,
81
84
  expiration_at: 2022-05-13 14:00:00 UTC,
82
85
  estimated_expiration?: false,
@@ -106,7 +109,12 @@ A few highlights to note here:
106
109
 
107
110
  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
111
 
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).
112
+ You get a `NOTAM::ParseError` in case the raw NOTAM text message fails to be parsed. This error object features two notable methods:
113
+
114
+ * `item` – the faulty item (if already available)
115
+ * `cause` – the underlying error object (if any)
116
+
117
+ If you're sure the NOTAM is correct, please [submit an issue](#development) or fix the bug and [submit a pull request](#development).
110
118
 
111
119
  See the [API documentation](https://www.rubydoc.info/gems/notam) for more.
112
120
 
@@ -114,8 +122,9 @@ See the [API documentation](https://www.rubydoc.info/gems/notam) for more.
114
122
 
115
123
  ### Anatomy of a NOTAM message
116
124
 
117
- A NOTAM message consists of a header followed by the following items:
125
+ A NOTAM message consists of the following items in order:
118
126
 
127
+ * Header: ID and type of NOTAM
119
128
  * [Q item](https://www.rubydoc.info/gems/notam/NOTAM/Q): Essential information such as purpose or center point and radius
120
129
  * [A item](https://www.rubydoc.info/gems/notam/NOTAM/A): Affected locations
121
130
  * [B item](https://www.rubydoc.info/gems/notam/NOTAM/B): When the NOTAM becomes effective
@@ -124,6 +133,9 @@ A NOTAM message consists of a header followed by the following items:
124
133
  * [E item](https://www.rubydoc.info/gems/notam/NOTAM/E): Free text description
125
134
  * [F item](https://www.rubydoc.info/gems/notam/NOTAM/F): Upper limit (optional)
126
135
  * [G item](https://www.rubydoc.info/gems/notam/NOTAM/G): Lower limit (optional)
136
+ * Footer: Any number of lines with metadata such as `CREATED` and `SOURCE`
137
+
138
+ 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
139
 
128
140
  ### FIR
129
141
 
@@ -190,11 +202,18 @@ For compatibility, schedule dates and times are expressed using the correspondin
190
202
  * [Day](https://www.rubydoc.info/gems/aixm/AIXM/Schedule/Day)
191
203
  * [Time](https://www.rubydoc.info/gems/aixm/AIXM/Schedule/Time)
192
204
 
205
+ Raw and parsed NOTAM schedule times differ in how the "end of day" is encoded:
206
+
207
+ NOTAM | Beginning of Day | End of Day | Remarks
208
+ -------|------------------|------------|--------
209
+ Raw | `"0000"` | `"2359"` | `"2400"` is considered illegal
210
+ Parsed | `00:00` | `24:00` | the Ruby way
211
+
193
212
  ### References
194
213
 
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)
214
+ * [ICAO Doc 8126: Aeronautical Information Services Manual](https://www.icao.int/NACC/Documents/eDOCS/AIM/8126_unedited_en%20Jul2021.pdf)
215
+ * [EUROCONTROL Guidelines Operating Procedures AIS Dynamic Data (OPADD)](https://www.eurocontrol.int/sites/default/files/2021-07/eurocontrol-guidelines-opadd-ed4-1.pdf)
196
216
  * [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
217
  * [NOTAM Contractions](https://www.notams.faa.gov/downloads/contractions.pdf)
199
218
  * [NOTAM format cheat sheet](http://vat-air.dk/files/ICAO%20NOTAM%20format.pdf)
200
219
  * [Introduction on Wikipedia](https://en.wikipedia.org/wiki/NOTAM)
@@ -207,12 +226,13 @@ Please [create a translation request issue](https://github.com/svoop/notam/issue
207
226
 
208
227
  ## Tests and Fixtures
209
228
 
210
- The test suite may run against live NOTAM if you set the `SPEC_SCOPE` environment variable:
229
+ The test suite may run against live NOTAM depending on whether and how you set the `SPEC_SCOPE` environment variable:
211
230
 
212
231
  ```
232
+ export SPEC_SCOPE=none # don't run against any NOTAM fixtures (default)
233
+ export SPEC_SCOPE=W0214/22 # run against given NOTAM fixture only
213
234
  export SPEC_SCOPE=all # run against all NOTAM fixtures
214
235
  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
236
  ```
217
237
 
218
238
  The NOTAM fixtures are written to `spec/fixtures`, you can manage them using a Rake tasks:
data/lib/locales/en.yml CHANGED
@@ -307,6 +307,22 @@ en:
307
307
  ZSHA: "Shanghai ACC"
308
308
  ZWUQ: "Urumqi ACC"
309
309
  ZYSH: "Shenyang ACC"
310
+ subject_groups:
311
+ airspace_organization: "airspace organization"
312
+ communications_and_surveillance_facilities: "communications and surveillance facilities"
313
+ facilities_and_services: "facilities and services"
314
+ gnss_services: "GNSS services"
315
+ instrument_and_microwave_landing_system: "instrument and microwave landing system"
316
+ checklist: "checklist"
317
+ lighting_facilities: "lighting facilities"
318
+ movement_and_landing_area: "movement and landing area"
319
+ terminal_and_en_route_navigation_facilities: "terminal and en route navigation facilities"
320
+ other_information: "other information"
321
+ air_traffic_procedures: "air traffic procedures"
322
+ airspace_restrictions: "airspace restrictions"
323
+ air_traffic_and_volmet_services: "air traffic and VOLMET services"
324
+ warning: "warning"
325
+ other: "other"
310
326
  subjects:
311
327
  minimum_altitude: "minimum altitude"
312
328
  class_bcde_surface_area: "class B/C/D/E surface area"
@@ -488,6 +504,14 @@ en:
488
504
  aerial_survey: "aerial survey"
489
505
  model_flying: "model flying"
490
506
  other: "other"
507
+ condition_groups:
508
+ availability: "availabiity"
509
+ changes: "changes"
510
+ hazard_conditions: "hazard conditions"
511
+ checklist: "checklist"
512
+ limitations: "limitations"
513
+ trigger: "trigger"
514
+ other: "other"
491
515
  conditions:
492
516
  withdrawn_for_maintenance: "withdrawn for maintenance"
493
517
  available_for_daylight_operation: "available for daylight operation"
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
@@ -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/q.rb CHANGED
@@ -26,11 +26,21 @@ module NOTAM
26
26
  captures['fir']
27
27
  end
28
28
 
29
+ # @return [Symbol]
30
+ def subject_group
31
+ NOTAM.subject_group_for(captures['subject'][0,1])
32
+ end
33
+
29
34
  # @return [Symbol]
30
35
  def subject
31
36
  NOTAM.subject_for(captures['subject'])
32
37
  end
33
38
 
39
+ # @return [Symbol]
40
+ def condition_group
41
+ NOTAM.condition_group_for(captures['condition'][0,1])
42
+ end
43
+
34
44
  # @return [Symbol]
35
45
  def condition
36
46
  NOTAM.condition_for(captures['condition'])
@@ -82,7 +92,7 @@ module NOTAM
82
92
 
83
93
  # @see NOTAM::Item#merge
84
94
  def merge
85
- super(:fir, :subject, :condition, :traffic, :purpose, :scope, :lower_limit, :upper_limit, :center_point, :radius)
95
+ super(:fir, :subject_group, :subject, :condition_group, :condition, :traffic, :purpose, :scope, :lower_limit, :upper_limit, :center_point, :radius)
86
96
  end
87
97
 
88
98
  end
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
@@ -26,6 +26,14 @@ module NOTAM
26
26
  FIRS.fetch(fir)
27
27
  end
28
28
 
29
+ # Translates the NOTAM subject group code to human/machine readable symbol
30
+ #
31
+ # @param code [String] one letter subject group code
32
+ # @return [Symbol] value from {NOTAM::SUBJECT_GROUPS}
33
+ def subject_group_for(code)
34
+ SUBJECT_GROUPS.fetch(code)
35
+ end
36
+
29
37
  # Translates the NOTAM subject code to human/machine readable symbol
30
38
  #
31
39
  # @param code [String] two letter subject code
@@ -34,6 +42,14 @@ module NOTAM
34
42
  SUBJECTS.fetch(code)
35
43
  end
36
44
 
45
+ # Translates the NOTAM condition group code to human/machine readable symbol
46
+ #
47
+ # @param code [String] one letter condition group code
48
+ # @return [Symbol] value from {NOTAM::CONDITION_GROUPS}
49
+ def condition_group_for(code)
50
+ CONDITION_GROUPS.fetch(code)
51
+ end
52
+
37
53
  # Translates the NOTAM condition code to human/machine readable symbol
38
54
  #
39
55
  # @param code [String] two letter condition code
@@ -393,6 +409,27 @@ module NOTAM
393
409
  'ZYSH' => [:CH]
394
410
  }.freeze
395
411
 
412
+ # International NOTAM Q codes for subject groups
413
+ #
414
+ # @see https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html
415
+ SUBJECT_GROUPS = {
416
+ 'A' => :airspace_organization,
417
+ 'C' => :communications_and_surveillance_facilities,
418
+ 'F' => :facilities_and_services,
419
+ 'G' => :gnss_services,
420
+ 'I' => :instrument_and_microwave_landing_system,
421
+ 'K' => :checklist,
422
+ 'L' => :lighting_facilities,
423
+ 'M' => :movement_and_landing_area,
424
+ 'N' => :terminal_and_en_route_navigation_facilities,
425
+ 'O' => :other_information,
426
+ 'P' => :air_traffic_procedures,
427
+ 'R' => :airspace_restrictions,
428
+ 'S' => :air_traffic_and_volmet_services,
429
+ 'W' => :warning,
430
+ 'X' => :other
431
+ }.freeze
432
+
396
433
  # International NOTAM Q codes for subjects
397
434
  #
398
435
  # @see https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html
@@ -579,6 +616,19 @@ module NOTAM
579
616
  'XX' => :other
580
617
  }.freeze
581
618
 
619
+ # International NOTAM Q codes for condition groups
620
+ #
621
+ # @see https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html
622
+ CONDITION_GROUPS = {
623
+ 'A' => :availability,
624
+ 'C' => :changes,
625
+ 'H' => :hazard_conditions,
626
+ 'K' => :checklist,
627
+ 'L' => :limitations,
628
+ 'T' => :trigger,
629
+ 'X' => :other
630
+ }.freeze
631
+
582
632
  # International NOTAM Q codes for conditions
583
633
  #
584
634
  # @see https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html
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.3".freeze
4
+ VERSION = "1.1.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.3
4
+ version: 1.1.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-24 00:00:00.000000000 Z
32
+ date: 2022-11-19 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.3.0
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.3.0
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