astroscript 0.3.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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroscript
4
+ class Calculator
5
+ attr_accessor :name, :latitude, :longitude, :altitude, :jd, :house_method, :tz, :arc, :prefix
6
+ attr_reader :flags, :asc, :mc, :vertex, :transformer, :equasc
7
+
8
+ def initialize *args
9
+ @arc = 0
10
+ params = args.last.is_a?(Hash) ? args.pop : {}
11
+ init_params(params)
12
+ # defaults
13
+ set_topo(51.476852, -0.000500) # Royal Observatory Greenwich, London, UK
14
+ self.datetime = DateTime.now.new_offset # Time.now.utc
15
+ end
16
+
17
+ def transformer=(method)
18
+ return unless method
19
+ raise ArgumentError, "must be a method or proc" unless method.respond_to?(:call)
20
+
21
+ @transformer = method
22
+ end
23
+
24
+ def options
25
+ opts = {}
26
+ opts[:house_method] = house_method
27
+ opts[:latitude] = AstroHelper.print_dms(latitude, lat: true)
28
+ opts[:longitude] = AstroHelper.print_dms(longitude, lon: true)
29
+ opts[:jd] = @jd
30
+ opts[:tz] = @tz.to_s
31
+ # date = @tz.to_local(DateTime.new(*Swe4r::swe_revjul(@jd))).to_s
32
+ date = DateTime.new(*Swe4r.swe_revjul(@jd)).to_s
33
+ opts[:date] = DateTime.parse(date).strftime("%Y-%m-%d %I:%M %p")
34
+ opts[:ayanamsha] = AstroHelper.print_dms ayanamsha, seconds: true
35
+ opts[:flags] = @flags
36
+ opts
37
+ end
38
+
39
+ def timezone
40
+ TZInfo::Timezone.get(@tz)
41
+ end
42
+
43
+ def utc
44
+ DateTime.new(*Swe4r.swe_revjul(@jd))
45
+ end
46
+
47
+ def datetime
48
+ @tz ? timezone.to_local(utc) : utc
49
+ end
50
+
51
+ def to_a
52
+ [@tz, @jd, @latitude, @longitude, @altitude]
53
+ end
54
+
55
+ def init *args
56
+ AstroHelper.init_calc(self, *args)
57
+ end
58
+
59
+ def update(params)
60
+ init_params(params)
61
+ end
62
+
63
+ def ayanamsha
64
+ @ayanamsha ? Swe4r.swe_get_ayanamsa_ex_ut(@jd, @flags) : 0
65
+ end
66
+
67
+ def svp
68
+ -ayanamsha
69
+ end
70
+
71
+ def true_node?
72
+ !!@true_node
73
+ end
74
+
75
+ def oe # obliquity of the ecliptic
76
+ @oe ||= Swe4r.swe_calc_ut(@jd, -1, 0).first
77
+ end
78
+
79
+ def datetime=(time)
80
+ @oe = nil
81
+ @ra = nil
82
+ @planetary_hours = nil
83
+ @jd = AstroHelper.datetime_to_jd(time)
84
+ end
85
+
86
+ def set_topo(latitude, longitude, altitude = nil)
87
+ @latitude = latitude
88
+ @longitude = longitude
89
+ @altitude = altitude.to_f
90
+ Swe4r.swe_set_topo(@longitude, @latitude, @altitude)
91
+ end
92
+
93
+ def calc(id, equatorial: false, heliocentric: false)
94
+ flags = self.flags
95
+ flags |= Swe4r::SEFLG_EQUATORIAL if equatorial
96
+ flags |= Swe4r::SEFLG_HELCTR if heliocentric
97
+
98
+ Swe4r.swe_calc_ut(jd, id, flags)
99
+ end
100
+
101
+ def get_body(abbr, jd = nil)
102
+ @jd = jd if jd
103
+ $logger.warn "use :NN with @true_node configuration instead of #{abbr}" if %i[MN TN].include?(abbr)
104
+ if abbr =~ /^C(\d+)$/
105
+ HouseCusp.new(::Regexp.last_match(1).to_i, self)
106
+ else
107
+ Body.new(abbr, self)
108
+ end
109
+ end
110
+
111
+ def [](abbr)
112
+ abbr = abbr.upcase if abbr.size == 2
113
+ get_body(abbr)
114
+ end
115
+
116
+ def ascendant
117
+ @asc || (get_houses && @asc)
118
+ end
119
+
120
+ def midheaven
121
+ @mc
122
+ end
123
+
124
+ def house_cusps(calc_method = nil)
125
+ case calc_method
126
+ when :asc
127
+ AstroHelper.harmonic_cusps(get_position(:asc).lon)
128
+ when :mc
129
+ AstroHelper.harmonic_cusps(get_position(:mc).lon, 10)
130
+ else
131
+ get_houses(calc_method) unless @house_cusps && calc_method.nil?
132
+ @house_cusps[1..12]
133
+ end
134
+ end
135
+
136
+ def get_houses(calc_method = nil)
137
+ if calc_method
138
+ @house_method = calc_method
139
+ else
140
+ @house_method ||= "P" # 'C'
141
+ end
142
+ raise "you must call set_topo with latitude and longitude before get_houses" unless @latitude && @longitude
143
+
144
+ flag = @ayanamsha ? Swe4r::SEFLG_SIDEREAL : 0
145
+ @house_cusps, ascmc, *, ascmc_speeds = Swe4r.swe_houses_ex2(jd, flag, @latitude, @longitude,
146
+ @house_method)
147
+ # @house_cusps.pop if @house_cusps.first.zero? # FIXME bug
148
+ @asc, @mc, @ramc, @vertex = *ascmc[0..3]
149
+ @equasc, = *ascmc[4..7]
150
+ # * ascmc[4] = equasc * "equatorial ascendant" *
151
+ # * ascmc[5] = coasc1 * "co-ascendant" (W. Koch) *
152
+ # * ascmc[6] = coasc2 * "co-ascendant" (M. Munkasey) *
153
+ # * ascmc[7] = polasc * "polar ascendant" (M. Munkasey) *
154
+ @asc_speed, @mc_speed, *, @vertex_speed = *ascmc_speeds[0..3]
155
+ true
156
+ end
157
+
158
+ def ramc
159
+ @ramc || (get_houses && @ramc)
160
+ end
161
+
162
+ def which_house(degree)
163
+ get_houses unless @house_cusps
164
+ min_cusp = house_cusps.min
165
+ degree += 360 if degree < min_cusp
166
+ # Iterate through the house cusps
167
+ house_cusps.each_with_index do |cusp, i|
168
+ # Check if the degree is within the current house
169
+ next_cusp = house_cusps[i + 1]
170
+ return 12 if next_cusp.nil?
171
+
172
+ next_cusp += 360 if next_cusp == min_cusp
173
+ # $logger.debug("#{degree.round(2)}.between? #{cusp},#{next_cusp}")
174
+ return i + 1 if degree.between?(cusp, next_cusp)
175
+ end
176
+ end
177
+
178
+ def dc
179
+ flip(asc)
180
+ end
181
+
182
+ def ic
183
+ flip(mc)
184
+ end
185
+
186
+ def av
187
+ flip(vertex)
188
+ end
189
+ alias avx av
190
+
191
+ def flip(d)
192
+ (d + 180) % 360
193
+ end
194
+
195
+ def before_sunrise?
196
+ ac = house_cusps[0] # sunrise
197
+ ic = house_cusps[3] # midnight
198
+ ic += 360 if ic < ac
199
+ get_body(:SO).to_f.between?(ac, ic) # before sunrise?
200
+ end
201
+
202
+ def solunar_phase
203
+ (get_body(:MO).to_f - get_body(:SO).to_f + 360) % 360
204
+ end
205
+
206
+ private
207
+
208
+ def init_params(params)
209
+ params = { ephemeris: :moshier, center: :geo, light_correction: true }.merge(params)
210
+ @house_method = params[:house_method] ||= "P" # Placidus as default
211
+ # default to true node
212
+ @true_node = true unless params[:true_node] == false || params[:mean_node] || params[:node] == :mean
213
+ # https://www.astro.com/swisseph/swephprg.htm
214
+ @flags = Swe4r::SEFLG_SPEED
215
+ if params[:astrometric]
216
+ @flags |= Swe4r::SEFLG_NOABERR | Swe4r::SEFLG_NOGDEFL
217
+ else
218
+ # no light time correction - return true positions, not apparent
219
+ @flags |= Swe4r::SEFLG_TRUEPOS unless params[:light_correction]
220
+ end
221
+ if (@ayanamsha = params[:sidereal])
222
+ @flags |= Swe4r::SEFLG_SIDEREAL if params[:sidereal]
223
+ if @ayanamsha.is_a? Array
224
+ Swe4r.swe_set_sid_mode(*@ayanamsha[0..2])
225
+ else
226
+ Swe4r.swe_set_sid_mode(@ayanamsha, 0, 0)
227
+ end
228
+ end
229
+ if params[:heliocentric]
230
+ @flags |= Swe4r::SEFLG_HELCTR
231
+ elsif params[:topocentric]
232
+ @flags |= Swe4r::SEFLG_TOPOCTR
233
+ elsif params[:center]
234
+ case params[:center]
235
+ when :helio
236
+ @flags |= Swe4r::SEFLG_HELCTR
237
+ when :topo
238
+ @flags |= Swe4r::SEFLG_TOPOCTR
239
+ when :geo
240
+ else
241
+ raise "bad option! center: #{params[:center]}"
242
+ end
243
+ end
244
+ case params[:ephemeris]
245
+ when :moshier
246
+ @flags |= Swe4r::SEFLG_MOSEPH
247
+ when :jpl
248
+ @flags |= Swe4r::SEFLG_JPLEPH
249
+ when :swiss
250
+ @flags |= Swe4r::SEFLG_SWIEPH
251
+ else
252
+ raise "bad option! ephemeris: #{params[:ephemeris]}"
253
+ end
254
+ @flags |= Swe4r::SEFLG_EQUATORIAL if params[:equatorial] # right ascension and declination instead of lat/lon
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroscript
4
+ class Chart
5
+ attr_reader :calc
6
+ attr_accessor :harmonic, :location
7
+
8
+ DEFAULT_BODIES = AstroHelper::PLANETS + AstroHelper::EXTRAS - [:VX]
9
+
10
+ # TODO: consider renaming opts to calc(_opts) ?
11
+ def initialize(birth_data, bodies: DEFAULT_BODIES, opts: {})
12
+ @opts = opts || {}
13
+ @opts[:true_node] ||= true
14
+ case birth_data
15
+ when Astroscript::Chart, Astroscript::Calculator
16
+ @calc = birth_data.dup
17
+ @calc.house_method = opts[:house_method]
18
+ @calc = @calc.calc if @calc.is_a?(Chart)
19
+ when Array
20
+ @calc = Astroscript::Calculator.new(opts).init(birth_data)
21
+ @location, name = birth_data[5..6]
22
+ when nil
23
+ raise ArgumentError, "no birth data provided!"
24
+ end
25
+ self.transformer = opts[:transformer]
26
+ @chart = {}
27
+ @harmonic = opts[:harmonic] || 1
28
+ @calc.arc = opts[:arc] if opts[:arc]
29
+ @calc.name = name || opts[:name] || ""
30
+ @calc.prefix = opts[:prefix]
31
+ get_bodies! bodies
32
+ end
33
+
34
+ def initialize_copy(orig)
35
+ super
36
+ # puts "duplicating @calc"
37
+ @calc = @calc.dup # attempt to prevent weirdness with transformers
38
+ self
39
+ end
40
+
41
+ # def dup
42
+ # Chart.new( @calc.dup, @chart.values.map(&:abbr), @opts)
43
+ # end
44
+
45
+ def transformer=(method)
46
+ @calc.transformer = method
47
+ end
48
+
49
+ def bodies
50
+ @chart.values
51
+ end
52
+
53
+ def recalc!(opts = {})
54
+ @calc.update(opts) unless opts.empty?
55
+ bodies.each(&:calculate!)
56
+ self
57
+ end
58
+
59
+ def asc=(deg)
60
+ @asc = deg.to_f
61
+ end
62
+
63
+ def asc
64
+ return @asc if @asc
65
+
66
+ if (a = find_body(:AC) || find_body(:C1))
67
+ a.degree
68
+ else
69
+ @calc.asc
70
+ end
71
+ end
72
+
73
+ def deg(body, method = :to_f)
74
+ body.send(method)
75
+ end
76
+
77
+ def degree(body, asc = nil) # helper for drawing chart
78
+ ((asc || self.asc) - 180 - deg(body)) % 360
79
+ end
80
+
81
+ def get_bodies!(planets)
82
+ planets.each do |abbr|
83
+ body = find_body!(abbr)
84
+ body.harmonize!(@harmonic) if body.is_a?(Astroscript::Body)
85
+ end
86
+ end
87
+
88
+ def find_body!(abbr)
89
+ case abbr
90
+ when *SwissEphemeris::BODIES.keys
91
+ key = AstroHelper.symbolize(SwissEphemeris::BODIES[abbr][:name])
92
+ self[key] = get_body(abbr)
93
+ else
94
+ raise ArgumentError, "unknown body: #{abbr}"
95
+ end
96
+ end
97
+
98
+ def phase_angle(b1, b2)
99
+ b1 = find_body(b1) if b1.is_a?(Symbol) && SwissEphemeris::BODIES.keys.include?(b1)
100
+ b2 = find_body(b2) if b2.is_a?(Symbol) && SwissEphemeris::BODIES.keys.include?(b2)
101
+ # (b1.lon - b2.lon) % 360
102
+ b1 - b2
103
+ end
104
+
105
+ def []=(key, body)
106
+ @chart[key] = body
107
+ # body.chart = self
108
+ end
109
+
110
+ def [](abbr)
111
+ abbr = abbr.upcase if abbr.size == 2
112
+ get_body(abbr)
113
+ end
114
+
115
+ def find_body(abbr)
116
+ if (result = @chart.find{|_k, v| v.abbr == abbr })
117
+ result[1]
118
+ else
119
+ # calc.get_body(abbr)
120
+ end
121
+ end
122
+
123
+ def get_body(abbr)
124
+ body = SwissEphemeris::BODIES[abbr]
125
+ if (method = body[:method])
126
+ method = body[:name] if method == true
127
+ output = MethodBody.new(send(method), method, chart: self)
128
+ else
129
+ output = find_body(abbr) || calc.get_body(abbr)
130
+ end
131
+ output.harmonize(@harmonic)
132
+ end
133
+
134
+ def find_bodies(ary)
135
+ ary.map{|a| find_body(a) }
136
+ end
137
+
138
+ def aries_point
139
+ # ConstBody.new( 0, 'AP' )
140
+ 0
141
+ end
142
+
143
+ def method_missing(method_name, ...)
144
+ if @calc.respond_to?(method_name)
145
+ @calc.send(method_name, ...)
146
+ elsif @chart.respond_to?(method_name)
147
+ @chart.send(method_name, ...)
148
+ else
149
+ raise "unknown method! :#{method_name}"
150
+ end
151
+ end
152
+
153
+ def night_birth?
154
+ sun = get_body(:SO).lon
155
+ desc = asc + 180
156
+ if desc > 360
157
+ sun.between?(desc % 360, asc)
158
+ else
159
+ sun.between?(asc, asc + 180)
160
+ end
161
+ end
162
+
163
+ def harmonic?
164
+ @harmonic > 1
165
+ end
166
+
167
+ def harmonize(h)
168
+ # output = Marshal.load( Marshal.dump(self) ) # deep copy
169
+ output = initialize_copy(self)
170
+ output.harmonize!(h)
171
+ end
172
+
173
+ def harmonize!(h)
174
+ @harmonic = h
175
+ bodies.each{|b| b.harmonize!(h) }
176
+ self
177
+ end
178
+
179
+ alias * harmonize
180
+
181
+ def print **opts # prefix: true, seconds: true, gate: true, house: true, spectrum: true
182
+ opts[:seconds] ||= true
183
+ puts @chart.map{|_k, v| v.to_s(opts) }.join("\n")
184
+ end
185
+
186
+ def to_a
187
+ calc.to_a << location
188
+ end
189
+
190
+ def to_json(*_args)
191
+ {
192
+ bodies: bodies.map(&:to_json)
193
+ }.to_json
194
+ end
195
+
196
+ def aspects(opts = {})
197
+ _bodies = bodies + opts[:with].to_a
198
+ if (orbs = opts[:orbs])
199
+ orbs.map do |flavor, orb|
200
+ _bodies.combination(2).map do |b1, b2|
201
+ Aspect.new(b1, b2, orb: orb, ratio: Aspect::FLAVORS.key(flavor))
202
+ end
203
+ end.flatten.uniq
204
+ else
205
+ _bodies.combination(2).map do |b1, b2|
206
+ Aspect.new(b1, b2, opts)
207
+ end.reject(&:invalid?)
208
+ end
209
+ end
210
+
211
+ def conjunctions(orb: 8)
212
+ aspects(orb: orb, harmonic: 1)
213
+ end
214
+
215
+ def trines(orb: 8)
216
+ aspects(orb: orb, harmonic: 3)
217
+ end
218
+
219
+ def midpoints
220
+ bodies.combination(2).map do |b1, b2|
221
+ Midpoint.new(b1, b2)
222
+ end
223
+ end
224
+
225
+ def midpoint_aspects(opts = {})
226
+ opts[:max_harmonic] ||= 2
227
+ opts[:orb] ||= 1.5
228
+ aspects(opts.merge(with: midpoints))
229
+ .select(&:midpoint?)
230
+ .reject(&:overlap?).reject(&:isotrap?)
231
+ .sort_by(&:orb)
232
+ end
233
+
234
+ # def isotraps( opts={} )
235
+ # opts[:orb] ||= 1.5
236
+ # aspects( opts.merge( with: midpoints ) ).select(&:isotrap?).reject(&:conjunction)
237
+ # end
238
+ end
239
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroscript
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # $LOAD_PATH.unshift File.expand_path(".", __dir__)
4
+
5
+ require "dotenv/load" if Gem.loaded_specs.key?("dotenv") # do this first
6
+ require "byebug" if Gem.loaded_specs.key?("byebug")
7
+
8
+ require "active_support"
9
+ require "active_support/core_ext/string"
10
+ require "forwardable"
11
+ require "geocoder"
12
+ require "logger"
13
+ require "ostruct"
14
+
15
+ require_relative "ruby_extensions"
16
+ require_relative "swiss_ephemeris"
17
+
18
+ require_relative "astroscript/version"
19
+ require_relative "astro_helper"
20
+ require_relative "astroscript/calculator"
21
+ require_relative "astroscript/body"
22
+ require_relative "astroscript/body/midpoint"
23
+ require_relative "astroscript/body/const_body"
24
+ require_relative "astroscript/body/method_body"
25
+ # require_relative './astro/body/house_cusp'
26
+ require_relative "astroscript/chart"
27
+ require_relative "astroscript/aspect"
28
+
29
+ $logger = Logger.new($stdout)
30
+ $logger.level = Logger::WARN
31
+ # $logger.level = Logger::DEBUG
32
+
33
+ require 'dotenv/load'
34
+ Geocoder.configure(lookup: :geoapify, api_key: ENV.fetch("GEOCODER_API_KEY", nil))
35
+ SwissEphemeris.path = ENV.fetch("SE_EPHE_PATH", File.expand_path("../vendor/swe_data", __dir__))
36
+
37
+ module Astroscript
38
+ class Error < StandardError; end
39
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module ArrayExtensions
6
+ def diff
7
+ diffop{|x, y| y - x }
8
+ end
9
+
10
+ def diffop
11
+ d1 = dup.unshift(0)
12
+ d2 = dup.push(0)
13
+ (0..size).map{|i| yield(d1[i], d2[i]) }[1...-1]
14
+ end
15
+
16
+ def avg
17
+ sum / size.to_f
18
+ end
19
+
20
+ def gmean
21
+ reduce(:*).to_f**(1.0 / size)
22
+ end
23
+
24
+ def **(other)
25
+ map{|x| x * other }
26
+ end
27
+
28
+ # harmonic operator
29
+ def >>(other)
30
+ map{|x| (x * other) % 360.0 }
31
+ end
32
+ end
33
+ Array.include ArrayExtensions
34
+
35
+ module NumericExtensions
36
+ def sgn
37
+ negative? ? -1 : 1
38
+ end
39
+
40
+ def mod360
41
+ self % 360
42
+ end
43
+ end
44
+ Numeric.include NumericExtensions
45
+
46
+ module IntegerExtensions
47
+ def ordinalize
48
+ n = self
49
+ return "#{n}th" if (11..13).include?(n % 100)
50
+
51
+ case n % 10
52
+ when 1 then "#{n}st"
53
+ when 2 then "#{n}nd"
54
+ when 3 then "#{n}rd"
55
+ else "#{n}th"
56
+ end
57
+ end
58
+
59
+ PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103,
60
+ 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349].freeze
61
+
62
+ def hph
63
+ n = self
64
+ return 1 if n == 1
65
+
66
+ PRIMES.reverse.find{|p| n.gcd(p) != 1 }
67
+ end
68
+ end
69
+ Integer.include IntegerExtensions
70
+
71
+ module DateTimeExtensions
72
+ def round(granularity = 1.hour)
73
+ Time.at((to_time.to_i / granularity).round * granularity).to_datetime
74
+ end
75
+ end
76
+ DateTime.include DateTimeExtensions
77
+
78
+ module FloatExtensions
79
+ def to_dms(opts = {})
80
+ AstroHelper.print_dms(self, opts)
81
+ end
82
+ end
83
+ Float.include FloatExtensions