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.
@@ -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