astro_chart 0.2.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 +97 -0
- data/README.md +417 -0
- data/astro_chart.gemspec +8 -4
- 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 +78 -29
- 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/houses.rb +91 -6
- data/lib/astro_chart/pure.rb +11 -6
- 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/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 +13 -0
- metadata +24 -7
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require_relative "time_conversion"
|
|
2
|
+
require_relative "transits"
|
|
3
|
+
|
|
4
|
+
module AstroChart
|
|
5
|
+
# Secondary progressions (二次推運): "a day for a year".
|
|
6
|
+
#
|
|
7
|
+
# For a target date N years after birth, the progressed chart is the sky
|
|
8
|
+
# N *days* after birth: jd_prog = jd_natal + years_elapsed, where
|
|
9
|
+
# years_elapsed = (jd_target - jd_natal) / 365.2425.
|
|
10
|
+
#
|
|
11
|
+
# Typical usage with a Chart#generate result:
|
|
12
|
+
#
|
|
13
|
+
# AstroChart::Progressions.secondary(natal_chart, "2026-07-24")
|
|
14
|
+
module Progressions
|
|
15
|
+
# Mean Gregorian year length in days (matches the calendar's 400-year cycle).
|
|
16
|
+
DAYS_PER_YEAR = 365.2425
|
|
17
|
+
|
|
18
|
+
# Secondary-progressed positions for a natal chart at a target date.
|
|
19
|
+
#
|
|
20
|
+
# natal_chart: a Chart#generate result hash (its "input" block supplies
|
|
21
|
+
# birth date/time/timezone, so jd_natal is reconstructed exactly).
|
|
22
|
+
# target_date: "YYYY-MM-DD". The target moment is taken at the same
|
|
23
|
+
# local birth time + timezone, so whole calendar years elapse cleanly.
|
|
24
|
+
# orb_limit: keep only progressed-to-natal aspects with orb <= limit
|
|
25
|
+
# (default 1.0 — progressions move slowly, only exact contacts matter).
|
|
26
|
+
#
|
|
27
|
+
# Returns:
|
|
28
|
+
# {
|
|
29
|
+
# "progressed_jd" => 2447926.72,
|
|
30
|
+
# "years_elapsed" => 30.0,
|
|
31
|
+
# "planets" => [ { "planet" => "太陽", "zodiac" => "獅子座",
|
|
32
|
+
# "degree" => 9.87, "total_degree" => 129.87,
|
|
33
|
+
# "natal_house" => 3 }, ... 12 entries ],
|
|
34
|
+
# "aspects_to_natal" => [ { "progressed_planet" => "月亮",
|
|
35
|
+
# "natal_planet" => "金星",
|
|
36
|
+
# "aspect_type" => "三分相", "orb" => 0.4 }, ... ]
|
|
37
|
+
# }
|
|
38
|
+
#
|
|
39
|
+
# aspects_to_natal are sorted by orb (tightest first).
|
|
40
|
+
def self.secondary(natal_chart, target_date, orb_limit: 1.0)
|
|
41
|
+
input = natal_chart&.dig("input")
|
|
42
|
+
if input.nil? || input["birth_date"].nil? || input["birth_time"].nil? || input["timezone"].nil?
|
|
43
|
+
raise ArgumentError, "chart has no input data (birth_date/birth_time/timezone required)"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
jd_natal = TimeConversion.to_julian_day(input["birth_date"], input["birth_time"], input["timezone"])
|
|
47
|
+
jd_target = TimeConversion.to_julian_day(target_date, input["birth_time"], input["timezone"])
|
|
48
|
+
|
|
49
|
+
years_elapsed = (jd_target - jd_natal) / DAYS_PER_YEAR
|
|
50
|
+
jd_prog = jd_natal + years_elapsed # a day for a year
|
|
51
|
+
|
|
52
|
+
progressed_positions = Planets.calculate_positions(jd_prog)
|
|
53
|
+
natal_positions = Synastry.positions_from_chart(natal_chart)
|
|
54
|
+
natal_cusps = Synastry.cusps_from_chart(natal_chart)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
"progressed_jd" => jd_prog,
|
|
58
|
+
"years_elapsed" => years_elapsed.round(2),
|
|
59
|
+
"planets" => Transits.planet_details(progressed_positions, natal_cusps),
|
|
60
|
+
"aspects_to_natal" => Transits.aspects_to_natal(
|
|
61
|
+
progressed_positions, natal_positions,
|
|
62
|
+
orb_limit: orb_limit,
|
|
63
|
+
keys: %w[progressed_planet natal_planet]
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -13,12 +13,18 @@ module AstroChart
|
|
|
13
13
|
# 宮頭 11/12 位於 RAMC 東側 1/3、2/3 半日弧處;宮頭 2/3 以半夜弧對應)
|
|
14
14
|
#
|
|
15
15
|
# 公開 API:
|
|
16
|
-
# Houses.calc(jd_ut, lat, lon)
|
|
16
|
+
# Houses.calc(jd_ut, lat, lon, hsys = "P")
|
|
17
17
|
# -> { "cusps" => [12 floats], "ascendant" => Float, "mc" => Float }
|
|
18
|
-
# 欄位與 AstroChart::Ext.houses(jd, lat, lon,
|
|
18
|
+
# 欄位與 AstroChart::Ext.houses(jd, lat, lon, hsys.ord) 相同。
|
|
19
19
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
20
|
+
# 宮位制:
|
|
21
|
+
# "P" — Placidus(預設)。適用範圍 |lat| < ~66°(極圈內無定義),
|
|
22
|
+
# 超出範圍或迭代未收斂時 raise Core::DomainError(顯性失敗,不靜默 clamp)。
|
|
23
|
+
# "W" — 整宮制(Whole Sign)。宮頭 1 = ASC 所在星座起點(floor(asc/30)*30),
|
|
24
|
+
# 其後每宮 +30°。ASC / MC 計算與 Placidus 相同(整宮制只改宮頭),
|
|
25
|
+
# 唯極圈內(|lat| > 90° − ε ≈ 66.56°)另套用東昇點修正(見
|
|
26
|
+
# east_ascendant)——標準公式在該區可能傳回差 180° 的西方交點。
|
|
27
|
+
# 於 |lat| < 90° 皆有定義(lat = ±90° 時 tanφ 發散,ASC 本身無定義)。
|
|
22
28
|
module Houses
|
|
23
29
|
DEG2RAD = Core::DEG2RAD
|
|
24
30
|
RAD2DEG = Core::RAD2DEG
|
|
@@ -26,7 +32,8 @@ module AstroChart
|
|
|
26
32
|
module_function
|
|
27
33
|
|
|
28
34
|
# jd_ut: UT 儒略日;lat: 地理緯度(北正);lon: 地理經度(東正)
|
|
29
|
-
|
|
35
|
+
# hsys: "P"(Placidus,預設)或 "W"(整宮制)
|
|
36
|
+
def calc(jd_ut, lat, lon, hsys = "P")
|
|
30
37
|
tt = Core.jd_tt(jd_ut)
|
|
31
38
|
eps = Core.true_obliquity(tt) * DEG2RAD # 真黃赤交角(弧度)
|
|
32
39
|
ast_deg = Core.apparent_sidereal_deg(jd_ut) # 視恆星時(度)
|
|
@@ -36,6 +43,33 @@ module AstroChart
|
|
|
36
43
|
mc = mc_longitude(armc, eps)
|
|
37
44
|
asc = asc_longitude(armc, eps, phi)
|
|
38
45
|
|
|
46
|
+
cusps =
|
|
47
|
+
case hsys
|
|
48
|
+
when "W"
|
|
49
|
+
# 極圈內(|lat| > 90° − ε ≈ 66.56°)標準 atan2 公式可能傳回西方
|
|
50
|
+
# 交點(差 180°);整宮制在極圈內仍有定義,故此處修正為東昇點。
|
|
51
|
+
# 極圈外此修正恆為 no-op;Placidus 路徑不套用(維持原輸出)。
|
|
52
|
+
asc = east_ascendant(asc, mc)
|
|
53
|
+
whole_sign_cusps(asc)
|
|
54
|
+
when "E"
|
|
55
|
+
# 等宮制:宮頭 1 = ASC,其後每宮 +30°。與整宮制同樣在 |lat| < 90°
|
|
56
|
+
# 皆有定義,故一併套用東昇點修正。
|
|
57
|
+
asc = east_ascendant(asc, mc)
|
|
58
|
+
equal_cusps(asc)
|
|
59
|
+
when "O"
|
|
60
|
+
# Porphyry:四大軸(ASC/IC/DSC/MC)之間的黃道弧各三等分。純幾何,
|
|
61
|
+
# 不需半弧迭代,於 |lat| < 90° 皆有定義。
|
|
62
|
+
asc = east_ascendant(asc, mc)
|
|
63
|
+
porphyry_cusps(asc, mc)
|
|
64
|
+
else
|
|
65
|
+
placidus_cusps(armc, eps, phi, asc, mc)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{ "cusps" => cusps, "ascendant" => asc, "mc" => mc }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- Placidus 12 宮頭 ---
|
|
72
|
+
def placidus_cusps(armc, eps, phi, asc, mc)
|
|
39
73
|
# Placidus 半弧迭代:宮頭 11、12(地平上,RAMC 東側)與 2、3(地平下)
|
|
40
74
|
c11 = placidus_cusp(armc, eps, phi, 30.0, 1.0 / 3.0, :diurnal)
|
|
41
75
|
c12 = placidus_cusp(armc, eps, phi, 60.0, 2.0 / 3.0, :diurnal)
|
|
@@ -55,8 +89,59 @@ module AstroChart
|
|
|
55
89
|
cusps[9] = mc # 第 10 宮 = MC
|
|
56
90
|
cusps[10] = c11
|
|
57
91
|
cusps[11] = c12
|
|
92
|
+
cusps
|
|
93
|
+
end
|
|
58
94
|
|
|
59
|
-
|
|
95
|
+
# --- 東昇點修正 ---
|
|
96
|
+
# ASC 依定義位於 MC 東側半圓:(asc − mc) mod 360 ∈ (0°, 180°)。
|
|
97
|
+
# 極圈內原始公式可能落在西側半圓(實為降點),此時加 180°。
|
|
98
|
+
# 與 Swiss Ephemeris 極區行為一致(tmp/explore_polar_asc.rb 驗證 400 例全符)。
|
|
99
|
+
def east_ascendant(asc, mc)
|
|
100
|
+
((asc - mc) % 360.0) < 180.0 ? asc : Core.norm360(asc + 180.0)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- 整宮制 12 宮頭 ---
|
|
104
|
+
# 宮頭 1 = ASC 所在星座 0°,其後每宮 +30°(模 360)。
|
|
105
|
+
def whole_sign_cusps(asc)
|
|
106
|
+
base = (asc / 30.0).floor * 30.0
|
|
107
|
+
Array.new(12) { |i| (base + 30.0 * i) % 360.0 }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- 等宮制 12 宮頭 ---
|
|
111
|
+
# 宮頭 1 = ASC 本身,其後每宮 +30°(模 360)。MC 仍照常算出並回傳,
|
|
112
|
+
# 但等宮制的第 10 宮頭為 ASC+270°,不等於 MC(MC 在宮內浮動)。
|
|
113
|
+
def equal_cusps(asc)
|
|
114
|
+
Array.new(12) { |i| (asc + 30.0 * i) % 360.0 }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- Porphyry 12 宮頭 ---
|
|
118
|
+
# 依黃道經度遞增,四大軸順序為 ASC(1) → IC(4) → DSC(7) → MC(10)。每個
|
|
119
|
+
# 象限的黃道弧三等分,得中間兩個宮頭:
|
|
120
|
+
# 1→4:ASC..IC 弧 → 宮頭 2、3 4→7:IC..DSC 弧 → 宮頭 5、6
|
|
121
|
+
# 7→10:DSC..MC 弧 → 宮頭 8、9 10→1:MC..ASC 弧 → 宮頭 11、12
|
|
122
|
+
def porphyry_cusps(asc, mc)
|
|
123
|
+
ic = Core.norm360(mc + 180.0) # 第 4 宮 = MC 對沖
|
|
124
|
+
dsc = Core.norm360(asc + 180.0) # 第 7 宮 = ASC 對沖
|
|
125
|
+
|
|
126
|
+
q1 = (ic - asc) % 360.0 # ASC → IC
|
|
127
|
+
q2 = (dsc - ic) % 360.0 # IC → DSC
|
|
128
|
+
q3 = (mc - dsc) % 360.0 # DSC → MC
|
|
129
|
+
q4 = (asc - mc) % 360.0 # MC → ASC
|
|
130
|
+
|
|
131
|
+
cusps = Array.new(12)
|
|
132
|
+
cusps[0] = asc
|
|
133
|
+
cusps[1] = Core.norm360(asc + q1 / 3.0)
|
|
134
|
+
cusps[2] = Core.norm360(asc + 2.0 * q1 / 3.0)
|
|
135
|
+
cusps[3] = ic
|
|
136
|
+
cusps[4] = Core.norm360(ic + q2 / 3.0)
|
|
137
|
+
cusps[5] = Core.norm360(ic + 2.0 * q2 / 3.0)
|
|
138
|
+
cusps[6] = dsc
|
|
139
|
+
cusps[7] = Core.norm360(dsc + q3 / 3.0)
|
|
140
|
+
cusps[8] = Core.norm360(dsc + 2.0 * q3 / 3.0)
|
|
141
|
+
cusps[9] = mc
|
|
142
|
+
cusps[10] = Core.norm360(mc + q4 / 3.0)
|
|
143
|
+
cusps[11] = Core.norm360(mc + 2.0 * q4 / 3.0)
|
|
144
|
+
cusps
|
|
60
145
|
end
|
|
61
146
|
|
|
62
147
|
# --- MC:天頂赤經 → 黃道經度 ---
|
data/lib/astro_chart/pure.rb
CHANGED
|
@@ -49,15 +49,20 @@ module AstroChart
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# House cusps + ascendant + MC.
|
|
53
|
-
# hsys accepts "P" or 80 (
|
|
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
54
|
def houses(jd_ut, latitude, longitude, hsys = "P")
|
|
55
|
-
|
|
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
|
|
56
61
|
raise ArgumentError,
|
|
57
|
-
"unsupported house system #{hsys.inspect}
|
|
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)"
|
|
58
65
|
end
|
|
59
|
-
|
|
60
|
-
Houses.calc(jd_ut, latitude, longitude)
|
|
61
66
|
end
|
|
62
67
|
end
|
|
63
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
|