skymap 0.4.0 → 0.5.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +570 -31
  4. data/data/LICENSE-deep_sky_objects.txt +30 -0
  5. data/data/LICENSE-stars.txt +35 -0
  6. data/data/deep_sky_objects.csv +1742 -0
  7. data/data/stars.bin +0 -0
  8. data/lib/skymap/canvas.rb +12 -3
  9. data/lib/skymap/catalog/deep_sky_objects.rb +60 -0
  10. data/lib/skymap/catalog/star_records.rb +322 -0
  11. data/lib/skymap/catalog/stars.rb +75 -12
  12. data/lib/skymap/chart.rb +18 -4
  13. data/lib/skymap/cone.rb +21 -0
  14. data/lib/skymap/context.rb +326 -8
  15. data/lib/skymap/deep_sky_object.rb +140 -0
  16. data/lib/skymap/eyepiece.rb +22 -0
  17. data/lib/skymap/field.rb +20 -0
  18. data/lib/skymap/geometry.rb +47 -0
  19. data/lib/skymap/horizon.rb +33 -3
  20. data/lib/skymap/label_placement.rb +263 -0
  21. data/lib/skymap/layers/cardinal_directions.rb +2 -0
  22. data/lib/skymap/layers/constellation_lines.rb +6 -1
  23. data/lib/skymap/layers/constellation_names.rb +5 -2
  24. data/lib/skymap/layers/deep_sky_objects.rb +128 -0
  25. data/lib/skymap/layers/ecliptic.rb +1 -7
  26. data/lib/skymap/layers/field_circles.rb +169 -0
  27. data/lib/skymap/layers/grid.rb +153 -0
  28. data/lib/skymap/layers/moon.rb +22 -12
  29. data/lib/skymap/layers/planets.rb +4 -0
  30. data/lib/skymap/layers/scale.rb +75 -0
  31. data/lib/skymap/layers/star_names.rb +21 -4
  32. data/lib/skymap/layers/stars.rb +31 -8
  33. data/lib/skymap/layers/sun.rb +13 -9
  34. data/lib/skymap/magnitude_limit.rb +15 -0
  35. data/lib/skymap/print_size.rb +50 -0
  36. data/lib/skymap/projection/oblique_stereographic.rb +87 -0
  37. data/lib/skymap/projection/stereographic.rb +11 -0
  38. data/lib/skymap/renderer/caption.rb +35 -3
  39. data/lib/skymap/renderer/deep_sky_symbol.rb +67 -0
  40. data/lib/skymap/renderer/field_circle.rb +7 -0
  41. data/lib/skymap/renderer/grid.rb +7 -0
  42. data/lib/skymap/renderer/horizon_line.rb +7 -0
  43. data/lib/skymap/renderer/label.rb +3 -1
  44. data/lib/skymap/renderer/scale_bar.rb +7 -0
  45. data/lib/skymap/renderer/svg.rb +204 -15
  46. data/lib/skymap/setup.rb +48 -0
  47. data/lib/skymap/star.rb +127 -2
  48. data/lib/skymap/star_list.rb +25 -0
  49. data/lib/skymap/telescope.rb +17 -0
  50. data/lib/skymap/theme.rb +54 -20
  51. data/lib/skymap/version.rb +1 -1
  52. data/lib/skymap/view.rb +49 -0
  53. data/lib/skymap.rb +24 -0
  54. metadata +31 -2
  55. data/data/stars.csv +0 -9097
data/data/stars.bin ADDED
Binary file
data/lib/skymap/canvas.rb CHANGED
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skymap
4
- Canvas = Data.define(:size, :padding) do
5
- def initialize(size:, padding:)
6
- unless padding >= 0 && padding < size / 2.0
4
+ Canvas = Data.define(:size, :padding, :print_size) do
5
+ def initialize(size:, padding:, print_size: nil)
6
+ unless size.is_a?(Numeric) && size.real? && size.finite? && size > 0
7
+ raise ArgumentError, "size must be a number above 0"
8
+ end
9
+
10
+ unless padding.is_a?(Numeric) && padding.real? && padding >= 0 &&
11
+ padding < size / 2.0
7
12
  raise ArgumentError,
