astroscript 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/.rubocop.yml ADDED
@@ -0,0 +1,25 @@
1
+ # The behavior of RuboCop can be controlled via the .rubocop.yml
2
+ # configuration file. It makes it possible to enable/disable
3
+ # certain cops (checks) and to alter their behavior if they accept
4
+ # any parameters. The file can be placed either in your home
5
+ # directory or in some project directory.
6
+ #
7
+ # RuboCop will start looking for the configuration file in the directory
8
+ # where the inspected file is and continue its way up to the root directory.
9
+ #
10
+ # See https://docs.rubocop.org/rubocop/configuration
11
+
12
+ AllCops:
13
+ NewCops: enable
14
+
15
+ Layout/SpaceBeforeBlockBraces:
16
+ EnforcedStyle: no_space
17
+
18
+ Layout/SpaceInsideBlockBraces:
19
+ SpaceBeforeBlockParameters: false
20
+
21
+ Style/StringLiterals:
22
+ EnforcedStyle: double_quotes
23
+
24
+ Style/CommentedKeyword:
25
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.4
data/ARCH.md ADDED
@@ -0,0 +1,13 @@
1
+ # Architecture notes:
2
+
3
+ Chart
4
+ - date time place
5
+ - has array of bodies
6
+ - has array of aspects
7
+ - has array of formations
8
+
9
+ BiChart
10
+ - @natal Chart
11
+ - @transit Chart
12
+ - .bodies
13
+ - array of all bodies in both charts
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-11-13
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 David Löwenfels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ t.warning = false
10
+ end
11
+
12
+ task default: :test
data/exe/astroscript ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "astroscript"
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "geocoder"
5
+ require "tzinfo"
6
+ require "json"
7
+
8
+ module AstroHelper
9
+ module_function
10
+
11
+ PLANETS = %i[SO MO ME VE MA JU SA UR NE PL].freeze
12
+ EXTRAS = %i[AC MC NN CH VX].freeze
13
+ URANIAN = %i[CU HA ZE KR AN AD VU PO].freeze
14
+ ASTEROIDS = %i[CE PA JU VA].freeze
15
+ LOTS = %i[POF POS POH].freeze
16
+
17
+ LUNAR_PHASES = %i[new crescent first_quarter gibbous full disseminating last_quarter balsamic].freeze
18
+ PHASE_GLYPHS = %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘].freeze # northern hemisphere
19
+
20
+ def print_symbol(x)
21
+ mp = x.to_s.split("/")
22
+ if mp.size > 1
23
+ mp.each{|p| print_symbol(p) }.join("/")
24
+ else
25
+ SwissEphemeris::BODIES[x.to_sym][:symbol]
26
+ end
27
+ end
28
+
29
+ def symbolize(s)
30
+ s.to_s.delete(" ").sub(".", "_").downcase.to_sym
31
+ end
32
+
33
+ def titleize(str)
34
+ str = str.to_s
35
+ str.split("_").map(&:capitalize).join(" ").gsub("Of", "of")
36
+ end
37
+
38
+ def init_calc(calc, *args)
39
+ case args.size
40
+ when 1
41
+ ary = args.first.dup
42
+ tz = ary.shift
43
+ tz = tz["name"] if tz.respond_to?(:[])
44
+ tz = tz.name if tz.respond_to?(:name)
45
+ calc.tz = tz
46
+ time = ary.shift
47
+ if time.respond_to?(:jd)
48
+ time = time.jd
49
+ else
50
+ time = datetime_to_jd(time) unless time.is_a?(Numeric)
51
+ end
52
+ calc.jd = time
53
+ calc.set_topo(*ary[0...3])
54
+ calc.datetime = DateTime.now if calc.jd.zero?
55
+ calc
56
+ else
57
+ year, month, day, hour, minute, location, name = *args
58
+ second = ((minute - minute.floor) * 60).round
59
+ minute = minute.floor
60
+ results = Geocoder.search(location)
61
+ calc.set_topo(results.first.latitude, results.first.longitude)
62
+ tz = results.first.send(:properties)["timezone"]
63
+ calc.tz = tz["name"]
64
+ calc.datetime = TZInfo::Timezone.get(tz["name"]).local_time(year, month, day, hour, minute, second, 10/600r).utc
65
+ calc.to_a + [location, name]
66
+ end
67
+ end
68
+
69
+ def dms(degree, zodiac = true)
70
+ d = degree.abs.floor
71
+ m = (degree.abs - d) * 60.0
72
+ if m.round == 60
73
+ m = 0
74
+ d += 1
75
+ end
76
+ s = (m - m.floor) * 60.0
77
+ if s.round == 60
78
+ s = 0
79
+ m += 1
80
+ end
81
+ d %= 30 if zodiac
82
+ [d, m, s]
83
+ end
84
+
85
+ def dms_to_deg(d, m, s)
86
+ d + (m * 60) + (s * 3600)
87
+ end
88
+
89
+ def print_dms(deg, opts = {})
90
+ if opts[:flavor] == :astrolog
91
+ opts[:zodiac] = true
92
+ opts[:glyph] = false
93
+ opts[:minutes] = ""
94
+ end
95
+ opts[:glyph] = true if opts[:glyph].nil?
96
+ d = deg.to_f
97
+ neg = d.negative?
98
+ d, m, s = dms(d, false)
99
+ if opts[:zodiac]
100
+ sign, d = d.divmod(30)
101
+ opts[:separator] = opts[:glyph] ? SwissEphemeris::ZODIAC_SYM[sign] : SwissEphemeris::SIGNS[sign][0...3].capitalize
102
+ end
103
+ opts[:separator] ||= "°"
104
+ if opts[:latitude] || opts[:longitude]
105
+ opts[:seconds] ||= false
106
+ opts[:separator] = neg ? "S" : "N" if opts[:latitude]
107
+ opts[:separator] = neg ? "E" : "W" if opts[:longitude]
108
+ opts[:separator] = "º#{opts[:separator]}" if opts[:degree]
109
+ neg = false
110
+ d = d.abs
111
+ output = if opts[:seconds] == false
112
+ format("%02d%s%02d'", d, opts[:separator], m.round)
113
+ else
114
+ format("%02d%s%02d'%02d\"", d, opts[:separator], m.floor, s.round)
115
+ end
116
+ elsif opts[:full]
117
+ output = format("%2d#{opts[:separator]}%2d'%7.4f", d, m.floor, s)
118
+ elsif opts[:seconds] || (d.zero? && m.floor.zero? && !s.round.zero?)
119
+ output = format("%02d#{opts[:separator]}%02d'%02d\"", d, m.floor, s.round)
120
+ else # default to rounded minutes
121
+ opts[:minutes] ||= "'"
122
+ output = format("%02d#{opts[:separator]}%02d#{opts[:minutes]}", d, m.round)
123
+ end
124
+ if neg
125
+ output = if output[0] == " "
126
+ "-#{output[1..]}"
127
+ else
128
+ "-#{output}"
129
+ end
130
+ elsif opts[:plus]
131
+ output = if output[0] == " "
132
+ "+#{output[1..]}"
133
+ else
134
+ "+#{output}"
135
+ end
136
+ end
137
+ output
138
+ end
139
+
140
+ def sign_to_str(sign, kind = :full)
141
+ output = sign.capitalize
142
+ case kind
143
+ when :glyph
144
+ SwissEphemeris::ZODIAC_SYM[sign_to_i(sign)]
145
+ when :short
146
+ output[0...3]
147
+ else
148
+ output
149
+ end
150
+ end
151
+
152
+ def deg_to_sign(deg)
153
+ SwissEphemeris::SIGNS[((deg % 360) / 30).floor]
154
+ end
155
+
156
+ def deg_to_gate(deg)
157
+ gate, line, * = HumanDesign.deg_to_gate(deg)
158
+ format("%2d.%1d%1s", gate, line, print_hexagram(gate))
159
+ end
160
+
161
+ def deg_to_s(angle, opts = {})
162
+ opts = opts.dup
163
+ opts = { padding: 12, tabs: true, angle: true, glyph: false, prefix: false, seconds: false, gate: false,
164
+ base: false, house: false, spectrum: false }.merge!(opts)
165
+ degree, minute, seconds = dms(angle, true)
166
+ sign = deg_to_sign(angle)
167
+ output = ""
168
+ if opts[:angle]
169
+ if opts[:glyph]
170
+ # output += sprintf("%s %2dº%2d'", sign_to_str(sign, :glyph), degree, minute.round)
171
+ output += format("%02d%s%02d", degree, sign_to_str(sign, :glyph), minute.round)
172
+ else
173
+ output += format("%2dº %s ", degree, sign_to_str(sign, :short))
174
+ output += opts[:seconds] ? format("%2d'%2d", minute.floor, seconds.round) : format("%2d'", minute.round)
175
+ end
176
+ end
177
+ output = format("%#{opts[:padding]}s %s", opts[:prefix], output) if opts[:prefix]
178
+ output += opts[:postfix].to_s if opts[:postfix]
179
+
180
+ if (val = opts[:gate] || opts[:spectrum])
181
+ if val.is_a?(Numeric) # adjust for ayanamsha
182
+ angle = (angle + val) % 360
183
+ # $logger.debug("adjusting ayanamsha by #{val}")
184
+ end
185
+ gate, line, color, tone, base = HumanDesign.deg_to_gate(angle)
186
+ if opts[:gate]
187
+ output += format(" %1s %2d.%1d", print_hexagram(gate), gate, line)
188
+ output += ".#{color}.#{tone}.#{base}" if opts[:base]
189
+ end
190
+ output += " (#{GeneKeys.gate_spectrum(gate)})" if opts[:spectrum]
191
+ end
192
+ output = output.gsub("\t", " ") unless opts[:tabs]
193
+ output.strip if opts[:strip]
194
+ output
195
+ end
196
+
197
+ def get_timezone_from_latlon(latitude, longitude)
198
+ response = HTTParty.get("https://api.geoapify.com/v1/geocode/reverse?lat=#{latitude}&lon=#{longitude}&format=json&apiKey=#{API_KEY}")
199
+ return JSON.parse(response.body)["results"].first["timezone"] if response.success?
200
+
201
+ puts "Request failed: #{response.code}"
202
+ end
203
+
204
+ def calculate_aspects(degree1, degree2, orb, max, min_orb = 0)
205
+ # Create an empty array to store the harmonics
206
+ harmonics = {}
207
+ delta = degree1 - degree2
208
+ # Iterate over the range of harmonics
209
+ (1..max).each do |harmonic|
210
+ # Calculate the difference based on the harmonic and default orb value
211
+ difference = delta * harmonic % 360
212
+ difference = 360 - difference if difference > 300
213
+ # Check if the difference is within the orb range
214
+ next unless difference <= orb && difference > min_orb
215
+
216
+ multiple = false
217
+ if harmonics.any?
218
+ first = harmonics.first[0]
219
+ multiple = if first > 1
220
+ harmonic.gcd(first) > 1
221
+ else
222
+ (difference - (harmonics.first[1] * harmonic)) < 0.001
223
+ end
224
+ end
225
+ # If the difference is within the orb range, add the harmonic to the array
226
+ harmonics[harmonic] = difference unless multiple
227
+ end
228
+ harmonics.transform_values{|v| v.round(3) }
229
+ end
230
+
231
+ def calc_all_aspects(planets, opts = {})
232
+ return if planets.size < 2
233
+
234
+ p1 = planets.pop
235
+ # $logger.debug( "**** Aspects for #{p1.name}" )
236
+ # $logger.debug( " with #{planets.map(&:abbr)}" )
237
+ planets.reverse.each do |p2|
238
+ aspects = p1.%(p2, opts)
239
+ next unless aspects.any?
240
+
241
+ $logger.debug(aspects.map(&:to_s))
242
+ @aspects += aspects
243
+ # @aspects << [p1.to_sym, p2.to_sym, aspects.inject({}){|m,e| m.merge(e.to_a.last) } ]
244
+ end
245
+ calc_all_aspects(planets, opts)
246
+ end
247
+
248
+ def clear_aspects!
249
+ @aspects = nil
250
+ end
251
+
252
+ def aspects(planets, _opts = {})
253
+ opts = { clear_cache: true }.merge!(_opts)
254
+ if @aspects.nil? || opts[:clear_cache]
255
+ @aspects = []
256
+ # $logger.debug "Aspects: opts = #{opts.inspect}"
257
+ calc_all_aspects(planets.reverse, opts)
258
+ end
259
+ [%i[NN SN], %i[MC IC], %i[VX AV], %i[AC DC]].each do |anti|
260
+ @aspects.reject!{|a| (a.bodies.map(&:abbr) & anti) == anti }
261
+ end
262
+ # $logger.debug("DONE! (#{@aspects.count})")
263
+ @aspects
264
+ end
265
+
266
+ def print_hexagram(idx)
267
+ (0x4DC0 + idx - 1).to_s(16).to_i(16).chr("UTF-8")
268
+ end
269
+
270
+ def digital_root(num, max = 9)
271
+ num = num.to_s.chars.map(&:to_i).inject(:+) until num <= max
272
+ num
273
+ end
274
+
275
+ HOUSE_THEMES = %w[self possessions communication home creativity/pleasure health/service partnerships
276
+ sex/death/rebirth philosophy/travel career friendships dissolution/karma].unshift(nil)
277
+ def print_houses(calc, calc_method = nil)
278
+ output = calc.house_cusps(calc_method).map.with_index do |deg, i|
279
+ i += 1
280
+ prefix = format("%-31s", "House #{SwissEphemeris::HOUSES[i]} (#{HOUSE_THEMES[i]}):")
281
+ ruler = calc.get_body(degree_rulership(deg))
282
+ tone = ruler.house - i + 1
283
+ tone += 12 if tone.negative?
284
+ postfix = " [Lord #{ruler.symbol} in #{format('%3s:%-2s', SwissEphemeris::HOUSES[ruler.house], tone)}] "
285
+ AstroHelper.deg_to_s(deg, gate: calc.ayanamsha, prefix: prefix, spectrum: true, postfix: postfix)
286
+ end
287
+ puts output
288
+ end
289
+
290
+ def name_to_sym(name)
291
+ name.to_s.delete(" ").sub(".", "_").downcase.to_sym
292
+ end
293
+
294
+ SYMBOLS = { # http://en.wikipedia.org/wiki/Astrological_symbols
295
+ sun: ["\u2609", "SO"], # "☉"
296
+ earth: ["\u2295", "EA"], # "⊕"
297
+ # :earth => ["\u2641", 'EA'], #"♁"
298
+ moon: ["\u263d", "MO"], # "☾"
299
+ mercury: ["\u263f", "ME"], # "☿"
300
+ venus: ["\u2640", "VE"], # "♀"
301
+ mars: ["\u2642", "MA"], # "♂"
302
+ jupiter: ["\u2643", "JU"], # "♃"
303
+ saturn: ["\u2644", "SA"], # "♄"
304
+ uranus: ["\u2645", "UR"], # "♅"
305
+ neptune: ["\u2646", "NE"], # "♆"
306
+ pluto: ["\u2647", "PL"], # "♇"
307
+ n_node: ["\u260a", "NN"], # "☊"
308
+ s_node: ["\u260b", "SN"], # "☋"
309
+ chiron: ["\u26b7", "CH"],
310
+ ceres: %w[ʡ CE], # "ʡ"
311
+ pallas: ["\u26b4", "PA"],
312
+ juno: ["\u26b5", "JN"],
313
+ vesta: ["\u26b6", "VA"],
314
+ ascendant: %w[AC AC],
315
+ descendant: %w[DC DC],
316
+ midheaven: %w[MC MC],
317
+ nadir: %w[IC IC],
318
+ aries: ["♈", "AP"],
319
+ vertex: ["🜊", "VX"], # TODO: "We surmise that it is possible to introduce another 10 points, complementing the Vertex or Antivertex to a 12-fold division along the prime vertical" # https://web.archive.org/web/20150926042925/http://www.levante.org/svarogich/en/principia_en/part01.html
320
+ av: ["A🜊", "AV"],
321
+ retrograde: "\u211e", # "℞",
322
+ zodiac: ("\u2648".."\u2653").to_a # %w[♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ]
323
+ }.freeze
324
+
325
+ def zodiac_symbol(sign)
326
+ SYMBOLS[:zodiac][sign_to_i(sign)]
327
+ end
328
+
329
+ def symbol(key)
330
+ SYMBOLS[key].first
331
+ end
332
+
333
+ def abbr(key)
334
+ SYMBOLS[key].last.to_sym
335
+ end
336
+
337
+ def abbr_to_key(abbr)
338
+ SYMBOLS.find{|_k, v| v.last == abbr.to_s }.first
339
+ end
340
+
341
+ # this should work for a calc or a chart
342
+ def converse(calc, natal_calc)
343
+ diff = (natal_calc.jd - calc.jd).abs
344
+ output = calc.dup
345
+ output.jd = natal_calc.jd - diff
346
+ output.recalc!
347
+ output
348
+ end
349
+
350
+ def datetime_to_jd(time = DateTime.now)
351
+ time = time.utc.to_datetime if time.is_a?(ActiveSupport::TimeWithZone)
352
+ Swe4r.swe_julday(time.year, time.month, time.day, time.hour + (time.min / 60.0) + (0.1 / 3600.0))
353
+ end
354
+
355
+ def transits(location, t = DateTime.now, **opts)
356
+ calc = Astroscript::Calculator.new(opts)
357
+ date = [t.year, t.month, t.day, t.hour, t.min]
358
+ calc.init(*date, location)
359
+ end
360
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroscript
4
+ class Aspect
5
+ attr_reader :orb, :aspect, :bodies, :phase
6
+
7
+ extend Forwardable
8
+ def_delegators :@aspect, :numerator, :denominator
9
+ def_delegators :denominator, :hph
10
+
11
+ FLAVORS = {
12
+ 1r => "conjunct", 1/2r => "opposite", 1/4r => "square",
13
+ 1/8r => "octile", 3/8r => "trioctile",
14
+ 1/3r => "trine", 1/6r => "sextile", # 1/9r => 'novile',
15
+ 1/12r => "semisextile", 5/12r => "quincunx",
16
+ 1/5r => "quintile", 2/5r => "biquintile"
17
+ # 1/7r => 'septile', 2/7r => 'biseptile', 3/7r => 'triseptile'
18
+ # 1/10r => 'decile', 1/11r => 'undecile',
19
+ }.freeze
20
+
21
+ FLAVORS.each do |r, flavor|
22
+ define_method "#{flavor}?" do
23
+ aspect == r
24
+ end
25
+ end
26
+
27
+ def initialize(b1, b2, opts = {})
28
+ opts[:max_harmonic] ||= 12
29
+ opts[:harmonic_orb] ||= 16.0
30
+ if opts[:ratio] # isolate flavor, e.g. 5/12
31
+ opts[:harmonic] = opts[:ratio].denominator
32
+ opts[:numerator] = opts[:ratio].numerator
33
+ end
34
+ @bodies = [b1, b2]
35
+ sort!
36
+ @phase = @bodies.map(&:lon).diff.first.abs
37
+
38
+ ary = if (h = opts[:harmonic]) # isolate single flavor
39
+ [h]
40
+ else
41
+ 1..opts[:max_harmonic]
42
+ end
43
+
44
+ ary.each do |harmonic|
45
+ mod = 360
46
+ orb = (@phase * harmonic) % mod
47
+ orb = mod - orb if orb > mod / 2 # this is the "flip" for oppositions
48
+
49
+ # check for conjunction in nth harmonic "flavor"
50
+ next unless orb <= (opts[:orb] || opts[:harmonic_orb])
51
+
52
+ if harmonic == 1
53
+ @aspect = 1r # 1/1 = conjunction
54
+ else
55
+ num = (@phase.abs * harmonic / mod).round
56
+ num = harmonic - num if num > harmonic / 2 # flip e.g. 7/12 to 5/12
57
+ @aspect = Rational(num, harmonic)
58
+ end
59
+ @orb = orb / harmonic
60
+ isolated_harmonic = opts[:harmonic] && @aspect.denominator != opts[:harmonic]
61
+ isolated_numerator = opts[:numerator] && @aspect.numerator != opts[:numerator]
62
+ return self unless isolated_harmonic || isolated_numerator
63
+ end
64
+ @aspect = 0r
65
+ @orb = nil
66
+ end
67
+
68
+ def ==(other)
69
+ bodies.map(&:abbr).sort == other.bodies.map(&:abbr).sort && phase == other.phase
70
+ end
71
+ alias eql? ==
72
+
73
+ def overlap?
74
+ abbrs = @bodies.map{|b| b.abbr.to_s.split("/").flatten }
75
+ (abbrs.first & abbrs.last).any?
76
+ end
77
+
78
+ def sort!
79
+ @bodies.sort_by!{|x| x.is_a?(Midpoint) ? x.bodies.first.speed_order.to_i : x.speed_order.to_i }
80
+ b1 = @bodies.first
81
+ b2 = @bodies.last
82
+ if b1.is_a?(Midpoint)
83
+ if b2.is_a?(Midpoint)
84
+ if b1.bodies.first == b2.bodies.first && b1.bodies.last.speed_order.to_i < (b2.bodies.last.speed_order.to_i)
85
+ @bodies.reverse!
86
+ end
87
+ else
88
+ @bodies.reverse!
89
+ end
90
+ end
91
+ @bodies.reverse! if @bodies.last.prefix
92
+ self
93
+ end
94
+
95
+ def applying?
96
+ return @applying if @applying
97
+
98
+ @orb1 = @bodies.map(&:degree).inject(:-).abs
99
+ body = @bodies.first
100
+ if body.is_a?(Midpoint)
101
+ body = @bodies.last
102
+ return nil if body.is_a?(Midpoint)
103
+ end
104
+ body.calc.jd += 1 # move forward
105
+ @bodies.each(&:calculate!)
106
+ @orb2 = @bodies.map(&:degree).inject(:-).abs
107
+ body.calc.jd -= 1 # put back
108
+ @bodies.each(&:calculate!)
109
+ return nil if @orb1 == @orb2
110
+
111
+ @applying = @orb1 > @orb2
112
+ end
113
+
114
+ def separating?
115
+ applying? # calculate stuff
116
+ return nil if @orb1 == @orb2
117
+
118
+ @orb1 < @orb2
119
+ end
120
+
121
+ def ratio
122
+ "#{numerator}/#{denominator}"
123
+ end
124
+
125
+ def valid?
126
+ !invalid?
127
+ end
128
+
129
+ def invalid?
130
+ numerator.zero? || !FLAVORS.keys.include?(aspect)
131
+ end
132
+
133
+ def to_a
134
+ @bodies.map(&:to_sym) << { harmonic => orb.round(3) }
135
+ end
136
+
137
+ def to_s(opts = {})
138
+ opts[:symbol] = true unless opts.key?(:symbol)
139
+ opts[:orb] = true unless opts.key?(:orb)
140
+ return "invalid: #{inspect}" unless valid?
141
+
142
+ flavor = FLAVORS[aspect]
143
+ flavor = SwissEphemeris::SYMBOLS[flavor.to_sym] if opts[:symbol]
144
+ output = "#{@bodies.first.handle} #{flavor} #{@bodies.last.handle}"
145
+ output += " [#{@orb.to_dms(separator: applying? ? 'a' : 's')}]" if opts[:orb]
146
+ output
147
+ end
148
+
149
+ def antiphase
150
+ -@phase % 360
151
+ end
152
+
153
+ def sum
154
+ [b1, b2].map{|b| b.send(@method) }.sum % 360
155
+ end
156
+
157
+ def direct_midpoint?
158
+ return unless midpoint?
159
+
160
+ @bodies.map(&:lon).diff.first.abs < 90
161
+ end
162
+
163
+ def midpoint?
164
+ @bodies.any?{|b| b.is_a?(Midpoint) }
165
+ end
166
+
167
+ def isotrap?
168
+ @bodies.all?{|b| b.is_a?(Midpoint) }
169
+ end
170
+
171
+ def opposite_midpoint?
172
+ !direct?
173
+ end
174
+
175
+ def midpoint_aspect
176
+ return unless midpoint?
177
+
178
+ direct_midpoint? ? SwissEphemeris::SYMBOLS[:conjunct] : SwissEphemeris::SYMBOLS[:opposite]
179
+ end
180
+
181
+ def print_midpoint
182
+ return unless midpoint?
183
+
184
+ movement = applying? ? "a" : "s"
185
+ "#{@bodies.map(&:symbol).join(" #{midpoint_aspect} ")}\t[#{AstroHelper.print_dms(orb)}#{movement}]"
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroscript
4
+ class ConstBody < Body
5
+ attr_accessor :symbol, :name, :lon
6
+ alias degree lon
7
+ def initialize(deg, name, symbol = nil, abbr = nil)
8
+ @lon = deg
9
+ @name = name
10
+ @harmonic = 1
11
+ @abbr = abbr || name[0..1].upcase.to_sym
12
+ @symbol = symbol || @abbr
13
+ @calc = NullCalc.new(@lon)
14
+ end
15
+
16
+ def to_sym
17
+ @abbr
18
+ end
19
+
20
+ def calculate!
21
+ nil
22
+ end
23
+
24
+ class NullCalc # Silently ignores any method call
25
+ def initialize(lon)
26
+ @lon = lon
27
+ end
28
+
29
+ def get_body **_args
30
+ @lon
31
+ end
32
+
33
+ def method_missing(method_name, *_args)
34
+ case method_name
35
+ when %i[to_f]
36
+ 0.0
37
+ when %i[to_i + - / * %]
38
+ 0
39
+ when :prefix
40
+ ""
41
+ else
42
+ self
43
+ end
44
+ end
45
+
46
+ def respond_to_missing?(_method_name, _include_private = false)
47
+ true
48
+ end
49
+
50
+ def coerce(_other)
51
+ [0, 0] # or [other, 0] depending on your needs
52
+ end
53
+ end
54
+ end
55
+ end