astrolith 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +317 -0
- data/astrolith.gemspec +35 -0
- data/examples/example_usage.rb +154 -0
- data/lib/ruby_chart_engine/calculations/aspects.rb +88 -0
- data/lib/ruby_chart_engine/calculations/dignities.rb +71 -0
- data/lib/ruby_chart_engine/calculations/houses.rb +97 -0
- data/lib/ruby_chart_engine/calculations/positions.rb +113 -0
- data/lib/ruby_chart_engine/charts/base_chart.rb +135 -0
- data/lib/ruby_chart_engine/charts/composite.rb +90 -0
- data/lib/ruby_chart_engine/charts/natal.rb +79 -0
- data/lib/ruby_chart_engine/charts/progressed.rb +55 -0
- data/lib/ruby_chart_engine/charts/solar_return.rb +90 -0
- data/lib/ruby_chart_engine/charts/transit.rb +64 -0
- data/lib/ruby_chart_engine/input/coordinates.rb +49 -0
- data/lib/ruby_chart_engine/input/datetime.rb +68 -0
- data/lib/ruby_chart_engine/input/timezone.rb +76 -0
- data/lib/ruby_chart_engine/serializers/json_serializer.rb +44 -0
- data/lib/ruby_chart_engine/version.rb +3 -0
- data/lib/ruby_chart_engine.rb +82 -0
- metadata +177 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module RubyChartEngine
|
|
2
|
+
module Calculations
|
|
3
|
+
class Houses
|
|
4
|
+
attr_reader :julian_day, :latitude, :longitude, :house_system
|
|
5
|
+
|
|
6
|
+
def initialize(julian_day:, latitude:, longitude:, house_system: :placidus)
|
|
7
|
+
@julian_day = julian_day
|
|
8
|
+
@latitude = latitude
|
|
9
|
+
@longitude = longitude
|
|
10
|
+
@house_system = house_system
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Calculate house cusps and angles
|
|
14
|
+
def calculate
|
|
15
|
+
system_code = HOUSE_SYSTEMS[house_system] || 'P'
|
|
16
|
+
|
|
17
|
+
# Call swe_houses - returns a flat array of 23 elements
|
|
18
|
+
# [0-12]: house cusps (0 is unused, 1-12 are the 12 houses)
|
|
19
|
+
# [13+]: ASCMC values (ascendant, MC, ARMC, vertex, etc.)
|
|
20
|
+
result = Swe4r.swe_houses(julian_day, latitude, longitude, system_code)
|
|
21
|
+
|
|
22
|
+
unless result.is_a?(Array)
|
|
23
|
+
raise Error, "Unexpected swe_houses return type: #{result.class} - #{result.inspect}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
cusps: extract_cusps(result),
|
|
28
|
+
angles: extract_angles(result)
|
|
29
|
+
}
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
raise Error, "Failed to calculate houses: #{e.message}\nResult type: #{result.class rescue 'unknown'}\nResult: #{result.inspect rescue 'uninspectable'}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Determine which house a longitude is in
|
|
35
|
+
def house_for_longitude(longitude, cusps)
|
|
36
|
+
# Normalize longitude
|
|
37
|
+
longitude = normalize_degrees(longitude)
|
|
38
|
+
|
|
39
|
+
# Return default if cusps is empty or invalid
|
|
40
|
+
return 1 if cusps.nil? || cusps.empty? || cusps.length < 12
|
|
41
|
+
|
|
42
|
+
# Find the house
|
|
43
|
+
12.times do |i|
|
|
44
|
+
cusp_start = cusps[i]
|
|
45
|
+
cusp_end = cusps[(i + 1) % 12]
|
|
46
|
+
|
|
47
|
+
# Skip if cusps are nil
|
|
48
|
+
next if cusp_start.nil? || cusp_end.nil?
|
|
49
|
+
|
|
50
|
+
# Handle wrapping around 0 degrees
|
|
51
|
+
if cusp_start > cusp_end
|
|
52
|
+
return i + 1 if longitude >= cusp_start || longitude < cusp_end
|
|
53
|
+
else
|
|
54
|
+
return i + 1 if longitude >= cusp_start && longitude < cusp_end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
1 # Default to first house
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def extract_cusps(result_array)
|
|
64
|
+
# swe_houses returns a flat array where:
|
|
65
|
+
# [0-12]: house cusps (0 is unused, 1-12 are the 12 houses)
|
|
66
|
+
# We want indices 1-12
|
|
67
|
+
return [] unless result_array.is_a?(Array) && result_array.length >= 13
|
|
68
|
+
|
|
69
|
+
result_array[1..12]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_angles(result_array)
|
|
73
|
+
# swe_houses returns a flat array where:
|
|
74
|
+
# [13]: Ascendant
|
|
75
|
+
# [14]: MC (Midheaven)
|
|
76
|
+
# [15]: ARMC
|
|
77
|
+
# [16]: Vertex
|
|
78
|
+
# [17-22]: Other values
|
|
79
|
+
return {} unless result_array.is_a?(Array) && result_array.length >= 17
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
ascendant: result_array[13],
|
|
83
|
+
midheaven: result_array[14],
|
|
84
|
+
descendant: normalize_degrees(result_array[13] + 180),
|
|
85
|
+
imum_coeli: normalize_degrees(result_array[14] + 180),
|
|
86
|
+
vertex: result_array[16]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_degrees(degrees)
|
|
91
|
+
degrees = degrees % 360
|
|
92
|
+
degrees += 360 if degrees < 0
|
|
93
|
+
degrees
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module RubyChartEngine
|
|
2
|
+
module Calculations
|
|
3
|
+
class Positions
|
|
4
|
+
attr_reader :julian_day, :latitude, :longitude
|
|
5
|
+
|
|
6
|
+
def initialize(julian_day:, latitude:, longitude:)
|
|
7
|
+
@julian_day = julian_day
|
|
8
|
+
@latitude = latitude
|
|
9
|
+
@longitude = longitude
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Calculate position for a single celestial object
|
|
13
|
+
def calculate_planet(planet_id)
|
|
14
|
+
# Calculation flags: 4 = SEFLG_MOSEPH (use built-in Moshier ephemeris), 256 = SEFLG_SPEED
|
|
15
|
+
# Using Moshier ephemeris (analytical) to avoid needing external ephemeris files
|
|
16
|
+
# Provides precision of ~0.1 arc seconds for planets, ~3" for Moon
|
|
17
|
+
flags = 4 | 256 # SEFLG_MOSEPH | SEFLG_SPEED
|
|
18
|
+
|
|
19
|
+
result = Swe4r.swe_calc_ut(julian_day, planet_id, flags)
|
|
20
|
+
|
|
21
|
+
# swe_calc_ut returns an array: [longitude, latitude, distance, speed_long, speed_lat, speed_dist]
|
|
22
|
+
# Extract values based on whether it's an array or hash
|
|
23
|
+
if result.is_a?(Array)
|
|
24
|
+
{
|
|
25
|
+
longitude: result[0],
|
|
26
|
+
latitude: result[1],
|
|
27
|
+
distance: result[2],
|
|
28
|
+
speed_longitude: result[3],
|
|
29
|
+
speed_latitude: result[4],
|
|
30
|
+
speed_distance: result[5]
|
|
31
|
+
}
|
|
32
|
+
elsif result.is_a?(Hash)
|
|
33
|
+
{
|
|
34
|
+
longitude: result[:longitude] || result['longitude'],
|
|
35
|
+
latitude: result[:latitude] || result['latitude'],
|
|
36
|
+
distance: result[:distance] || result['distance'],
|
|
37
|
+
speed_longitude: result[:speed_longitude] || result['speed_longitude'],
|
|
38
|
+
speed_latitude: result[:speed_latitude] || result['speed_latitude'],
|
|
39
|
+
speed_distance: result[:speed_distance] || result['speed_distance']
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
raise Error, "Unexpected swe_calc_ut return type: #{result.class}"
|
|
43
|
+
end
|
|
44
|
+
rescue => e
|
|
45
|
+
raise Error, "Failed to calculate position for planet #{planet_id}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Calculate all planets
|
|
49
|
+
def calculate_all_planets
|
|
50
|
+
positions = {}
|
|
51
|
+
|
|
52
|
+
PLANETS.each do |name, id|
|
|
53
|
+
positions[name] = calculate_planet(id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Calculate points
|
|
57
|
+
POINTS.each do |name, id|
|
|
58
|
+
if name == :south_node
|
|
59
|
+
# South Node is opposite of North Node
|
|
60
|
+
north_node = calculate_planet(POINTS[:north_node])
|
|
61
|
+
positions[name] = north_node.dup
|
|
62
|
+
positions[name][:longitude] = normalize_degrees(north_node[:longitude] + 180)
|
|
63
|
+
elsif name == :chiron
|
|
64
|
+
# Chiron is not available in Moshier ephemeris, skip it
|
|
65
|
+
# Would require external ephemeris files (seas_*.se1)
|
|
66
|
+
next
|
|
67
|
+
else
|
|
68
|
+
positions[name] = calculate_planet(id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
positions
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get sign from longitude
|
|
76
|
+
def longitude_to_sign(longitude)
|
|
77
|
+
sign_index = (longitude / 30).floor
|
|
78
|
+
sign_longitude = longitude % 30
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
index: sign_index,
|
|
82
|
+
sign: SIGNS[sign_index],
|
|
83
|
+
longitude: sign_longitude,
|
|
84
|
+
decan: ((sign_longitude / 10).floor + 1)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Determine if planet is retrograde
|
|
89
|
+
def retrograde?(speed)
|
|
90
|
+
speed < 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get movement status
|
|
94
|
+
def movement_status(speed)
|
|
95
|
+
if speed.abs < 0.0001
|
|
96
|
+
'stationary'
|
|
97
|
+
elsif speed < 0
|
|
98
|
+
'retrograde'
|
|
99
|
+
else
|
|
100
|
+
'direct'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def normalize_degrees(degrees)
|
|
107
|
+
degrees = degrees % 360
|
|
108
|
+
degrees += 360 if degrees < 0
|
|
109
|
+
degrees
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module RubyChartEngine
|
|
2
|
+
module Charts
|
|
3
|
+
class BaseChart
|
|
4
|
+
attr_reader :datetime, :coordinates, :timezone, :house_system,
|
|
5
|
+
:julian_day, :houses, :angles, :planets, :aspects
|
|
6
|
+
|
|
7
|
+
def initialize(datetime:, latitude:, longitude:, timezone: 'UTC', house_system: :placidus)
|
|
8
|
+
# Parse inputs
|
|
9
|
+
@datetime = Input::DateTime.new(datetime)
|
|
10
|
+
@coordinates = Input::Coordinates.new(lat: latitude, lon: longitude)
|
|
11
|
+
@timezone = Input::Timezone.new(timezone, @datetime.datetime)
|
|
12
|
+
@house_system = house_system
|
|
13
|
+
|
|
14
|
+
# Calculate Julian Day
|
|
15
|
+
@julian_day = @datetime.to_julian_day(@timezone.offset_hours)
|
|
16
|
+
|
|
17
|
+
# Initialize calculators
|
|
18
|
+
@position_calc = Calculations::Positions.new(
|
|
19
|
+
julian_day: @julian_day,
|
|
20
|
+
latitude: @coordinates.latitude,
|
|
21
|
+
longitude: @coordinates.longitude
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@house_calc = Calculations::Houses.new(
|
|
25
|
+
julian_day: @julian_day,
|
|
26
|
+
latitude: @coordinates.latitude,
|
|
27
|
+
longitude: @coordinates.longitude,
|
|
28
|
+
house_system: @house_system
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Perform calculations
|
|
32
|
+
calculate!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_json(*args)
|
|
36
|
+
to_hash.to_json(*args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_hash
|
|
40
|
+
{
|
|
41
|
+
metadata: metadata,
|
|
42
|
+
planets: format_planets,
|
|
43
|
+
houses: format_houses,
|
|
44
|
+
angles: format_angles,
|
|
45
|
+
aspects: @aspects
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def calculate!
|
|
52
|
+
# Calculate houses and angles
|
|
53
|
+
house_data = @house_calc.calculate
|
|
54
|
+
@house_cusps = house_data[:cusps]
|
|
55
|
+
@angles = house_data[:angles]
|
|
56
|
+
@houses = house_data # Store full house data including cusps and angles
|
|
57
|
+
|
|
58
|
+
# Calculate planetary positions
|
|
59
|
+
@planets = calculate_planets
|
|
60
|
+
|
|
61
|
+
# Calculate aspects
|
|
62
|
+
@aspects = Calculations::Aspects.calculate(@planets)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def calculate_planets
|
|
66
|
+
raw_positions = @position_calc.calculate_all_planets
|
|
67
|
+
formatted_planets = {}
|
|
68
|
+
|
|
69
|
+
raw_positions.each do |name, data|
|
|
70
|
+
sign_data = @position_calc.longitude_to_sign(data[:longitude])
|
|
71
|
+
house_number = @house_calc.house_for_longitude(data[:longitude], @house_cusps)
|
|
72
|
+
|
|
73
|
+
formatted_planets[name] = {
|
|
74
|
+
longitude: data[:longitude],
|
|
75
|
+
latitude: data[:latitude],
|
|
76
|
+
distance: data[:distance],
|
|
77
|
+
speed_longitude: data[:speed_longitude],
|
|
78
|
+
sign_longitude: sign_data[:longitude],
|
|
79
|
+
sign: sign_data[:sign],
|
|
80
|
+
house: house_number,
|
|
81
|
+
decan: sign_data[:decan],
|
|
82
|
+
movement: @position_calc.movement_status(data[:speed_longitude]),
|
|
83
|
+
dignities: Calculations::Dignities.calculate(name, sign_data[:index])
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add angles as celestial points
|
|
88
|
+
@angles.each do |name, longitude|
|
|
89
|
+
next if name == :vertex # Skip vertex for now
|
|
90
|
+
|
|
91
|
+
sign_data = @position_calc.longitude_to_sign(longitude)
|
|
92
|
+
|
|
93
|
+
formatted_planets[name] = {
|
|
94
|
+
longitude: longitude,
|
|
95
|
+
sign_longitude: sign_data[:longitude],
|
|
96
|
+
sign: sign_data[:sign],
|
|
97
|
+
decan: sign_data[:decan]
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
formatted_planets
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_planets
|
|
105
|
+
@planets.select { |name, _| !ANGLES.keys.include?(name) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def format_angles
|
|
109
|
+
@planets.select { |name, _| ANGLES.keys.include?(name) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def format_houses
|
|
113
|
+
@house_cusps.each_with_index.map do |cusp, index|
|
|
114
|
+
sign_data = @position_calc.longitude_to_sign(cusp)
|
|
115
|
+
{
|
|
116
|
+
number: index + 1,
|
|
117
|
+
cusp: cusp,
|
|
118
|
+
sign: sign_data[:sign]
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def metadata
|
|
124
|
+
{
|
|
125
|
+
datetime: @datetime.datetime.iso8601,
|
|
126
|
+
julian_day: @julian_day,
|
|
127
|
+
latitude: @coordinates.latitude,
|
|
128
|
+
longitude: @coordinates.longitude,
|
|
129
|
+
timezone: @timezone.timezone.to_s,
|
|
130
|
+
house_system: @house_system.to_s
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require_relative 'base_chart'
|
|
2
|
+
|
|
3
|
+
module RubyChartEngine
|
|
4
|
+
module Charts
|
|
5
|
+
class Composite < BaseChart
|
|
6
|
+
attr_reader :chart1, :chart2
|
|
7
|
+
|
|
8
|
+
# Composite chart - midpoint chart between two natal charts
|
|
9
|
+
def initialize(chart1_params:, chart2_params:, house_system: :placidus)
|
|
10
|
+
# Create the two base charts
|
|
11
|
+
@chart1 = Natal.new(**chart1_params)
|
|
12
|
+
@chart2 = Natal.new(**chart2_params)
|
|
13
|
+
|
|
14
|
+
# Calculate composite datetime and location
|
|
15
|
+
composite_datetime = calculate_midpoint_datetime
|
|
16
|
+
composite_coords = calculate_midpoint_coordinates
|
|
17
|
+
|
|
18
|
+
# Initialize with composite parameters
|
|
19
|
+
super(
|
|
20
|
+
datetime: composite_datetime,
|
|
21
|
+
latitude: composite_coords[:latitude],
|
|
22
|
+
longitude: composite_coords[:longitude],
|
|
23
|
+
timezone: chart1_params[:timezone] || 'UTC',
|
|
24
|
+
house_system: house_system
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_hash
|
|
29
|
+
super.merge(
|
|
30
|
+
chart_type: 'composite',
|
|
31
|
+
chart1_metadata: @chart1.to_hash[:metadata],
|
|
32
|
+
chart2_metadata: @chart2.to_hash[:metadata]
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def calculate_midpoint_datetime
|
|
39
|
+
# Calculate midpoint between two datetimes
|
|
40
|
+
jd1 = @chart1.julian_day
|
|
41
|
+
jd2 = @chart2.julian_day
|
|
42
|
+
|
|
43
|
+
midpoint_jd = (jd1 + jd2) / 2.0
|
|
44
|
+
|
|
45
|
+
# Convert back to datetime
|
|
46
|
+
# swe_revjul takes 1 argument: julian day number
|
|
47
|
+
result = Swe4r.swe_revjul(midpoint_jd)
|
|
48
|
+
|
|
49
|
+
::DateTime.new(
|
|
50
|
+
result[:year],
|
|
51
|
+
result[:month],
|
|
52
|
+
result[:day],
|
|
53
|
+
result[:hour].to_i,
|
|
54
|
+
((result[:hour] % 1) * 60).to_i,
|
|
55
|
+
0
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def calculate_midpoint_coordinates
|
|
60
|
+
lat1 = @chart1.coordinates.latitude
|
|
61
|
+
lon1 = @chart1.coordinates.longitude
|
|
62
|
+
lat2 = @chart2.coordinates.latitude
|
|
63
|
+
lon2 = @chart2.coordinates.longitude
|
|
64
|
+
|
|
65
|
+
# Simple midpoint calculation
|
|
66
|
+
# Note: This doesn't account for the spherical nature of Earth
|
|
67
|
+
# For a more accurate calculation, use great circle midpoint
|
|
68
|
+
{
|
|
69
|
+
latitude: (lat1 + lat2) / 2.0,
|
|
70
|
+
longitude: calculate_longitude_midpoint(lon1, lon2)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def calculate_longitude_midpoint(lon1, lon2)
|
|
75
|
+
# Handle date line crossing
|
|
76
|
+
diff = (lon2 - lon1).abs
|
|
77
|
+
|
|
78
|
+
if diff > 180
|
|
79
|
+
# Crosses date line - take the "other" midpoint
|
|
80
|
+
midpoint = (lon1 + lon2 + 360) / 2.0
|
|
81
|
+
midpoint -= 360 if midpoint > 180
|
|
82
|
+
else
|
|
83
|
+
midpoint = (lon1 + lon2) / 2.0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
midpoint
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require_relative 'base_chart'
|
|
2
|
+
|
|
3
|
+
module RubyChartEngine
|
|
4
|
+
module Charts
|
|
5
|
+
class Natal < BaseChart
|
|
6
|
+
# Natal chart is the base chart type
|
|
7
|
+
# Additional natal-specific methods can be added here
|
|
8
|
+
|
|
9
|
+
def moon_phase
|
|
10
|
+
sun_lon = @planets[:sun][:longitude]
|
|
11
|
+
moon_lon = @planets[:moon][:longitude]
|
|
12
|
+
|
|
13
|
+
phase_angle = (moon_lon - sun_lon) % 360
|
|
14
|
+
|
|
15
|
+
case phase_angle
|
|
16
|
+
when 0..45
|
|
17
|
+
'New Moon'
|
|
18
|
+
when 45..90
|
|
19
|
+
'Waxing Crescent'
|
|
20
|
+
when 90..135
|
|
21
|
+
'First Quarter'
|
|
22
|
+
when 135..180
|
|
23
|
+
'Waxing Gibbous'
|
|
24
|
+
when 180..225
|
|
25
|
+
'Full Moon'
|
|
26
|
+
when 225..270
|
|
27
|
+
'Waning Gibbous'
|
|
28
|
+
when 270..315
|
|
29
|
+
'Last Quarter'
|
|
30
|
+
when 315..360
|
|
31
|
+
'Waning Crescent'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def chart_shape
|
|
36
|
+
# Analyze planetary distribution to determine chart shape
|
|
37
|
+
# This is a simplified implementation
|
|
38
|
+
longitudes = @planets.select { |k, _| PLANETS.keys.include?(k) }
|
|
39
|
+
.map { |_, v| v[:longitude] }
|
|
40
|
+
.sort
|
|
41
|
+
|
|
42
|
+
return 'splash' if longitudes.empty?
|
|
43
|
+
|
|
44
|
+
# Calculate the largest gap between consecutive planets
|
|
45
|
+
gaps = []
|
|
46
|
+
longitudes.each_with_index do |lon, i|
|
|
47
|
+
next_lon = longitudes[(i + 1) % longitudes.length]
|
|
48
|
+
gap = next_lon > lon ? next_lon - lon : (360 - lon) + next_lon
|
|
49
|
+
gaps << gap
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
max_gap = gaps.max
|
|
53
|
+
occupied_arc = 360 - max_gap
|
|
54
|
+
|
|
55
|
+
case
|
|
56
|
+
when max_gap > 240
|
|
57
|
+
'bundle'
|
|
58
|
+
when occupied_arc <= 120
|
|
59
|
+
'bundle'
|
|
60
|
+
when occupied_arc <= 180
|
|
61
|
+
'bowl'
|
|
62
|
+
when occupied_arc <= 240
|
|
63
|
+
'bucket'
|
|
64
|
+
when gaps.all? { |g| g > 20 && g < 60 }
|
|
65
|
+
'splay'
|
|
66
|
+
else
|
|
67
|
+
'splash'
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_hash
|
|
72
|
+
super.merge(
|
|
73
|
+
moon_phase: moon_phase,
|
|
74
|
+
chart_shape: chart_shape
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative 'base_chart'
|
|
2
|
+
|
|
3
|
+
module RubyChartEngine
|
|
4
|
+
module Charts
|
|
5
|
+
class Progressed < BaseChart
|
|
6
|
+
attr_reader :natal_datetime, :progression_date
|
|
7
|
+
|
|
8
|
+
# Secondary Progressed chart
|
|
9
|
+
# Uses "a day for a year" progression
|
|
10
|
+
def initialize(natal_datetime:, progression_date:, latitude:, longitude:, timezone: 'UTC', house_system: :placidus)
|
|
11
|
+
@natal_datetime = natal_datetime
|
|
12
|
+
@progression_date = progression_date
|
|
13
|
+
|
|
14
|
+
# Calculate progressed datetime
|
|
15
|
+
progressed_datetime = calculate_progressed_datetime
|
|
16
|
+
|
|
17
|
+
# Initialize with progressed datetime
|
|
18
|
+
super(
|
|
19
|
+
datetime: progressed_datetime,
|
|
20
|
+
latitude: latitude,
|
|
21
|
+
longitude: longitude,
|
|
22
|
+
timezone: timezone,
|
|
23
|
+
house_system: house_system
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_hash
|
|
28
|
+
super.merge(
|
|
29
|
+
natal_datetime: @natal_datetime.to_s,
|
|
30
|
+
progression_date: @progression_date.to_s,
|
|
31
|
+
chart_type: 'progressed'
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def calculate_progressed_datetime
|
|
38
|
+
natal_dt = Input::DateTime.new(@natal_datetime)
|
|
39
|
+
prog_dt = Input::DateTime.new(@progression_date)
|
|
40
|
+
|
|
41
|
+
# Calculate days between natal and progression date
|
|
42
|
+
natal_date = natal_dt.datetime
|
|
43
|
+
progression_date = prog_dt.datetime
|
|
44
|
+
|
|
45
|
+
days_difference = (progression_date - natal_date).to_i
|
|
46
|
+
|
|
47
|
+
# Add the same number of days to natal datetime
|
|
48
|
+
# (Secondary progression: 1 day = 1 year)
|
|
49
|
+
progressed = natal_date + days_difference
|
|
50
|
+
|
|
51
|
+
progressed
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require_relative 'base_chart'
|
|
2
|
+
|
|
3
|
+
module RubyChartEngine
|
|
4
|
+
module Charts
|
|
5
|
+
class SolarReturn < BaseChart
|
|
6
|
+
attr_reader :natal_sun_longitude
|
|
7
|
+
|
|
8
|
+
# Solar Return chart for a specific year
|
|
9
|
+
# Calculated for the moment the Sun returns to its natal position
|
|
10
|
+
def initialize(natal_datetime:, return_year:, latitude:, longitude:, timezone: 'UTC', house_system: :placidus)
|
|
11
|
+
# Get natal Sun position
|
|
12
|
+
natal_chart_temp = Natal.new(
|
|
13
|
+
datetime: natal_datetime,
|
|
14
|
+
latitude: latitude,
|
|
15
|
+
longitude: longitude,
|
|
16
|
+
timezone: timezone
|
|
17
|
+
)
|
|
18
|
+
@natal_sun_longitude = natal_chart_temp.planets[:sun][:longitude]
|
|
19
|
+
|
|
20
|
+
# Find the moment when Sun returns to natal position in the return year
|
|
21
|
+
return_datetime = find_solar_return_time(natal_datetime, return_year, timezone)
|
|
22
|
+
|
|
23
|
+
# Initialize with the solar return datetime
|
|
24
|
+
super(
|
|
25
|
+
datetime: return_datetime,
|
|
26
|
+
latitude: latitude,
|
|
27
|
+
longitude: longitude,
|
|
28
|
+
timezone: timezone,
|
|
29
|
+
house_system: house_system
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_hash
|
|
34
|
+
super.merge(
|
|
35
|
+
natal_sun_longitude: @natal_sun_longitude,
|
|
36
|
+
chart_type: 'solar_return'
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def find_solar_return_time(natal_datetime, return_year, timezone)
|
|
43
|
+
# Parse natal datetime
|
|
44
|
+
natal_dt = Input::DateTime.new(natal_datetime)
|
|
45
|
+
|
|
46
|
+
# Start with birthday in the return year
|
|
47
|
+
search_date = ::DateTime.new(
|
|
48
|
+
return_year,
|
|
49
|
+
natal_dt.month,
|
|
50
|
+
natal_dt.day,
|
|
51
|
+
natal_dt.hour,
|
|
52
|
+
natal_dt.minute,
|
|
53
|
+
natal_dt.second
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Binary search to find exact moment of solar return
|
|
57
|
+
# This is a simplified implementation
|
|
58
|
+
best_date = search_date
|
|
59
|
+
min_diff = Float::INFINITY
|
|
60
|
+
|
|
61
|
+
# Search in a 48-hour window around the birthday
|
|
62
|
+
(-24..24).each do |hour_offset|
|
|
63
|
+
test_date = search_date + (hour_offset / 24.0)
|
|
64
|
+
test_dt = Input::DateTime.new(test_date)
|
|
65
|
+
tz = Input::Timezone.new(timezone, test_date)
|
|
66
|
+
jd = test_dt.to_julian_day(tz.offset_hours)
|
|
67
|
+
|
|
68
|
+
# Calculate Sun position
|
|
69
|
+
pos_calc = Calculations::Positions.new(
|
|
70
|
+
julian_day: jd,
|
|
71
|
+
latitude: 0,
|
|
72
|
+
longitude: 0
|
|
73
|
+
)
|
|
74
|
+
sun_pos = pos_calc.calculate_planet(PLANETS[:sun])
|
|
75
|
+
|
|
76
|
+
# Calculate difference from natal Sun
|
|
77
|
+
diff = (sun_pos[:longitude] - @natal_sun_longitude).abs
|
|
78
|
+
diff = 360 - diff if diff > 180
|
|
79
|
+
|
|
80
|
+
if diff < min_diff
|
|
81
|
+
min_diff = diff
|
|
82
|
+
best_date = test_date
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
best_date
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|