8
13
  "padding must not be negative and must be less than half the size"
9
14
  end
10
15
 
16
+ unless print_size.nil? || print_size.is_a?(PrintSize)
17
+ raise ArgumentError, "print_size must be a Skymap::PrintSize"
18
+ end
19
+
11
20
  super
12
21
  end
13
22
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Skymap
6
+ module Catalog
7
+ class DeepSkyObjects
8
+ include Enumerable
9
+
10
+ PATH = File.expand_path("../../../data/deep_sky_objects.csv", __dir__)
11
+ MAGNITUDE_BANDS = {"V" => :visual, "B" => :blue}.freeze
12
+
13
+ def each
14
+ return enum_for(:each) unless block_given?
15
+
16
+ CSV.foreach(PATH, headers: true) do |row|
17
+ yield deep_sky_object_from(row)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def deep_sky_object_from(row)
24
+ DeepSkyObject.new(
25
+ designation: row["designation"],
26
+ name: row["name"],
27
+ type: row["type"].to_sym,
28
+ equatorial_coordinates: Astronoby::Coordinates::Equatorial.new(
29
+ right_ascension: Astronoby::Angle.from_hours(
30
+ row["right_ascension"].to_f
31
+ ),
32
+ declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
33
+ epoch: Astronoby::JulianDate::J2000
34
+ ),
35
+ magnitude: row["magnitude"]&.to_f,
36
+ magnitude_band: magnitude_band(row["magnitude_band"]),
37
+ major_axis: arcminutes(row["major_axis"]),
38
+ minor_axis: arcminutes(row["minor_axis"]),
39
+ position_angle: position_angle(row["position_angle"])
40
+ )
41
+ end
42
+
43
+ def magnitude_band(value)
44
+ MAGNITUDE_BANDS.fetch(value) if value
45
+ end
46
+
47
+ def arcminutes(value)
48
+ return unless value
49
+
50
+ Astronoby::Angle.from_degrees(value.to_f / 60.0)
51
+ end
52
+
53
+ def position_angle(value)
54
+ return unless value
55
+
56
+ Astronoby::Angle.from_degrees(value.to_f)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../geometry"
4
+
5
+ module Skymap
6
+ module Catalog
7
+ class StarRecords
8
+ MAGIC = "SKYS"
9
+ VERSION = 1
10
+ FORMAT = "L<l<s<s<s<S<s<S<"
11
+ RECORD_SIZE = 20
12
+ BRIGHTEST_BAND = 4
13
+ FAINTEST_BAND = 9
14
+ BANDS = (BRIGHTEST_BAND..FAINTEST_BAND)
15
+ ZONE_HEIGHT = 2
16
+ ZONES = 180 / ZONE_HEIGHT
17
+ OFFSETS_COUNT = BANDS.size * ZONES + 1
18
+ UNIT_SIZE = 4
19
+ SETTINGS = [
20
+ VERSION, BRIGHTEST_BAND, FAINTEST_BAND, ZONE_HEIGHT, RECORD_SIZE
21
+ ].freeze
22
+ HEADER_SIZE = MAGIC.bytesize + (SETTINGS.size + 2) * UNIT_SIZE
23
+ NO_RECORD = 2**32 - 1
24
+ RIGHT_ASCENSION_STEPS = 2**32
25
+ DECLINATION_STEPS = 2**31
26
+ MISSING = -32768
27
+ PARALLAX_STEPS_PER_MAS = 10
28
+ STORED_RANGES = {
29
+ magnitude: -32768..32767,
30
+ proper_motion: -32767..32767,
31
+ parallax: 0..65535,
32
+ radial_velocity: -32767..32767,
33
+ hr: 0..65535
34
+ }.freeze
35
+ Record = Data.define(
36
+ :right_ascension,
37
+ :declination,
38
+ :magnitude,
39
+ :proper_motion_ra,
40
+ :proper_motion_dec,
41
+ :parallax,
42
+ :radial_velocity,
43
+ :hr
44
+ )
45
+
46
+ def self.band(magnitude)
47
+ hundredths = (magnitude * 100).round
48
+
49
+ (-(-hundredths).div(100)).clamp(BRIGHTEST_BAND, FAINTEST_BAND)
50
+ end
51
+
52
+ def self.bands_within(magnitude_limit)
53
+ BANDS.select do |band|
54
+ magnitude_limit.nil? || band == BRIGHTEST_BAND ||
55
+ band - 1 < magnitude_limit
56
+ end
57
+ end
58
+
59
+ def self.zone(declination)
60
+ ((declination + 90) / ZONE_HEIGHT).floor.clamp(0, ZONES - 1)
61
+ end
62
+
63
+ def self.encode(stars)
64
+ ordered = stars.sort_by do |star|
65
+ [
66
+ band(star.magnitude), zone(star.declination), star.right_ascension,
67
+ star.declination, star.magnitude, star.hr || 0
68
+ ]
69
+ end
70
+ counts = Hash.new(0)
71
+ ordered.each do |star|
72
+ counts[[band(star.magnitude), zone(star.declination)]] += 1
73
+ end
74
+
75
+ offsets = []
76
+ total = 0
77
+ BANDS.each do |band|
78
+ ZONES.times do |zone|
79
+ offsets << total
80
+ total += counts[[band, zone]]
81
+ end
82
+ end
83
+ offsets << total
84
+
85
+ by_hr = []
86
+ ordered.each_with_index { |star, index|
87
+ by_hr[star.hr] = index if star.hr
88
+ }
89
+ by_hr = by_hr.map { |index| index || NO_RECORD }
90
+
91
+ header = [MAGIC, *SETTINGS, ordered.size, by_hr.size]
92
+
93
+ [*header, *offsets, *by_hr].pack("a4L<*") +
94
+ ordered.map { |star| pack(star) }.join
95
+ end
96
+
97
+ def self.pack(star)
98
+ [
99
+ (star.right_ascension / 360.0 * RIGHT_ASCENSION_STEPS).round %
100
+ RIGHT_ASCENSION_STEPS,
101
+ [
102
+ (star.declination / 90.0 * DECLINATION_STEPS).round,
103
+ DECLINATION_STEPS - 1
104
+ ].min,
105
+ fit((star.magnitude * 100).round, :magnitude),
106
+ fit(star.proper_motion_ra, :proper_motion) || MISSING,
107
+ fit(star.proper_motion_dec, :proper_motion) || MISSING,
108
+ fit(
109
+ ((star.parallax || 0) * PARALLAX_STEPS_PER_MAS).round,
110
+ :parallax
111
+ ),
112
+ fit(star.radial_velocity, :radial_velocity) || MISSING,
113
+ fit(star.hr || 0, :hr)
114
+ ].pack(FORMAT)
115
+ end
116
+
117
+ def self.fit(value, field)
118
+ return value if value.nil? || STORED_RANGES.fetch(field).cover?(value)
119
+
120
+ raise ArgumentError,
121
+ "#{field} is out of the range of the file: #{value}"
122
+ end
123
+ private_class_method :pack, :fit
124
+
125
+ attr_reader :count
126
+
127
+ def initialize(data)
128
+ unless data.bytesize >= HEADER_SIZE &&
129
+ data.byteslice(0, MAGIC.bytesize) == MAGIC
130
+ raise ArgumentError, "data is not a star catalog"
131
+ end
132
+
133
+ @data = data
134
+ settings = data.unpack("L<#{SETTINGS.size}", offset: MAGIC.bytesize)
135
+ unless settings == SETTINGS
136
+ raise ArgumentError, "star catalog was built with other settings"
137
+ end
138
+
139
+ counts_at = MAGIC.bytesize + SETTINGS.size * UNIT_SIZE
140
+ @count, hr_count = data.unpack("L<2", offset: counts_at)
141
+ read_tables(hr_count)
142
+ unless @records_at + @count * RECORD_SIZE == data.bytesize
143
+ raise ArgumentError, "star catalog has the wrong size"
144
+ end
145
+ end
146
+
147
+ def each_with_hr
148
+ return enum_for(:each_with_hr) unless block_given?
149
+
150
+ @by_hr.each do |index|
151
+ next if index == NO_RECORD
152
+
153
+ yield decode(@data.unpack(FORMAT, offset: record_at(index)))
154
+ end
155
+ end
156
+
157
+ def each_in_band(band)
158
+ return enum_for(:each_in_band, band) unless block_given?
159
+
160
+ ZONES.times do |zone|
161
+ each_in_cell(band, zone, nil) { |record| yield record }
162
+ end
163
+ end
164
+
165
+ def each(magnitude_limit: nil, center: nil, radius: nil)
166
+ unless block_given?
167
+ return enum_for(:each, magnitude_limit:, center:,
168
+ radius:)
169
+ end
170
+
171
+ search = center && Search.new(center, radius)
172
+ self.class.bands_within(magnitude_limit).each do |band|
173
+ zones(search).each do |zone|
174
+ each_in_cell(band, zone, search) do |record|
175
+ next if magnitude_limit && record.magnitude > magnitude_limit
176
+
177
+ yield record if search.nil? || search.include?(record)
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def read_tables(hr_count)
186
+ @offsets = @data.unpack("L<#{OFFSETS_COUNT}", offset: HEADER_SIZE)
187
+ by_hr_at = HEADER_SIZE + OFFSETS_COUNT * UNIT_SIZE
188
+ @by_hr = @data.unpack("L<#{hr_count}", offset: by_hr_at)
189
+ @records_at = by_hr_at + hr_count * UNIT_SIZE
190
+ end
191
+
192
+ def zones(search)
193
+ return 0...ZONES unless search
194
+
195
+ search.zones
196
+ end
197
+
198
+ def each_in_cell(band, zone, search)
199
+ first, last = cell(band, zone)
200
+ windows = search ? search.windows : [[0, RIGHT_ASCENSION_STEPS - 1]]
201
+
202
+ windows.each do |from, to|
203
+ start = lower_bound(first, last, from)
204
+ index = start
205
+ while index < last
206
+ raw = @data.unpack(FORMAT, offset: record_at(index))
207
+ break if raw[0] > to
208
+
209
+ yield decode(raw)
210
+ index += 1
211
+ end
212
+ end
213
+ end
214
+
215
+ def cell(band, zone)
216
+ row = (band - BRIGHTEST_BAND) * ZONES + zone
217
+
218
+ [@offsets[row], @offsets[row + 1]]
219
+ end
220
+
221
+ def lower_bound(first, last, steps)
222
+ low = first
223
+ high = last
224
+ while low < high
225
+ middle = (low + high) / 2
226
+ right_ascension = @data.unpack1("L<", offset: record_at(middle))
227
+ if right_ascension < steps
228
+ low = middle + 1
229
+ else
230
+ high = middle
231
+ end
232
+ end
233
+ low
234
+ end
235
+
236
+ def record_at(index)
237
+ @records_at + index * RECORD_SIZE
238
+ end
239
+
240
+ def decode(raw)
241
+ right_ascension, declination, magnitude, pm_ra, pm_dec, parallax,
242
+ radial_velocity, hr = raw
243
+
244
+ Record.new(
245
+ right_ascension: right_ascension * 360.0 / RIGHT_ASCENSION_STEPS,
246
+ declination: declination * 90.0 / DECLINATION_STEPS,
247
+ magnitude: magnitude / 100.0,
248
+ proper_motion_ra: (pm_ra unless pm_ra == MISSING),
249
+ proper_motion_dec: (pm_dec unless pm_dec == MISSING),
250
+ parallax: (
251
+ parallax.fdiv(PARALLAX_STEPS_PER_MAS) unless parallax.zero?
252
+ ),
253
+ radial_velocity: (radial_velocity unless radial_velocity == MISSING),
254
+ hr: (hr unless hr.zero?)
255
+ )
256
+ end
257
+
258
+ class Search
259
+ attr_reader :center, :radius
260
+
261
+ def initialize(center, radius)
262
+ @center = center
263
+ @radius = radius
264
+ @cosine = Math.cos(radius * Math::PI / 180)
265
+ @center_vector = vector(center.first, center.last)
266
+ end
267
+
268
+ def zones
269
+ lowest = [@center.last - @radius, -90.0].max
270
+ highest = [@center.last + @radius, 90.0].min
271
+
272
+ StarRecords.zone(lowest)..StarRecords.zone(highest)
273
+ end
274
+
275
+ def windows
276
+ width = half_width
277
+ return [[0, RIGHT_ASCENSION_STEPS - 1]] if width >= 180
278
+
279
+ from = @center.first - width
280
+ to = @center.first + width
281
+ if from.negative?
282
+ [[0, steps(to)], [steps(from + 360), RIGHT_ASCENSION_STEPS - 1]]
283
+ elsif to >= 360
284
+ [[0, steps(to - 360)], [steps(from), RIGHT_ASCENSION_STEPS - 1]]
285
+ else
286
+ [[steps(from), steps(to)]]
287
+ end
288
+ end
289
+
290
+ def include?(record)
291
+ point = vector(record.right_ascension, record.declination)
292
+
293
+ Geometry.dot(@center_vector, point) >= @cosine
294
+ end
295
+
296
+ private
297
+
298
+ def half_width
299
+ return 180.0 if @center.last.abs + @radius >= 90
300
+
301
+ Math.asin(
302
+ Math.sin(@radius * Math::PI / 180) /
303
+ Math.cos(@center.last * Math::PI / 180)
304
+ ) * 180 / Math::PI
305
+ end
306
+
307
+ def steps(degrees)
308
+ (degrees / 360.0 * RIGHT_ASCENSION_STEPS).floor
309
+ .clamp(0, RIGHT_ASCENSION_STEPS - 1)
310
+ end
311
+
312
+ def vector(right_ascension, declination)
313
+ Geometry.direction(
314
+ right_ascension * Math::PI / 180,
315
+ declination * Math::PI / 180
316
+ )
317
+ end
318
+ end
319
+ private_constant :Search
320
+ end
321
+ end
322
+ end
@@ -1,37 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "csv"
4
-
5
3
  module Skymap
