aipp 0.1.3 → 0.2.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,101 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ # ENR Navaids
5
+ class ENR41 < AIP
6
+ using AIPP::Refinements
7
+
8
+ def parse
9
+ load_html.css('tbody').each do |tbody|
10
+ tbody.css('tr').to_enum.with_index(1).each do |tr, index|
11
+ tds = cleanup(node: tr).css('td')
12
+ master, slave = tds[1].text.strip.gsub(/[^\w-]/, '').downcase.split('-')
13
+ navaid = AIXM.send(master, base_from(tds).merge(send("#{master}_from", tds)))
14
+ navaid.source = source_for(tr)
15
+ navaid.timetable = timetable_from(tds[4])
16
+ navaid.remarks = remarks_from(tds[5], tds[7], tds[9])
17
+ navaid.send("associate_#{slave}", channel: channel_from(tds[3])) if slave
18
+ aixm.features << navaid
19
+ rescue => error
20
+ warn("error parsing navigational aid at ##{index}: #{error.message}", context: error)
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def base_from(tds)
28
+ {
29
+ organisation: organisation_lf,
30
+ id: tds[2].text.strip,
31
+ name: tds[0].text.strip,
32
+ xy: xy_from(tds[5]),
33
+ z: z_from(tds[6])
34
+ }
35
+ end
36
+
37
+ def vor_from(tds)
38
+ {
39
+ type: :conventional,
40
+ f: frequency_from(tds[3]),
41
+ north: :magnetic,
42
+ }
43
+ end
44
+
45
+ def dme_from(tds)
46
+ {
47
+ channel: channel_from(tds[3])
48
+ }
49
+ end
50
+
51
+ def ndb_from(tds)
52
+ {
53
+ type: :en_route,
54
+ f: frequency_from(tds[3])
55
+ }
56
+ end
57
+
58
+ def tacan_from(tds)
59
+ {
60
+ channel: channel_from(tds[3])
61
+ }
62
+ end
63
+
64
+ def z_from(td)
65
+ parts = td.text.strip.split(/\s+/)
66
+ AIXM.z(parts[0].to_i, :qnh) if parts[1] == 'ft'
67
+ end
68
+
69
+ def frequency_from(td)
70
+ parts = td.text.strip.split(/\s+/)
71
+ AIXM.f(parts[0].to_f, parts[1]) if parts[1] =~ /hz$/i
72
+ end
73
+
74
+ def channel_from(td)
75
+ parts = td.text.strip.split(/\s+/)
76
+ parts.last if parts[-2].downcase == 'ch'
77
+ end
78
+
79
+ def timetable_from(td)
80
+ code = td.text.strip
81
+ AIXM.timetable(code: code) unless code.empty?
82
+ end
83
+
84
+ def remarks_from(*parts)
85
+ part_titles = ['RANGE', 'SITUATION', 'OBSERVATIONS']
86
+ [].tap do |remarks|
87
+ parts.each.with_index do |part, index|
88
+ text = if index == 0
89
+ part = part.text.strip.split(/\s+/)
90
+ part.shift(2)
91
+ part.join(' ').blank_to_nil
92
+ else
93
+ part.text.strip.blank_to_nil
94
+ end
95
+ remarks << "#{part_titles[index]}:\n#{text}" if text
96
+ end
97
+ end.join("\n\n").blank_to_nil
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,26 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ # Designated Points
5
+ class ENR43 < AIP
6
+
7
+ def parse
8
+ load_html.css('tbody').each do |tbody|
9
+ tbody.css('tr').to_enum.with_index(1).each do |tr, index|
10
+ tds = cleanup(node: tr).css('td')
11
+ designated_point = AIXM.designated_point(
12
+ type: :icao,
13
+ id: tds[0].text.strip,
14
+ xy: xy_from(tds[1])
15
+ )
16
+ designated_point.source = source_for(tr)
17
+ aixm.features << designated_point
18
+ rescue => error
19
+ warn("error parsing designated point at ##{index}: #{error.message}", context: error)
20
+ end
21
+ end
22
+ end
23
+
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,65 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ # D/P/R Zones
5
+ class ENR51 < AIP
6
+ using AIPP::Refinements
7
+
8
+ TYPES = {
9
+ 'D' => 'D',
10
+ 'P' => 'P',
11
+ 'R' => 'R',
12
+ 'ZIT' => 'P'
13
+ }.freeze
14
+
15
+ def parse
16
+ load_html.css('tbody:has(tr[id^=mid])').each do |tbody|
17
+ airspace = nil
18
+ tbody.css('tr').to_enum.with_index(1).each do |tr, index|
19
+ if tr.attr(:class) =~ /keep-with-next-row/
20
+ airspace = airspace_from cleanup(node: tr)
21
+ else
22
+ begin
23
+ tds = cleanup(node: tr).css('td')
24
+ airspace.geometry = geometry_from tds[0]
25
+ fail("geometry is not closed") unless airspace.geometry.closed?
26
+ airspace.layers << layer_from(tds[1])
27
+ airspace.layers.first.timetable = timetable_from tds[2]
28
+ airspace.layers.first.remarks = remarks_from(tds[2], tds[3], tds[4])
29
+ aixm.features << airspace
30
+ rescue => error
31
+ warn("error parsing airspace `#{airspace.name}' at ##{index}: #{error.message}", context: error)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def airspace_from(tr)
41
+ spans = tr.css(:span)
42
+ AIXM.airspace(
43
+ name: [spans[1], spans[2], spans[3], spans[5].text.blank_to_nil].compact.join(' '),
44
+ local_type: [spans[1], spans[2], spans[3]].compact.join(' '),
45
+ type: TYPES.fetch(spans[2].text)
46
+ ).tap do |airspace|
47
+ airspace.source = source_for(tr)
48
+ end
49
+ end
50
+
51
+ def remarks_from(*parts)
52
+ part_titles = ['TIMETABLE', 'RESTRICTION', 'AUTHORITY/CONDITIONS']
53
+ [].tap do |remarks|
54
+ parts.each.with_index do |part, index|
55
+ if part = part.text.gsub(/ +/, ' ').gsub(/(\n ?)+/, "\n").strip.blank_to_nil
56
+ unless index.zero? && part == 'H24'
57
+ remarks << "#{part_titles[index]}:\n#{part}"
58
+ end
59
+ end
60
+ end
61
+ end.join("\n\n").blank_to_nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,177 @@
1
+ module AIPP
2
+ module LF
3
+ module Helper
4
+ using AIPP::Refinements
5
+ using AIXM::Refinements
6
+
7
+ BORDERS = {
8
+ 'franco-allemande' => 'FRANCE_GERMANY',
9
+ 'franco-espagnole' => 'FRANCE_SPAIN',
10
+ 'franco-italienne' => 'FRANCE_ITALY',
11
+ 'franco-suisse' => 'FRANCE_SWITZERLAND',
12
+ 'franco-luxembourgeoise' => 'FRANCE_LUXEMBOURG',
13
+ 'franco-belge' => 'BELGIUM_FRANCE',
14
+ 'germano-suisse' => 'GERMANY_SWITZERLAND',
15
+ 'hispano-andorrane' => 'ANDORRA_SPAIN',
16
+ 'la côte atlantique française' => 'FRANCE_ATLANTIC_COAST',
17
+ 'côte méditérrannéenne' => 'FRANCE_MEDITERRANEAN_COAST',
18
+ 'limite des eaux territoriales atlantique françaises' => 'FRANCE_ATLANTIC_TERRITORIAL_SEA',
19
+ 'parc national des écrins' => 'FRANCE_ECRINS_NATIONAL_PARK'
20
+ }.freeze
21
+
22
+ INTERSECTIONS = {
23
+ 'FRANCE_SPAIN|ANDORRA_SPAIN' => AIXM.xy(lat: 42.502720, long: 1.725965),
24
+ 'ANDORRA_SPAIN|FRANCE_SPAIN' => AIXM.xy(lat: 42.603571, long: 1.442681),
25
+ 'FRANCE_SWITZERLAND|FRANCE_ITALY' => AIXM.xy(lat: 45.922701, long: 7.044125),
26
+ 'BELGIUM_FRANCE|FRANCE_LUXEMBOURG' => AIXM.xy(lat: 49.546428, long: 5.818415),
27
+ 'FRANCE_LUXEMBOURG|FRANCE_GERMANY' => AIXM.xy(lat: 49.469438, long: 6.367516),
28
+ 'FRANCE_GERMANY|FRANCE_SWITZERLAND' => AIXM.xy(lat: 47.589831, long: 7.589049),
29
+ 'GERMANY_SWITZERLAND|FRANCE_GERMANY' => AIXM.xy(lat: 47.589831, long: 7.589049)
30
+ }
31
+
32
+ ANGLICISE_MAP = {
33
+ /[^A-Z0-9 .\-]/ => '',
34
+ / 0(\d)/ => ' \1',
35
+ /(\d)-(\d)/ => '\1.\2',
36
+ /PARTIE/ => '',
37
+ /DELEG\./ => 'DELEG ',
38
+ /FRANCAISE?/ => 'FR',
39
+ /ANGLAISE?/ => 'UK',
40
+ /BELGE/ => 'BE',
41
+ /LUXEMBOURGEOISE?/ => 'LU',
42
+ /ALLEMANDE?/ => 'DE',
43
+ /SUISSE/ => 'CH',
44
+ /ITALIEN(?:NE)?/ => 'IT',
45
+ /ESPAGNOLE?/ => 'ES',
46
+ /ANDORRANE?/ => 'AD',
47
+ /NORD/ => 'N',
48
+ /EST/ => 'E',
49
+ /SUD/ => 'S',
50
+ /OEST/ => 'W',
51
+ /ANGLO NORMANDES/ => 'ANGLO-NORMANDES',
52
+ / +/ => ' '
53
+ }.freeze
54
+
55
+ # Download URL
56
+
57
+ def url_for(aip_file)
58
+ "https://www.sia.aviation-civile.gouv.fr/dvd/eAIP_%s/FRANCE/AIRAC-%s/html/eAIP/FR-%s-fr-FR.html" % [
59
+ options[:airac].date.strftime('%d_%^b_%Y'), # 04_JAN_2018
60
+ options[:airac].date.xmlschema, # 2018-01-04
61
+ aip_file # ENR-5.1 or AD-2.LFMV
62
+ ]
63
+ end
64
+
65
+ # Templates
66
+
67
+ def organisation_lf
68
+ @organisation_lf ||= AIXM.organisation(
69
+ name: 'FRANCE',
70
+ type: 'S'
71
+ ).tap do |organisation|
72
+ organisation.id = 'LF'
73
+ end
74
+ end
75
+
76
+ # Transformations
77
+
78
+ def cleanup(node:)
79
+ node.tap do |root|
80
+ root.css('del').each { |n| n.remove } # remove deleted entries
81
+ end
82
+ end
83
+
84
+ def anglicise(name:)
85
+ name.uptrans.tap do |string|
86
+ ANGLICISE_MAP.each do |regexp, replacement|
87
+ string.gsub!(regexp, replacement)
88
+ end
89
+ end
90
+ end
91
+
92
+ # Parsers
93
+
94
+ def source_for(element)
95
+ [
96
+ options[:region],
97
+ @aip.split('-').first,
98
+ @aip,
99
+ options[:airac].date.xmlschema,
100
+ element.line
101
+ ].join('|')
102
+ end
103
+
104
+ def xy_from(td)
105
+ parts = td.text.strip.split(/\s+/)
106
+ AIXM.xy(lat: parts[0], long: parts[1])
107
+ end
108
+
109
+ def z_from(limit)
110
+ case limit
111
+ when nil then nil
112
+ when 'SFC' then AIXM::GROUND
113
+ when 'UNL' then AIXM::UNLIMITED
114
+ when /(\d+)ftASFC/ then AIXM.z($1.to_i, :qfe)
115
+ when /(\d+)ftAMSL/ then AIXM.z($1.to_i, :qnh)
116
+ when /FL(\d+)/ then AIXM.z($1.to_i, :qne)
117
+ else fail "z `#{limit}' not recognized"
118
+ end
119
+ end
120
+
121
+ def layer_from(td)
122
+ above, below = td.text.gsub(/ /, '').split(/\n+/).select(&:blank_to_nil).split { |e| e.match? '---+' }
123
+ above.reverse!
124
+ AIXM.layer(
125
+ vertical_limits: AIXM.vertical_limits(
126
+ max_z: z_from(above[1]),
127
+ upper_z: z_from(above[0]),
128
+ lower_z: z_from(below[0]),
129
+ min_z: z_from(below[1])
130
+ )
131
+ )
132
+ end
133
+
134
+ def geometry_from(td)
135
+ AIXM.geometry.tap do |geometry|
136
+ buffer = {}
137
+ td.text.gsub(/\s+/, ' ').strip.split(/ - /).append('end').each do |element|
138
+ case element
139
+ when /arc (anti-)?horaire .+ sur (\S+) , (\S+)/i
140
+ geometry << AIXM.arc(
141
+ xy: buffer.delete(:xy),
142
+ center_xy: AIXM.xy(lat: $2, long: $3),
143
+ clockwise: $1.nil?
144
+ )
145
+ when /cercle de ([\d\.]+) (NM|km|m) .+ sur (\S+) , (\S+)/i
146
+ geometry << AIXM.circle(
147
+ center_xy: AIXM.xy(lat: $3, long: $4),
148
+ radius: AIXM.d($1.to_f, $2)
149
+ )
150
+ when /end|(\S+) , (\S+)/
151
+ geometry << AIXM.point(xy: buffer[:xy]) if buffer.has_key?(:xy)
152
+ buffer[:xy] = AIXM.xy(lat: $1, long: $2) if $1
153
+ when /^frontière ([\w-]+)/i, /^(\D[^(]+)/i
154
+ border_name = BORDERS.fetch($1.downcase.strip)
155
+ buffer[:xy] ||= INTERSECTIONS.fetch("#{buffer[:border_name]}|#{border_name}")
156
+ buffer[:border_name] = border_name
157
+ if border_name == 'FRANCE_SPAIN' # specify which part of this split border
158
+ border_name += buffer[:xy].lat < 42.55 ? '_EAST' : '_WEST'
159
+ end
160
+ geometry << AIXM.border(
161
+ xy: buffer.delete(:xy),
162
+ name: border_name
163
+ )
164
+ else
165
+ fail "geometry `#{element}' not recognized"
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ def timetable_from(td)
172
+ AIXM::H24 if td.text.gsub(/\W/, '') == 'H24'
173
+ end
174
+
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,46 @@
1
+ module AIPP
2
+
3
+ # Topologically sortable hash for dealing with dependencies
4
+ #
5
+ # Example:
6
+ # dependency_hash = THash[
7
+ # dns: %i(net),
8
+ # webserver: %i(dns logger),
9
+ # net: [],
10
+ # logger: []
11
+ # ]
12
+ # # Sort to resolve dependencies of the entire hash
13
+ # dependency_hash.tsort # => [:net, :dns, :logger, :webserver]
14
+ # # Sort to resolve dependencies of one node only
15
+ # dependency_hash.tsort(:dns) # => [:net, :dns]
16
+ class THash < Hash
17
+ include TSort
18
+
19
+ alias_method :tsort_each_node, :each_key
20
+
21
+ def tsort_each_child(node, &block)
22
+ fetch(node).each(&block)
23
+ end
24
+
25
+ def tsort(node=nil)
26
+ if node
27
+ subhash = subhash_for node
28
+ super().select { |n| subhash.include? n }
29
+ else
30
+ super()
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def subhash_for(node, memo=[])
37
+ memo.tap do |m|
38
+ fail TSort::Cyclic if m.include? node
39
+ m << node
40
+ fetch(node).each { |n| subhash_for(n, m) }
41
+ end
42
+ end
43
+
44
+ end
45
+
46
+ end
@@ -1,3 +1,3 @@
1
1
  module AIPP
2
- VERSION = "0.1.3".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
@@ -8,7 +8,7 @@ describe AIPP::AIRAC do
8
8
  end
9
9
  end
10
10
 
11
- context "on AIRAC date" do
11
+ context "on AIRAC date (as Date)" do
12
12
  subject do
13
13
  AIPP::AIRAC.new(Date.parse('2018-01-04'))
14
14
  end
@@ -30,9 +30,9 @@ describe AIPP::AIRAC do
30
30
  end
31
31
  end
32
32
 
33
- context "one day before AIRAC date" do
33
+ context "one day before AIRAC date (as String)" do
34
34
  subject do
35
- AIPP::AIRAC.new(Date.parse('2018-01-03'))
35
+ AIPP::AIRAC.new('2018-01-03')
36
36
  end
37
37
 
38
38
  it "must calculate correct #date" do
@@ -4,49 +4,119 @@ using AIPP::Refinements
4
4
 
5
5
  describe AIPP::Refinements do
6
6
 
7
- describe 'String#blank_to_nil' do
8
- it "must convert blank to nil" do
9
- "\n \n ".blank_to_nil.must_be :nil?
10
- end
7
+ context String do
8
+ describe :blank_to_nil do
9
+ it "must convert blank to nil" do
10
+ "\n \n ".blank_to_nil.must_be :nil?
11
+ end
12
+
13
+ it "must leave non-blank untouched" do
14
+ "foobar".blank_to_nil.must_equal "foobar"
15
+ end
11
16
 
12
- it "must leave non-blank untouched" do
13
- "foobar".blank_to_nil.must_equal "foobar"
17
+ it "must leave non-blank with whitespace untouched" do
18
+ "\nfoo bar\n".blank_to_nil.must_equal "\nfoo bar\n"
19
+ end
14
20
  end
15
21
 
16
- it "must leave non-blank with whitespace untouched" do
17
- "\nfoo bar\n".blank_to_nil.must_equal "\nfoo bar\n"
22
+ describe :blank? do
23
+ it "all whitespace must return true" do
24
+ "\n \n ".blank?.must_equal true
25
+ end
26
+
27
+ it "not all whitespace must return false" do
28
+ "\nfoo bar\n".blank?.must_equal false
29
+ end
18
30
  end
19
- end
20
31
 
21
- describe 'NilClass#blank_to_nil' do
22
- it "must return self" do
23
- nil.blank_to_nil.must_be :nil?
32
+ describe :classify do
33
+ it "must convert file name to class name" do
34
+ "ENR-5.1".classify.must_equal "ENR51"
35
+ "helper".classify.must_equal "Helper"
36
+ "foo_bar".classify.must_equal "FooBar"
37
+ end
24
38
  end
25
39
  end
26
40
 
27
- describe 'Array#split' do
28
- it "must split at pattern" do
29
- [1, 2, '---', 3, 4].split(/-+/).must_equal [[1, 2], [3, 4]]
41
+ context NilClass do
42
+ describe :blank_to_nil do
43
+ it "must return self" do
44
+ nil.blank_to_nil.must_be :nil?
45
+ end
30
46
  end
31
47
 
32
- it "won't split arrays with no pattern matches" do
33
- [1, 2, 3].split(/-+/).must_equal [[1, 2, 3]]
48
+ describe :blank? do
49
+ it "must return true" do
50
+ nil.blank?.must_equal true
51
+ end
34
52
  end
53
+ end
35
54
 
36
- it "must keep leading empty subarrays" do
37
- ['---', 1, 2, '---', 3, 4].split(/-+/).must_equal [[], [1, 2], [3, 4]]
38
- end
55
+ context Array do
56
+ describe :constantize do
57
+ it "must convert to constant" do
58
+ %w(AIPP Refinements).constantize.must_equal AIPP::Refinements
59
+ end
39
60
 
40
- it "must keep empty subarrays in the middle" do
41
- [1, 2, '---', '---', 3, 4].split(/-+/).must_equal [[1, 2], [], [3, 4]]
61
+ it "fails to convert to inexistant constant" do
62
+ -> { %w(Foo Bar).constantize }.must_raise NameError
63
+ end
42
64
  end
65
+ end
43
66
 
44
- it "must drop trailing empty subarrays" do
45
- [1, 2, '---', 3, 4, '---'].split(/-+/).must_equal [[1, 2], [3, 4]]
46
- end
67
+ context Enumerable do
68
+ describe :split do
69
+ context "by object" do
70
+ it "must split at matching element" do
71
+ [1, 2, 0, 3, 4].split(0).must_equal [[1, 2], [3, 4]]
72
+ end
73
+
74
+ it "won't split when no element matches" do
75
+ [1, 2, 3].split(0).must_equal [[1, 2, 3]]
76
+ end
77
+
78
+ it "won't split zero length enumerable" do
79
+ [].split(0).must_equal []
80
+ end
81
+
82
+ it "must keep leading empty subarrays" do
83
+ [0, 1, 2, 0, 3, 4].split(0).must_equal [[], [1, 2], [3, 4]]
84
+ end
85
+
86
+ it "must keep empty subarrays in the middle" do
87
+ [1, 2, 0, 0, 3, 4].split(0).must_equal [[1, 2], [], [3, 4]]
88
+ end
89
+
90
+ it "must drop trailing empty subarrays" do
91
+ [1, 2, 0, 3, 4, 0].split(0).must_equal [[1, 2], [3, 4]]
92
+ end
93
+ end
94
+
95
+ context "by block" do
96
+ it "must split at matching element" do
97
+ [1, 2, 0, 3, 4].split { |e| e.zero? }.must_equal [[1, 2], [3, 4]]
98
+ end
99
+
100
+ it "won't split when no element matches" do
101
+ [1, 2, 3].split { |e| e.zero? }.must_equal [[1, 2, 3]]
102
+ end
103
+
104
+ it "won't split zero length enumerable" do
105
+ [].split { |e| e.zero? }.must_equal []
106
+ end
107
+
108
+ it "must keep leading empty subarrays" do
109
+ [0, 1, 2, 0, 3, 4].split { |e| e.zero? }.must_equal [[], [1, 2], [3, 4]]
110
+ end
111
+
112
+ it "must keep empty subarrays in the middle" do
113
+ [1, 2, 0, 0, 3, 4].split { |e| e.zero? }.must_equal [[1, 2], [], [3, 4]]
114
+ end
47
115
 
48
- it "won't alter empty arrays" do
49
- [].split(/-+/).must_equal []
116
+ it "must drop trailing empty subarrays" do
117
+ [1, 2, 0, 3, 4, 0].split { |e| e.zero? }.must_equal [[1, 2], [3, 4]]
118
+ end
119
+ end
50
120
  end
51
121
  end
52
122