astro_chart 0.1.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +122 -0
- data/LICENSE +17 -16
- data/README.md +417 -0
- data/astro_chart.gemspec +11 -6
- data/lib/astro_chart/aspects.rb +31 -2
- data/lib/astro_chart/chart.rb +94 -12
- data/lib/astro_chart/composite.rb +101 -0
- data/lib/astro_chart/dignities.rb +220 -0
- data/lib/astro_chart/draconic.rb +64 -0
- data/lib/astro_chart/ephemeris.rb +125 -18
- data/lib/astro_chart/houses.rb +2 -1
- data/lib/astro_chart/lunar_return.rb +90 -0
- data/lib/astro_chart/patterns.rb +248 -0
- data/lib/astro_chart/points.rb +82 -0
- data/lib/astro_chart/profection.rb +56 -0
- data/lib/astro_chart/progressions.rb +68 -0
- data/lib/astro_chart/pure/core.rb +209 -0
- data/lib/astro_chart/pure/houses.rb +207 -0
- data/lib/astro_chart/pure/moon.rb +341 -0
- data/lib/astro_chart/pure/moon_elp.rb +931 -0
- data/lib/astro_chart/pure/pluto.rb +220 -0
- data/lib/astro_chart/pure/vsop87.rb +152 -0
- data/lib/astro_chart/pure/vsop87_data.rb +210 -0
- data/lib/astro_chart/pure.rb +68 -0
- data/lib/astro_chart/solar_arc.rb +59 -0
- data/lib/astro_chart/solar_return.rb +193 -0
- data/lib/astro_chart/stats.rb +34 -0
- data/lib/astro_chart/synastry.rb +118 -0
- data/lib/astro_chart/transit_timing.rb +186 -0
- data/lib/astro_chart/transits.rb +108 -0
- data/lib/astro_chart/version.rb +1 -1
- data/lib/astro_chart.rb +19 -1
- metadata +36 -45
- data/ext/astro_chart/astro_chart_ext.c +0 -99
- data/ext/astro_chart/extconf.rb +0 -8
- data/ext/astro_chart/swecl.c +0 -6428
- data/ext/astro_chart/swedate.c +0 -588
- data/ext/astro_chart/swedate.h +0 -81
- data/ext/astro_chart/swedll.h +0 -403
- data/ext/astro_chart/sweephe4.c +0 -702
- data/ext/astro_chart/sweephe4.h +0 -239
- data/ext/astro_chart/swehel.c +0 -3511
- data/ext/astro_chart/swehouse.c +0 -3143
- data/ext/astro_chart/swehouse.h +0 -98
- data/ext/astro_chart/swejpl.c +0 -958
- data/ext/astro_chart/swejpl.h +0 -103
- data/ext/astro_chart/swemmoon.c +0 -1930
- data/ext/astro_chart/swemplan.c +0 -967
- data/ext/astro_chart/swemptab.h +0 -10640
- data/ext/astro_chart/swenut2000a.h +0 -2819
- data/ext/astro_chart/sweodef.h +0 -326
- data/ext/astro_chart/sweph.c +0 -8614
- data/ext/astro_chart/sweph.h +0 -849
- data/ext/astro_chart/swephexp.h +0 -1020
- data/ext/astro_chart/swephlib.c +0 -4634
- data/ext/astro_chart/swephlib.h +0 -189
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pure/core"
|
|
4
|
+
require_relative "pure/houses"
|
|
5
|
+
require_relative "pure/vsop87"
|
|
6
|
+
require_relative "pure/moon"
|
|
7
|
+
require_relative "pure/pluto"
|
|
8
|
+
|
|
9
|
+
module AstroChart
|
|
10
|
+
# Pure-Ruby ephemeris backend (MIT-safe, zero external dependencies).
|
|
11
|
+
#
|
|
12
|
+
# Front-facing API mirrors the Swiss Ephemeris C extension (AstroChart::Ext):
|
|
13
|
+
# Pure.julday(year, month, day, hour) -> JD(UT) Float
|
|
14
|
+
# Pure.calc_ut(jd_ut, planet_id) -> apparent ecliptic longitude (deg, 0-360)
|
|
15
|
+
# Pure.houses(jd_ut, lat, lon, hsys = "P") -> { "cusps" => [12], "ascendant" => f, "mc" => f }
|
|
16
|
+
#
|
|
17
|
+
# planet_id follows the SE convention:
|
|
18
|
+
# SUN=0 MOON=1 MERCURY=2 VENUS=3 MARS=4 JUPITER=5 SATURN=6
|
|
19
|
+
# URANUS=7 NEPTUNE=8 PLUTO=9 TRUE_NODE=11
|
|
20
|
+
module Pure
|
|
21
|
+
# planet_id (SE convention) -> handled by which pure module
|
|
22
|
+
VSOP87_PLANET_IDS = [0, 2, 3, 4, 5, 6, 7, 8].freeze
|
|
23
|
+
MOON_ID = 1
|
|
24
|
+
PLUTO_ID = 9
|
|
25
|
+
TRUE_NODE_ID = 11
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Convert calendar date/time (UT) to Julian Day number.
|
|
30
|
+
def julday(year, month, day, hour)
|
|
31
|
+
Core.julday(year, month, day, hour)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Apparent ecliptic longitude (degrees, [0, 360)) for the given body.
|
|
35
|
+
def calc_ut(jd_ut, planet_id)
|
|
36
|
+
case planet_id
|
|
37
|
+
when *VSOP87_PLANET_IDS
|
|
38
|
+
Vsop87.apparent_longitude(planet_id, jd_ut)
|
|
39
|
+
when MOON_ID
|
|
40
|
+
Moon.apparent_longitude(jd_ut)
|
|
41
|
+
when PLUTO_ID
|
|
42
|
+
Pluto.apparent_longitude(jd_ut)
|
|
43
|
+
when TRUE_NODE_ID
|
|
44
|
+
Moon.true_node(jd_ut)
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"unsupported planet_id #{planet_id.inspect} " \
|
|
48
|
+
"(supported: 0-9 and 11/TRUE_NODE, SE convention)"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# House cusps + ascendant + MC. Placidus ("P") and Whole Sign ("W").
|
|
53
|
+
# hsys accepts "P"/"W" or 80/87 (ord values) to mirror the C extension's int argument.
|
|
54
|
+
def houses(jd_ut, latitude, longitude, hsys = "P")
|
|
55
|
+
case hsys
|
|
56
|
+
when "P", 80 then Houses.calc(jd_ut, latitude, longitude, "P")
|
|
57
|
+
when "W", 87 then Houses.calc(jd_ut, latitude, longitude, "W")
|
|
58
|
+
when "E", 69 then Houses.calc(jd_ut, latitude, longitude, "E")
|
|
59
|
+
when "O", 79 then Houses.calc(jd_ut, latitude, longitude, "O")
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"unsupported house system #{hsys.inspect} " \
|
|
63
|
+
"(pure backend supports Placidus \"P\"/80, Whole Sign \"W\"/87, " \
|
|
64
|
+
"Equal \"E\"/69 and Porphyry \"O\"/79)"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative "time_conversion"
|
|
2
|
+
require_relative "planets"
|
|
3
|
+
require_relative "synastry"
|
|
4
|
+
require_relative "transits"
|
|
5
|
+
|
|
6
|
+
module AstroChart
|
|
7
|
+
# Solar arc directions (太陽弧正向推運): advance every natal point by a single
|
|
8
|
+
# arc — the distance the secondary-progressed Sun has travelled since birth.
|
|
9
|
+
# Unlike secondary progressions (where each body moves at its own rate),
|
|
10
|
+
# solar arc moves the whole chart rigidly, so directed-to-natal aspects
|
|
11
|
+
# perfect at a rate of ~1° per year of life.
|
|
12
|
+
#
|
|
13
|
+
# natal = AstroChart::Chart.new(...).generate
|
|
14
|
+
# result = AstroChart::SolarArc.directions(natal, "2026-07-24")
|
|
15
|
+
# result["arc"] # degrees the chart has been directed (~age)
|
|
16
|
+
# result["planets"] # directed positions + natal-house placement
|
|
17
|
+
# result["aspects_to_natal"] # directed→natal aspects, sorted by orb
|
|
18
|
+
module SolarArc
|
|
19
|
+
DAYS_PER_YEAR = 365.2425 # matches Progressions
|
|
20
|
+
|
|
21
|
+
SUN = "太陽"
|
|
22
|
+
|
|
23
|
+
# natal_chart: a Chart#generate result hash (its input block gives birth
|
|
24
|
+
# date/time/timezone). target_date: "YYYY-MM-DD".
|
|
25
|
+
# orb_limit: keep only directed-to-natal aspects within this orb (default
|
|
26
|
+
# 1.0 — solar arc is slow, only near-exact contacts matter).
|
|
27
|
+
def self.directions(natal_chart, target_date, orb_limit: 1.0)
|
|
28
|
+
input = natal_chart&.dig("input")
|
|
29
|
+
if input.nil? || input["birth_date"].nil? || input["birth_time"].nil? || input["timezone"].nil?
|
|
30
|
+
raise ArgumentError, "chart has no input data (birth_date/birth_time/timezone required)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
jd_natal = TimeConversion.to_julian_day(input["birth_date"], input["birth_time"], input["timezone"])
|
|
34
|
+
jd_target = TimeConversion.to_julian_day(target_date, input["birth_time"], input["timezone"])
|
|
35
|
+
jd_prog = jd_natal + (jd_target - jd_natal) / DAYS_PER_YEAR # a day for a year
|
|
36
|
+
|
|
37
|
+
natal_positions = Synastry.positions_from_chart(natal_chart)
|
|
38
|
+
natal_cusps = Synastry.cusps_from_chart(natal_chart)
|
|
39
|
+
|
|
40
|
+
natal_sun = natal_positions[SUN] || Planets.calculate_positions(jd_natal)[SUN]
|
|
41
|
+
prog_sun = Planets.calculate_positions(jd_prog)[SUN]
|
|
42
|
+
# The Sun only ever moves forward, so the forward arc is the plain
|
|
43
|
+
# modular difference (< 360° for any human lifespan).
|
|
44
|
+
arc = (prog_sun - natal_sun) % 360.0
|
|
45
|
+
|
|
46
|
+
directed_positions = natal_positions.transform_values { |lon| (lon + arc) % 360.0 }
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"arc" => arc.round(4),
|
|
50
|
+
"planets" => Transits.planet_details(directed_positions, natal_cusps),
|
|
51
|
+
"aspects_to_natal" => Transits.aspects_to_natal(
|
|
52
|
+
directed_positions, natal_positions,
|
|
53
|
+
orb_limit: orb_limit,
|
|
54
|
+
keys: %w[directed_planet natal_planet]
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
module AstroChart
|
|
2
|
+
# Solar return chart (太陽回歸盤): the chart cast for the exact UTC instant
|
|
3
|
+
# the transiting Sun returns to its natal longitude in a given year.
|
|
4
|
+
#
|
|
5
|
+
# natal = AstroChart::Chart.new(...).generate
|
|
6
|
+
# result = SolarReturn.for_year(natal, 2026)
|
|
7
|
+
# result["return_jd"] # Julian Day (UT) of the return instant
|
|
8
|
+
# result["return_time_utc"] # ISO8601 UTC string, e.g. "2026-07-03T05:12:34Z"
|
|
9
|
+
# result["chart"] # full chart structure at the return instant
|
|
10
|
+
#
|
|
11
|
+
# The return instant is found by Newton iteration on the Sun's longitude,
|
|
12
|
+
# then the chart is computed directly at that Julian Day (no lossy
|
|
13
|
+
# round-trip through date strings).
|
|
14
|
+
module SolarReturn
|
|
15
|
+
SUN_ID = Ephemeris::PLANETS["太陽"]
|
|
16
|
+
|
|
17
|
+
# Raised when the Newton iteration fails to converge (should not happen
|
|
18
|
+
# for the Sun, whose longitude is monotonic at ~0.9856°/day).
|
|
19
|
+
class ConvergenceError < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Convergence threshold in degrees (~0.36 arcsec, i.e. under 10 seconds
|
|
22
|
+
# of clock time at the Sun's mean speed).
|
|
23
|
+
CONVERGENCE_DEG = 1e-4
|
|
24
|
+
MAX_ITERATIONS = 20
|
|
25
|
+
|
|
26
|
+
# Same planet => aspect-list wiring as Chart#generate.
|
|
27
|
+
ASPECT_MAP = {
|
|
28
|
+
"太陽" => "sun_aspects",
|
|
29
|
+
"月亮" => "moon_aspects",
|
|
30
|
+
"土星" => "saturn_aspects",
|
|
31
|
+
"金星" => "venus_aspects",
|
|
32
|
+
"北交點" => "north_node_aspects",
|
|
33
|
+
"南交點" => "south_node_aspects",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Build the solar return for a natal chart (a Chart#generate hash) in the
|
|
37
|
+
# given year. Location defaults to the natal chart's coordinates/timezone;
|
|
38
|
+
# pass latitude:/longitude: (and timezone:, informational) to relocate.
|
|
39
|
+
def self.for_year(natal_chart, year, latitude: nil, longitude: nil, timezone: nil)
|
|
40
|
+
natal_sun = natal_sun_degree(natal_chart)
|
|
41
|
+
month, day = natal_month_day(natal_chart)
|
|
42
|
+
|
|
43
|
+
input = natal_chart["input"] || {}
|
|
44
|
+
coords = input["coordinates"] || {}
|
|
45
|
+
lat = latitude || coords["latitude"]
|
|
46
|
+
lng = longitude || coords["longitude"]
|
|
47
|
+
tz = timezone || input["timezone"]
|
|
48
|
+
if lat.nil? || lng.nil?
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"no coordinates: natal chart input has none and none were given"
|
|
51
|
+
end
|
|
52
|
+
lat = lat.to_f
|
|
53
|
+
lng = lng.to_f
|
|
54
|
+
|
|
55
|
+
jd = find_return_jd(natal_sun, year, month, day)
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
"return_jd" => jd,
|
|
59
|
+
"return_time_utc" => jd_to_utc_iso8601(jd),
|
|
60
|
+
"location" => {
|
|
61
|
+
"latitude" => lat,
|
|
62
|
+
"longitude" => lng,
|
|
63
|
+
"timezone" => tz,
|
|
64
|
+
},
|
|
65
|
+
"chart" => build_chart_at(jd, lat, lng),
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Newton iteration: find the JD(UT) nearest the birthday in `year` where
|
|
70
|
+
# the Sun's longitude equals target_deg.
|
|
71
|
+
def self.find_return_jd(target_deg, year, month, day)
|
|
72
|
+
jd = Ephemeris.julday(year, month, day, 12.0)
|
|
73
|
+
|
|
74
|
+
MAX_ITERATIONS.times do
|
|
75
|
+
delta = angle_delta(target_deg - Ephemeris.calc_ut(jd, SUN_ID))
|
|
76
|
+
return jd if delta.abs < CONVERGENCE_DEG
|
|
77
|
+
|
|
78
|
+
jd += delta / sun_speed(jd)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
raise ConvergenceError,
|
|
82
|
+
"solar return did not converge within #{MAX_ITERATIONS} iterations " \
|
|
83
|
+
"(year=#{year}, target=#{target_deg})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sun's longitudinal speed (deg/day) via central difference (~0.9856).
|
|
87
|
+
def self.sun_speed(jd, step = 0.05)
|
|
88
|
+
diff = angle_delta(
|
|
89
|
+
Ephemeris.calc_ut(jd + step, SUN_ID) - Ephemeris.calc_ut(jd - step, SUN_ID)
|
|
90
|
+
)
|
|
91
|
+
diff / (2.0 * step)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Signed shortest angular difference, mapped into [-180, 180).
|
|
95
|
+
def self.angle_delta(deg)
|
|
96
|
+
(deg + 540.0) % 360.0 - 180.0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Inverse Julian Day (Meeus, Astronomical Algorithms ch. 7):
|
|
100
|
+
# JD(UT) -> [year, month, day, hour, minute, second] in UTC.
|
|
101
|
+
# Rounded to the nearest whole second before decomposition, so
|
|
102
|
+
# 23:59:59.6 rolls over to 00:00:00 of the next day correctly.
|
|
103
|
+
def self.jd_to_utc(jd)
|
|
104
|
+
total_seconds = ((jd + 0.5) * 86_400.0).round
|
|
105
|
+
z = total_seconds / 86_400
|
|
106
|
+
sec = total_seconds % 86_400
|
|
107
|
+
|
|
108
|
+
if z < 2_299_161 # before the Gregorian reform (1582-10-15)
|
|
109
|
+
a = z
|
|
110
|
+
else
|
|
111
|
+
alpha = ((z - 1_867_216.25) / 36_524.25).floor
|
|
112
|
+
a = z + 1 + alpha - (alpha / 4)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
b = a + 1524
|
|
116
|
+
c = ((b - 122.1) / 365.25).floor
|
|
117
|
+
d = (365.25 * c).floor
|
|
118
|
+
e = ((b - d) / 30.6001).floor
|
|
119
|
+
|
|
120
|
+
day = b - d - (30.6001 * e).floor
|
|
121
|
+
month = e < 14 ? e - 1 : e - 13
|
|
122
|
+
year = month > 2 ? c - 4716 : c - 4715
|
|
123
|
+
|
|
124
|
+
[year, month, day, sec / 3600, (sec % 3600) / 60, sec % 60]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.jd_to_utc_iso8601(jd)
|
|
128
|
+
y, mo, d, h, mi, s = jd_to_utc(jd)
|
|
129
|
+
format("%04d-%02d-%02dT%02d:%02d:%02dZ", y, mo, d, h, mi, s)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Chart structure at an exact JD — mirrors the "chart" section of
|
|
133
|
+
# Chart#generate, computed directly with Planets/Houses (Chart itself
|
|
134
|
+
# only accepts date strings, which would lose sub-minute precision).
|
|
135
|
+
def self.build_chart_at(jd, latitude, longitude)
|
|
136
|
+
cusps, ascendant = Houses.calculate(jd, latitude, longitude)
|
|
137
|
+
|
|
138
|
+
positions = Planets.calculate_positions(jd)
|
|
139
|
+
planet_details = Planets.build_details(positions, cusps)
|
|
140
|
+
|
|
141
|
+
kp = Planets.key_points_data(positions, cusps, ascendant)
|
|
142
|
+
|
|
143
|
+
planet_details.each do |planet|
|
|
144
|
+
key = ASPECT_MAP[planet["planet"]]
|
|
145
|
+
planet["aspects"] = kp[key] if key
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
planet_details.concat(kp["additional_points"])
|
|
149
|
+
|
|
150
|
+
houses_data = cusps.each_with_index.map do |deg, i|
|
|
151
|
+
{
|
|
152
|
+
"house_number" => i + 1,
|
|
153
|
+
"degree" => deg.round(4),
|
|
154
|
+
"zodiac" => Zodiac.sign_name(deg),
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
"ascendant" => {
|
|
160
|
+
"zodiac" => Zodiac.sign_name(ascendant),
|
|
161
|
+
"degree" => (ascendant % 30).round(4),
|
|
162
|
+
"total_degree" => ascendant.round(4),
|
|
163
|
+
},
|
|
164
|
+
"planets" => planet_details,
|
|
165
|
+
"houses" => houses_data,
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Natal Sun total longitude from a Chart#generate hash.
|
|
170
|
+
def self.natal_sun_degree(chart)
|
|
171
|
+
planets = chart&.dig("chart", "planets")
|
|
172
|
+
raise ArgumentError, "chart has no planets data" if planets.nil? || planets.empty?
|
|
173
|
+
|
|
174
|
+
sun = planets.find { |p| p["planet"] == "太陽" }
|
|
175
|
+
raise ArgumentError, "chart has no 太陽 position" if sun.nil? || sun["total_degree"].nil?
|
|
176
|
+
|
|
177
|
+
sun["total_degree"]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# [month, day] of the natal birthday from a Chart#generate hash.
|
|
181
|
+
def self.natal_month_day(chart)
|
|
182
|
+
birth_date = chart&.dig("input", "birth_date")
|
|
183
|
+
raise ArgumentError, "chart has no input birth_date" if birth_date.nil?
|
|
184
|
+
|
|
185
|
+
_, month, day = birth_date.split("-").map(&:to_i)
|
|
186
|
+
raise ArgumentError, "invalid birth_date: #{birth_date.inspect}" if month.nil? || day.nil?
|
|
187
|
+
|
|
188
|
+
[month, day]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private_class_method :natal_sun_degree, :natal_month_day
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module AstroChart
|
|
2
|
+
# Element (四大元素) and modality (三大模式) distribution statistics.
|
|
3
|
+
#
|
|
4
|
+
# Both classifications follow directly from the zodiac sign a body
|
|
5
|
+
# occupies. Starting from 牡羊座, elements repeat every 4 signs
|
|
6
|
+
# (火土風水) and modalities every 3 signs (基本固定變動):
|
|
7
|
+
#
|
|
8
|
+
# 牡羊=火基本 金牛=土固定 雙子=風變動 巨蟹=水基本
|
|
9
|
+
# 獅子=火固定 處女=土變動 天秤=風基本 天蠍=水固定
|
|
10
|
+
# 射手=火變動 摩羯=土基本 水瓶=風固定 雙魚=水變動
|
|
11
|
+
module Stats
|
|
12
|
+
ELEMENTS = ["火", "土", "風", "水"].freeze
|
|
13
|
+
MODALITIES = ["基本", "固定", "變動"].freeze
|
|
14
|
+
|
|
15
|
+
# positions: { "太陽" => 123.45, ... } — the 10 classical planets
|
|
16
|
+
# (太陽..冥王星); the caller is responsible for passing only those.
|
|
17
|
+
#
|
|
18
|
+
# Returns:
|
|
19
|
+
# { "elements" => { "火" => n, "土" => n, "風" => n, "水" => n },
|
|
20
|
+
# "modalities" => { "基本" => n, "固定" => n, "變動" => n } }
|
|
21
|
+
def self.elements(positions)
|
|
22
|
+
element_counts = ELEMENTS.to_h { |e| [e, 0] }
|
|
23
|
+
modality_counts = MODALITIES.to_h { |m| [m, 0] }
|
|
24
|
+
|
|
25
|
+
positions.each_value do |degree|
|
|
26
|
+
sign_index = (degree % 360).floor / 30
|
|
27
|
+
element_counts[ELEMENTS[sign_index % 4]] += 1
|
|
28
|
+
modality_counts[MODALITIES[sign_index % 3]] += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
{ "elements" => element_counts, "modalities" => modality_counts }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module AstroChart
|
|
2
|
+
# Synastry (合盤): cross-chart comparison between two natal charts.
|
|
3
|
+
#
|
|
4
|
+
# Pure composition of existing modules — Aspects.calculate works on any two
|
|
5
|
+
# ecliptic longitudes regardless of which chart they come from, and
|
|
6
|
+
# Houses.find_house places any longitude into any set of cusps.
|
|
7
|
+
# Backend-independent: no ephemeris call happens here.
|
|
8
|
+
#
|
|
9
|
+
# Typical usage with two Chart#generate results:
|
|
10
|
+
#
|
|
11
|
+
# result = Synastry.between(chart_a, chart_b)
|
|
12
|
+
# result["aspects"] # A 的行星 × B 的行星 的跨盤相位
|
|
13
|
+
# result["a_planets_in_b_houses"] # A 的行星落在 B 的哪一宮(疊盤)
|
|
14
|
+
# result["b_planets_in_a_houses"] # B 的行星落在 A 的哪一宮
|
|
15
|
+
module Synastry
|
|
16
|
+
# Bodies used for cross-chart comparison.
|
|
17
|
+
# 南交點 is excluded by default: it is always exactly opposite 北交點,
|
|
18
|
+
# so including it would mirror every node aspect and double the noise.
|
|
19
|
+
BODIES = Ephemeris::PLANETS.keys.freeze
|
|
20
|
+
|
|
21
|
+
# Cross aspects between two position sets.
|
|
22
|
+
#
|
|
23
|
+
# positions_a / positions_b: { "太陽" => 123.45, ... } (ecliptic longitudes)
|
|
24
|
+
# orb_limit: optional Float — keep only aspects with orb <= limit.
|
|
25
|
+
# (Aspects uses natal orbs: conjunction 15°, others 6-10°. Synastry
|
|
26
|
+
# practice often uses tighter orbs; pass e.g. orb_limit: 6.0 to tighten.)
|
|
27
|
+
#
|
|
28
|
+
# Returns Array of:
|
|
29
|
+
# { "a_planet" => "太陽", "b_planet" => "月亮",
|
|
30
|
+
# "aspect_type" => "三分相", "orb" => 1.23 }
|
|
31
|
+
#
|
|
32
|
+
# Note: (A太陽, B月亮) and (A月亮, B太陽) are different pairs —
|
|
33
|
+
# they compare different positions, both are kept.
|
|
34
|
+
def self.cross_aspects(positions_a, positions_b, orb_limit: nil)
|
|
35
|
+
results = []
|
|
36
|
+
positions_a.each do |name_a, pos_a|
|
|
37
|
+
positions_b.each do |name_b, pos_b|
|
|
38
|
+
aspect_type, orb = Aspects.calculate(pos_a, pos_b)
|
|
39
|
+
next if aspect_type.nil?
|
|
40
|
+
next if orb_limit && orb > orb_limit
|
|
41
|
+
|
|
42
|
+
results << {
|
|
43
|
+
"a_planet" => name_a,
|
|
44
|
+
"b_planet" => name_b,
|
|
45
|
+
"aspect_type" => aspect_type,
|
|
46
|
+
"orb" => orb,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
results.sort_by { |r| r["orb"] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# House overlay (疊盤): place one person's planets into the other
|
|
54
|
+
# person's houses.
|
|
55
|
+
#
|
|
56
|
+
# positions: { "太陽" => 123.45, ... }
|
|
57
|
+
# cusps: 12 house cusp degrees (cusps[0] = 1st house)
|
|
58
|
+
#
|
|
59
|
+
# Returns { "太陽" => 7, ... } (house number 1-12)
|
|
60
|
+
def self.house_overlay(positions, cusps)
|
|
61
|
+
positions.each_with_object({}) do |(name, pos), out|
|
|
62
|
+
house = Houses.find_house(pos, cusps)
|
|
63
|
+
out[name] = house if house
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Full synastry between two Chart#generate result hashes.
|
|
68
|
+
def self.between(chart_a, chart_b, orb_limit: nil)
|
|
69
|
+
pos_a = positions_from_chart(chart_a)
|
|
70
|
+
pos_b = positions_from_chart(chart_b)
|
|
71
|
+
cusps_a = cusps_from_chart(chart_a)
|
|
72
|
+
cusps_b = cusps_from_chart(chart_b)
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
"aspects" => cross_aspects(pos_a, pos_b, orb_limit: orb_limit),
|
|
76
|
+
"a_planets_in_b_houses" => house_overlay(pos_a, cusps_b),
|
|
77
|
+
"b_planets_in_a_houses" => house_overlay(pos_b, cusps_a),
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Extract { name => total_degree } for BODIES from a Chart#generate hash.
|
|
82
|
+
# Ruler points appended by key_points_data are ignored (not in BODIES).
|
|
83
|
+
def self.positions_from_chart(chart)
|
|
84
|
+
planets = chart&.dig("chart", "planets")
|
|
85
|
+
raise ArgumentError, "chart has no planets data" if planets.nil? || planets.empty?
|
|
86
|
+
|
|
87
|
+
planets.each_with_object({}) do |p, out|
|
|
88
|
+
name = p["planet"]
|
|
89
|
+
next unless BODIES.include?(name)
|
|
90
|
+
|
|
91
|
+
degree = p["total_degree"] || total_degree_from(p)
|
|
92
|
+
out[name] = degree if degree
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Extract the 12 cusp degrees (index 0 = 1st house) from a Chart#generate hash.
|
|
97
|
+
def self.cusps_from_chart(chart)
|
|
98
|
+
houses = chart&.dig("chart", "houses")
|
|
99
|
+
raise ArgumentError, "chart has no houses data" if houses.nil? || houses.length != 12
|
|
100
|
+
|
|
101
|
+
houses.sort_by { |h| h["house_number"] }.map { |h| h["degree"] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Fallback for older snapshots that only stored zodiac + in-sign degree.
|
|
105
|
+
def self.total_degree_from(planet)
|
|
106
|
+
zodiac = planet["zodiac"]
|
|
107
|
+
degree = planet["degree"]
|
|
108
|
+
return nil if zodiac.nil? || degree.nil?
|
|
109
|
+
|
|
110
|
+
sign_index = Zodiac::SIGNS.index(zodiac)
|
|
111
|
+
return nil if sign_index.nil?
|
|
112
|
+
|
|
113
|
+
sign_index * 30.0 + degree
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method :total_degree_from
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require_relative "ephemeris"
|
|
2
|
+
require_relative "aspects"
|
|
3
|
+
require_relative "zodiac"
|
|
4
|
+
require_relative "synastry"
|
|
5
|
+
require_relative "solar_return"
|
|
6
|
+
|
|
7
|
+
module AstroChart
|
|
8
|
+
# Transit timing (行運精確時點): the exact UTC instants, within a date range,
|
|
9
|
+
# when a transiting body forms an exact aspect to a natal point.
|
|
10
|
+
#
|
|
11
|
+
# Where Transits.against is a single snapshot ("what aspects hold right
|
|
12
|
+
# now?"), this answers the question people actually ask — "*when* does
|
|
13
|
+
# transiting 土星 exactly square my natal 太陽?".
|
|
14
|
+
#
|
|
15
|
+
# natal = AstroChart::Chart.new(...).generate
|
|
16
|
+
# events = AstroChart::TransitTiming.events(natal, "2026-01-01", "2026-12-31")
|
|
17
|
+
# events.first
|
|
18
|
+
# #=> { "transit_planet" => "土星", "natal_planet" => "太陽",
|
|
19
|
+
# # "aspect_type" => "四分相", "jd" => 2461...,
|
|
20
|
+
# # "time_utc" => "2026-03-14T07:22:10Z", "transit_zodiac" => "牡羊座",
|
|
21
|
+
# # "retrograde" => false }
|
|
22
|
+
#
|
|
23
|
+
# Method: each transiting body's longitude is sampled across the range at
|
|
24
|
+
# `step_days`; for every natal point and aspect angle the signed separation
|
|
25
|
+
# is bracketed where it crosses exactness, then refined by bisection (robust
|
|
26
|
+
# through retrograde stations, unlike a pure Newton step). A step where the
|
|
27
|
+
# body moves < 90° guarantees no aliasing — 1 day is safe for all bodies
|
|
28
|
+
# (the Moon moves ~13°/day).
|
|
29
|
+
module TransitTiming
|
|
30
|
+
# Transiting bodies considered (12-body set minus 南交點, mirroring
|
|
31
|
+
# Synastry/Transits — a south-node hit merely mirrors a north-node one).
|
|
32
|
+
BODIES = Synastry::BODIES
|
|
33
|
+
|
|
34
|
+
# Major aspect angles. Each non-zero, non-opposition aspect is exact at two
|
|
35
|
+
# signed separations (applying from either side); 合相/對分相 have one.
|
|
36
|
+
MAJOR = { "合相" => 0, "六分相" => 60, "四分相" => 90, "三分相" => 120, "對分相" => 180 }.freeze
|
|
37
|
+
MINOR = { "十二分相" => 30, "半四分相" => 45, "補八分相" => 135, "補十二分相" => 150 }.freeze
|
|
38
|
+
|
|
39
|
+
CONVERGENCE_DAYS = 1e-6 # ~0.1 second of clock time
|
|
40
|
+
MAX_BISECT = 60
|
|
41
|
+
|
|
42
|
+
# natal_chart: a Chart#generate result hash.
|
|
43
|
+
# start_date / end_date: "YYYY-MM-DD" (interpreted at 00:00 UT).
|
|
44
|
+
# minor: also time the minor aspects (30/45/135/150).
|
|
45
|
+
# step_days: sampling stride (default 1.0 — safe for every body).
|
|
46
|
+
# bodies: restrict transiting bodies (default all BODIES; e.g. drop "月亮"
|
|
47
|
+
# to avoid the Moon's ~monthly hits flooding the list).
|
|
48
|
+
#
|
|
49
|
+
# Returns an Array of event hashes sorted by time (jd ascending).
|
|
50
|
+
def self.events(natal_chart, start_date, end_date, minor: false,
|
|
51
|
+
step_days: 1.0, bodies: nil)
|
|
52
|
+
natal = Synastry.positions_from_chart(natal_chart)
|
|
53
|
+
moving = (bodies || BODIES).select { |b| Ephemeris::PLANETS.key?(b) }
|
|
54
|
+
targets = signed_targets(minor)
|
|
55
|
+
|
|
56
|
+
jd_start = date_to_jd(start_date)
|
|
57
|
+
jd_end = date_to_jd(end_date)
|
|
58
|
+
|
|
59
|
+
events = []
|
|
60
|
+
moving.each do |body|
|
|
61
|
+
id = Ephemeris::PLANETS[body]
|
|
62
|
+
# Sample this body's longitude ONCE across the grid, then reuse the
|
|
63
|
+
# cached samples for every natal point × aspect target (calc_ut is the
|
|
64
|
+
# cost; the scan phase does zero extra ephemeris calls, only bisection
|
|
65
|
+
# refinement recomputes).
|
|
66
|
+
grid = sample_grid(id, jd_start, jd_end, step_days)
|
|
67
|
+
|
|
68
|
+
natal.each do |n_name, n_pos|
|
|
69
|
+
targets.each do |aspect_type, sep|
|
|
70
|
+
scan_grid(grid, n_pos, sep).each do |jd0, h0, jd1, h1|
|
|
71
|
+
jd = bisect(id, n_pos, sep, jd0, h0, jd1, h1)
|
|
72
|
+
events << build_event(body, id, n_name, aspect_type, jd)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
dedupe(events).sort_by { |e| e["jd"] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# [[jd, lon], ...] samples of one body from jd_start to jd_end (inclusive).
|
|
82
|
+
def self.sample_grid(id, jd_start, jd_end, step)
|
|
83
|
+
grid = []
|
|
84
|
+
jd = jd_start
|
|
85
|
+
loop do
|
|
86
|
+
jd = jd_end if jd > jd_end
|
|
87
|
+
grid << [jd, Ephemeris.calc_ut(jd, id)]
|
|
88
|
+
break if jd >= jd_end
|
|
89
|
+
|
|
90
|
+
jd += step
|
|
91
|
+
end
|
|
92
|
+
grid
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Brackets where the signed separation crosses exactness, from cached
|
|
96
|
+
# longitudes. Returns [[jd0, h0, jd1, h1], ...] for bisection.
|
|
97
|
+
def self.scan_grid(grid, natal_pos, sep)
|
|
98
|
+
brackets = []
|
|
99
|
+
grid.each_cons(2) do |(jd0, l0), (jd1, l1)|
|
|
100
|
+
h0 = SolarReturn.angle_delta(l0 - natal_pos - sep)
|
|
101
|
+
h1 = SolarReturn.angle_delta(l1 - natal_pos - sep)
|
|
102
|
+
# A sign change with both endpoints near the target (|h| < 90°) is a
|
|
103
|
+
# real crossing; the |h| guard rejects the ±180° wrap discontinuity.
|
|
104
|
+
next unless h0 != 0 && (h0 <=> 0) != (h1 <=> 0) && h0.abs < 90 && h1.abs < 90
|
|
105
|
+
|
|
106
|
+
brackets << [jd0, h0, jd1, h1]
|
|
107
|
+
end
|
|
108
|
+
brackets
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Signed separations at which each aspect is exact. 合相 (0) and 對分相
|
|
112
|
+
# (180) have a single point; the rest have ±angle.
|
|
113
|
+
def self.signed_targets(minor)
|
|
114
|
+
table = minor ? MAJOR.merge(MINOR) : MAJOR
|
|
115
|
+
table.flat_map do |name, angle|
|
|
116
|
+
if angle.zero? || angle == 180
|
|
117
|
+
[[name, angle]]
|
|
118
|
+
else
|
|
119
|
+
[[name, angle], [name, -angle]]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.bisect(id, natal_pos, sep, a, ha, b, _hb)
|
|
125
|
+
MAX_BISECT.times do
|
|
126
|
+
break if (b - a) < CONVERGENCE_DAYS
|
|
127
|
+
|
|
128
|
+
mid = (a + b) / 2.0
|
|
129
|
+
hm = separation(id, mid, natal_pos, sep)
|
|
130
|
+
if hm == 0
|
|
131
|
+
return mid
|
|
132
|
+
elsif (ha <=> 0) != (hm <=> 0)
|
|
133
|
+
b = mid
|
|
134
|
+
else
|
|
135
|
+
a = mid
|
|
136
|
+
ha = hm
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
(a + b) / 2.0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Signed distance (deg, [-180,180)) from exactness of this aspect.
|
|
143
|
+
def self.separation(id, jd, natal_pos, sep)
|
|
144
|
+
SolarReturn.angle_delta(Ephemeris.calc_ut(jd, id) - natal_pos - sep)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.build_event(body, id, natal_name, aspect_type, jd)
|
|
148
|
+
lon = Ephemeris.calc_ut(jd, id)
|
|
149
|
+
{
|
|
150
|
+
"transit_planet" => body,
|
|
151
|
+
"natal_planet" => natal_name,
|
|
152
|
+
"aspect_type" => aspect_type,
|
|
153
|
+
"jd" => jd,
|
|
154
|
+
"time_utc" => SolarReturn.jd_to_utc_iso8601(jd),
|
|
155
|
+
"transit_zodiac" => Zodiac.sign_name(lon),
|
|
156
|
+
"retrograde" => never_retrograde?(body) ? false : Ephemeris.retrograde?(jd, id),
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.never_retrograde?(body)
|
|
161
|
+
body == "太陽" || body == "月亮"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Two roots for the same contact found at adjacent brackets (a root landing
|
|
165
|
+
# on a sample boundary) collapse to one.
|
|
166
|
+
def self.dedupe(events)
|
|
167
|
+
events
|
|
168
|
+
.group_by { |e| [e["transit_planet"], e["natal_planet"], e["aspect_type"]] }
|
|
169
|
+
.flat_map do |_key, group|
|
|
170
|
+
group.sort_by { |e| e["jd"] }.chunk_while { |a, b| (b["jd"] - a["jd"]) < 1e-3 }
|
|
171
|
+
.map(&:first)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.date_to_jd(date)
|
|
176
|
+
y, m, d = date.split("-").map(&:to_i)
|
|
177
|
+
raise ArgumentError, "invalid date: #{date.inspect}" if y.nil? || m.nil? || d.nil?
|
|
178
|
+
|
|
179
|
+
Ephemeris.julday(y, m, d, 0.0)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private_class_method :signed_targets, :sample_grid, :scan_grid, :bisect,
|
|
183
|
+
:separation, :build_event, :never_retrograde?,
|
|
184
|
+
:dedupe, :date_to_jd
|
|
185
|
+
end
|
|
186
|
+
end
|