6
4
  module Catalog
7
5
  class Stars
8
6
  include Enumerable
9
7
 
10
- PATH = File.expand_path("../../../data/stars.csv", __dir__)
8
+ PATH = File.expand_path("../../../data/stars.bin", __dir__)
9
+
10
+ def self.records
11
+ @records ||= StarRecords.new(File.binread(PATH))
12
+ end
13
+ private_class_method :records
11
14
 
12
15
  def each
13
16
  return enum_for(:each) unless block_given?
14
17
 
15
- CSV.foreach(PATH, headers: true) do |row|
16
- yield star_from(row)
17
- end
18
+ records.each { |record| yield star_from(record) }
19
+ end
20
+
21
+ def search(magnitude_limit: nil, cone: nil)
22
+ return whole_sky(magnitude_limit) unless cone
23
+
24
+ records.each(
25
+ magnitude_limit: magnitude_limit,
26
+ center: [
27
+ cone.center.right_ascension.degrees,
28
+ cone.center.declination.degrees
29
+ ],
30
+ radius: cone.radius.degrees
31
+ ).map { |record| star_from(record) }
32
+ end
33
+
34
+ def by_hr
35
+ @by_hr ||= Star.by_hr(
36
+ records.each_with_hr.map { |record| star_from(record) }
37
+ )
18
38
  end
19
39
 
20
40
  private
21
41
 
22
- def star_from(row)
42
+ def records
43
+ self.class.send(:records)
44
+ end
45
+
46
+ def whole_sky(magnitude_limit)
47
+ stars = StarRecords.bands_within(magnitude_limit).flat_map do |band|
48
+ band_stars(band)
49
+ end
50
+ return stars unless magnitude_limit
51
+
52
+ stars.select { |star| star.magnitude <= magnitude_limit }
53
+ end
54
+
55
+ def band_stars(band)
56
+ @band_stars ||= {}
57
+ @band_stars[band] ||= records.each_in_band(band)
58
+ .map { |record| star_from(record) }.freeze
59
+ end
60
+
61
+ def star_from(record)
23
62
  Star.new(
24
- hr: row["hr"].to_i,
63
+ hr: record.hr,
25
64
  equatorial_coordinates: Astronoby::Coordinates::Equatorial.new(
26
- right_ascension: Astronoby::Angle.from_hours(
27
- row["right_ascension"].to_f
65
+ right_ascension: Astronoby::Angle.from_degrees(
66
+ record.right_ascension
28
67
  ),
29
- declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
68
+ declination: Astronoby::Angle.from_degrees(record.declination),
30
69
  epoch: Astronoby::JulianDate::J2000
31
70
  ),
32
- magnitude: row["magnitude"].to_f
71
+ magnitude: record.magnitude,
72
+ proper_motion_ra: proper_motion(record.proper_motion_ra),
73
+ proper_motion_dec: proper_motion(record.proper_motion_dec),
74
+ parallax: parallax(record.parallax),
75
+ radial_velocity: radial_velocity(record.radial_velocity)
76
+ )
77
+ end
78
+
79
+ def proper_motion(milliarcseconds_per_year)
80
+ return unless milliarcseconds_per_year
81
+
82
+ Astronoby::AngularVelocity.from_milliarcseconds_per_year(
83
+ milliarcseconds_per_year
33
84
  )
34
85
  end
86
+
87
+ def parallax(milliarcseconds)
88
+ return unless milliarcseconds
89
+
90
+ Astronoby::Angle.from_degree_arcseconds(milliarcseconds / 1000.0)
91
+ end
92
+
93
+ def radial_velocity(kilometers_per_second)
94
+ return unless kilometers_per_second
95
+
96
+ Astronoby::Velocity.from_kilometers_per_second(kilometers_per_second)
97
+ end
35
98
  end
36
99
  end
37
100
  end
data/lib/skymap/chart.rb CHANGED
@@ -2,22 +2,36 @@
2
2
 
3
3
  module Skymap
4
4
  class Chart
5
- def initialize(observer:, instant:, canvas:, theme: Theme.dark)
5
+ def initialize(observer:, instant:, canvas:, theme: Theme.dark, view: nil)
6
6
  @observer = observer
7
7
  @instant = instant
8
8
  @canvas = canvas
9
9
  @theme = theme
10
+ @view = view
10
11
  end
11
12
 
12
13
  def render(*layers)
13
14
  context = Context.new(
14
15
  observer: @observer,
15
16
  instant: @instant,
16
- canvas: @canvas
17
+ canvas: @canvas,
18
+ view: @view
17
19
  )
18
- elements = layers.flat_map { |layer| layer.elements(context) }
20
+ elements = horizon(context) +
21
+ layers.flat_map { |layer| layer.elements(context) }
19
22
 
20
- Renderer::SVG.new(canvas: @canvas, theme: @theme).render(elements)
23
+ Renderer::SVG.new(canvas: @canvas, theme: @theme, clipped: !@view.nil?)
24
+ .render(LabelPlacement.new(canvas: @canvas).arrange(elements))
25
+ end
26
+
27
+ private
28
+
29
+ def horizon(context)
30
+ return [] if context.full_sky?
31
+
32
+ Horizon.line(context).map do |points|
33
+ Renderer::HorizonLine.new(points: points)
34
+ end
21
35
  end
22
36
  end
23
37
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Cone = Data.define(:center, :radius) do
5
+ def initialize(center:, radius:)
6
+ unless center.is_a?(Astronoby::Coordinates::Equatorial) &&
7
+ center.epoch == Astronoby::JulianDate::J2000
8
+ raise ArgumentError,
9
+ "center must be Astronoby::Coordinates::Equatorial at J2000"
10
+ end
11
+
12
+ unless radius.is_a?(Astronoby::Angle) && radius.positive? &&
13
+ radius.radians <= Math::PI
14
+ raise ArgumentError,
15
+ "radius must be an Astronoby::Angle above 0 and up to 180 degrees"
16
+ end
17
+
18
+ super
19
+ end
20
+ end
21
+ end