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
|
@@ -3,45 +3,28 @@
|
|
|
3
3
|
module AstroChart
|
|
4
4
|
DEFAULT_BACKEND = :pure
|
|
5
5
|
|
|
6
|
+
# Backend selection lives on Ephemeris (not the AstroChart top-level
|
|
7
|
+
# constant) so host apps that reassign or remove the AstroChart constant
|
|
8
|
+
# (e.g. a Rails app whose ActiveRecord model claims the name after
|
|
9
|
+
# capturing the gem classes) never break internal dispatch.
|
|
10
|
+
# AstroChart.backend / backend= below stay as thin delegators for
|
|
11
|
+
# API compatibility.
|
|
6
12
|
class << self
|
|
7
13
|
# Current ephemeris backend (:pure or :swiss). Defaults to :pure.
|
|
8
14
|
def backend
|
|
9
|
-
|
|
15
|
+
Ephemeris.backend
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
# Switch the ephemeris backend.
|
|
13
19
|
#
|
|
14
20
|
# AstroChart.backend = :pure # pure-Ruby (default, no C extension needed)
|
|
15
21
|
# AstroChart.backend = :swiss # Swiss Ephemeris C extension (AGPL)
|
|
16
|
-
#
|
|
17
|
-
# Selecting :swiss loads the C extension on demand; a missing/uncompiled
|
|
18
|
-
# extension raises LoadError with an explicit message instead of failing
|
|
19
|
-
# silently at call time.
|
|
20
22
|
def backend=(name)
|
|
21
|
-
|
|
22
|
-
when :pure
|
|
23
|
-
@backend = :pure
|
|
24
|
-
when :swiss
|
|
25
|
-
load_swiss_extension!
|
|
26
|
-
@backend = :swiss
|
|
27
|
-
else
|
|
28
|
-
raise ArgumentError,
|
|
29
|
-
"unknown backend #{name.inspect} (expected :pure or :swiss)"
|
|
30
|
-
end
|
|
23
|
+
Ephemeris.backend = name
|
|
31
24
|
end
|
|
32
25
|
|
|
33
26
|
def load_swiss_extension!
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
begin
|
|
37
|
-
require_relative "astro_chart_ext"
|
|
38
|
-
rescue LoadError => e
|
|
39
|
-
raise LoadError,
|
|
40
|
-
"AstroChart :swiss backend requires the compiled Swiss Ephemeris C extension " \
|
|
41
|
-
"(astro_chart_ext). Build it with `rake compile` (or `ruby ext/astro_chart/extconf.rb && make`), " \
|
|
42
|
-
"or use the default pure-Ruby backend (AstroChart.backend = :pure). " \
|
|
43
|
-
"Original error: #{e.message}"
|
|
44
|
-
end
|
|
27
|
+
Ephemeris.load_swiss_extension!
|
|
45
28
|
end
|
|
46
29
|
end
|
|
47
30
|
|
|
@@ -49,6 +32,43 @@ module AstroChart
|
|
|
49
32
|
# module so the backend (:pure / :swiss) can be swapped without touching
|
|
50
33
|
# the rest of the code.
|
|
51
34
|
module Ephemeris
|
|
35
|
+
class << self
|
|
36
|
+
# Current ephemeris backend (:pure or :swiss). Defaults to :pure.
|
|
37
|
+
def backend
|
|
38
|
+
@backend ||= DEFAULT_BACKEND
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Selecting :swiss loads the C extension on demand; a missing/uncompiled
|
|
42
|
+
# extension raises LoadError with an explicit message instead of failing
|
|
43
|
+
# silently at call time.
|
|
44
|
+
def backend=(name)
|
|
45
|
+
case name
|
|
46
|
+
when :pure
|
|
47
|
+
@backend = :pure
|
|
48
|
+
when :swiss
|
|
49
|
+
load_swiss_extension!
|
|
50
|
+
@backend = :swiss
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"unknown backend #{name.inspect} (expected :pure or :swiss)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_swiss_extension!
|
|
58
|
+
return if defined?(Ext)
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
require_relative "astro_chart_ext"
|
|
62
|
+
rescue LoadError => e
|
|
63
|
+
raise LoadError,
|
|
64
|
+
"AstroChart :swiss backend requires the compiled Swiss Ephemeris C extension " \
|
|
65
|
+
"(astro_chart_ext). Build it with `rake compile` (or `ruby ext/astro_chart/extconf.rb && make`), " \
|
|
66
|
+
"or use the default pure-Ruby backend (AstroChart.backend = :pure). " \
|
|
67
|
+
"Original error: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
# SE-convention planet ids (numeric literals so the :pure default
|
|
53
73
|
# works without the C extension loaded; values match AstroChart::Ext
|
|
54
74
|
# constants when the extension is present).
|
|
@@ -68,7 +88,7 @@ module AstroChart
|
|
|
68
88
|
|
|
69
89
|
# Convert date/time to Julian Day number.
|
|
70
90
|
def self.julday(year, month, day, hour)
|
|
71
|
-
case
|
|
91
|
+
case backend
|
|
72
92
|
when :swiss then Ext.julday(year, month, day, hour)
|
|
73
93
|
else Pure.julday(year, month, day, hour)
|
|
74
94
|
end
|
|
@@ -76,16 +96,45 @@ module AstroChart
|
|
|
76
96
|
|
|
77
97
|
# Calculate planet apparent ecliptic longitude (degrees 0-360).
|
|
78
98
|
def self.calc_ut(jd, planet_id)
|
|
79
|
-
case
|
|
99
|
+
case backend
|
|
80
100
|
when :swiss then Ext.calc_ut(jd, planet_id)
|
|
81
101
|
else Pure.calc_ut(jd, planet_id)
|
|
82
102
|
end
|
|
83
103
|
end
|
|
84
104
|
|
|
105
|
+
# Apparent daily motion in ecliptic longitude (degrees/day), via central
|
|
106
|
+
# difference of calc_ut at jd ± 0.5 day. The difference is folded into
|
|
107
|
+
# (-180, 180] so the 0°/360° wraparound never produces a spurious value.
|
|
108
|
+
# Negative speed = retrograde motion.
|
|
109
|
+
#
|
|
110
|
+
# When jd ± 0.5 falls outside a body's valid ephemeris window (e.g. the
|
|
111
|
+
# pure Pluto series' 1885-2099 range) while jd itself is inside, the
|
|
112
|
+
# stencil falls back to a one-sided difference over half a day, so
|
|
113
|
+
# charts at the very edges of the documented range still work. A jd
|
|
114
|
+
# that is itself out of range still raises Pure::Core::DomainError.
|
|
115
|
+
def self.speed(jd, planet_id)
|
|
116
|
+
a, b, days =
|
|
117
|
+
begin
|
|
118
|
+
[calc_ut(jd - 0.5, planet_id), calc_ut(jd + 0.5, planet_id), 1.0]
|
|
119
|
+
rescue Pure::Core::DomainError
|
|
120
|
+
begin
|
|
121
|
+
[calc_ut(jd, planet_id), calc_ut(jd + 0.5, planet_id), 0.5]
|
|
122
|
+
rescue Pure::Core::DomainError
|
|
123
|
+
[calc_ut(jd - 0.5, planet_id), calc_ut(jd, planet_id), 0.5]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
(((b - a + 540.0) % 360.0) - 180.0) / days
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether the body is retrograde (moving backwards in longitude) at jd.
|
|
130
|
+
def self.retrograde?(jd, planet_id)
|
|
131
|
+
speed(jd, planet_id) < 0
|
|
132
|
+
end
|
|
133
|
+
|
|
85
134
|
# Calculate house cusps + ascendant.
|
|
86
135
|
# Returns { "cusps" => [12 floats], "ascendant" => float, "mc" => float }
|
|
87
136
|
def self.houses(jd, latitude, longitude, system = "P")
|
|
88
|
-
case
|
|
137
|
+
case backend
|
|
89
138
|
when :swiss then Ext.houses(jd, latitude, longitude, system.ord)
|
|
90
139
|
else Pure.houses(jd, latitude, longitude, system)
|
|
91
140
|
end
|
data/lib/astro_chart/houses.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module AstroChart
|
|
2
2
|
module Houses
|
|
3
3
|
# Calculate house cusps and ascendant from Julian Day + coordinates.
|
|
4
|
+
# system: "P" (Placidus, default) or "W" (Whole Sign).
|
|
4
5
|
# Returns [cusps_array(12), ascendant_degree].
|
|
5
|
-
def self.calculate(jd, latitude, longitude, system
|
|
6
|
+
def self.calculate(jd, latitude, longitude, system: "P")
|
|
6
7
|
data = Ephemeris.houses(jd, latitude, longitude, system)
|
|
7
8
|
[data["cusps"], data["ascendant"]]
|
|
8
9
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require_relative "ephemeris"
|
|
2
|
+
require_relative "solar_return"
|
|
3
|
+
|
|
4
|
+
module AstroChart
|
|
5
|
+
# Lunar return chart (月亮回歸盤): the chart cast for the exact UTC instant the
|
|
6
|
+
# transiting Moon returns to its natal longitude — a monthly (~27.32-day)
|
|
7
|
+
# cycle, the lunar analogue of the solar return.
|
|
8
|
+
#
|
|
9
|
+
# natal = AstroChart::Chart.new(...).generate
|
|
10
|
+
# result = AstroChart::LunarReturn.for_date(natal, "2026-07-24")
|
|
11
|
+
# result["return_jd"] # Julian Day (UT) of the nearest return
|
|
12
|
+
# result["return_time_utc"] # ISO8601 UTC
|
|
13
|
+
# result["chart"] # full chart at the return instant
|
|
14
|
+
#
|
|
15
|
+
# The instant is found by Newton iteration on the Moon's longitude starting
|
|
16
|
+
# from the target date, so it converges to the return nearest that date
|
|
17
|
+
# (within ~±½ cycle). Chart building and JD↔UTC reuse SolarReturn.
|
|
18
|
+
module LunarReturn
|
|
19
|
+
MOON_ID = Ephemeris::PLANETS["月亮"]
|
|
20
|
+
|
|
21
|
+
class ConvergenceError < StandardError; end
|
|
22
|
+
|
|
23
|
+
CONVERGENCE_DEG = 1e-4
|
|
24
|
+
MAX_ITERATIONS = 40 # more than the Sun: the Moon's speed varies ~11-15°/day
|
|
25
|
+
|
|
26
|
+
# natal_chart: a Chart#generate result hash. target_date: "YYYY-MM-DD" —
|
|
27
|
+
# the returned instant is the lunar return nearest this date.
|
|
28
|
+
# Location defaults to the natal coordinates; pass latitude:/longitude:
|
|
29
|
+
# (and timezone:, informational) to relocate.
|
|
30
|
+
def self.for_date(natal_chart, target_date, latitude: nil, longitude: nil, timezone: nil)
|
|
31
|
+
natal_moon = natal_moon_degree(natal_chart)
|
|
32
|
+
|
|
33
|
+
input = natal_chart["input"] || {}
|
|
34
|
+
coords = input["coordinates"] || {}
|
|
35
|
+
lat = (latitude || coords["latitude"])
|
|
36
|
+
lng = (longitude || coords["longitude"])
|
|
37
|
+
tz = (timezone || input["timezone"])
|
|
38
|
+
if lat.nil? || lng.nil?
|
|
39
|
+
raise ArgumentError, "no coordinates: natal chart input has none and none were given"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
jd = find_return_jd(natal_moon, target_date)
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
"return_jd" => jd,
|
|
46
|
+
"return_time_utc" => SolarReturn.jd_to_utc_iso8601(jd),
|
|
47
|
+
"location" => { "latitude" => lat.to_f, "longitude" => lng.to_f, "timezone" => tz },
|
|
48
|
+
"chart" => SolarReturn.build_chart_at(jd, lat.to_f, lng.to_f),
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Newton iteration on the Moon's longitude from the target date.
|
|
53
|
+
def self.find_return_jd(target_deg, target_date)
|
|
54
|
+
y, m, d = target_date.split("-").map(&:to_i)
|
|
55
|
+
raise ArgumentError, "invalid date: #{target_date.inspect}" if y.nil? || m.nil? || d.nil?
|
|
56
|
+
|
|
57
|
+
jd = Ephemeris.julday(y, m, d, 0.0)
|
|
58
|
+
|
|
59
|
+
MAX_ITERATIONS.times do
|
|
60
|
+
delta = SolarReturn.angle_delta(target_deg - Ephemeris.calc_ut(jd, MOON_ID))
|
|
61
|
+
return jd if delta.abs < CONVERGENCE_DEG
|
|
62
|
+
|
|
63
|
+
jd += delta / moon_speed(jd)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
raise ConvergenceError,
|
|
67
|
+
"lunar return did not converge within #{MAX_ITERATIONS} iterations " \
|
|
68
|
+
"(date=#{target_date}, target=#{target_deg})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Moon's longitudinal speed (deg/day) via central difference (~13.2).
|
|
72
|
+
def self.moon_speed(jd, step = 0.02)
|
|
73
|
+
SolarReturn.angle_delta(
|
|
74
|
+
Ephemeris.calc_ut(jd + step, MOON_ID) - Ephemeris.calc_ut(jd - step, MOON_ID)
|
|
75
|
+
) / (2.0 * step)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.natal_moon_degree(chart)
|
|
79
|
+
planets = chart&.dig("chart", "planets")
|
|
80
|
+
raise ArgumentError, "chart has no planets data" if planets.nil? || planets.empty?
|
|
81
|
+
|
|
82
|
+
moon = planets.find { |p| p["planet"] == "月亮" }
|
|
83
|
+
raise ArgumentError, "chart has no 月亮 position" if moon.nil? || moon["total_degree"].nil?
|
|
84
|
+
|
|
85
|
+
moon["total_degree"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private_class_method :natal_moon_degree
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require_relative "aspects"
|
|
2
|
+
require_relative "zodiac"
|
|
3
|
+
|
|
4
|
+
module AstroChart
|
|
5
|
+
# Aspect pattern detection (相位圖形).
|
|
6
|
+
#
|
|
7
|
+
# Detects the classic configurations from a set of ecliptic longitudes
|
|
8
|
+
# (相位由 Aspects.calculate 重新計算,不依賴外部相位表):
|
|
9
|
+
#
|
|
10
|
+
# 大三角 (Grand Trine): 3 bodies pairwise in 三分相.
|
|
11
|
+
# T三角 (T-Square): 2 bodies in 對分相, both 四分相 to an apex.
|
|
12
|
+
# 大十字 (Grand Cross): 4 bodies forming 2 對分相 pairs with all 4
|
|
13
|
+
# adjacent pairs in 四分相.
|
|
14
|
+
# 上帝之指 (Yod): 2 bodies in 六分相, both 補十二分相 (150°) to an apex.
|
|
15
|
+
# 風箏 (Kite): a 大三角 plus a 4th body opposing one leg and
|
|
16
|
+
# 六分相 to the other two.
|
|
17
|
+
# 神祕矩形 (Mystic Rectangle): 2 對分相 pairs joined by 六分相 (short sides)
|
|
18
|
+
# and 三分相 (long sides).
|
|
19
|
+
# 星群 (Stellium): 3+ bodies sharing one zodiac sign.
|
|
20
|
+
#
|
|
21
|
+
# Deduplication rules:
|
|
22
|
+
# - A 大十字 subsumes the 4 T三角s formed by its own bodies — those
|
|
23
|
+
# T三角s are not reported separately.
|
|
24
|
+
# - 南交點 sits exactly 180° from 北交點, so an opposition squared by
|
|
25
|
+
# the node axis would always yield two mirrored T三角s (apex 北交點
|
|
26
|
+
# and apex 南交點). The 南交點-apex twin is collapsed: the single
|
|
27
|
+
# physical configuration is reported once, with apex 北交點.
|
|
28
|
+
# - Each pattern is reported once regardless of body order.
|
|
29
|
+
# - A 風箏 does NOT subsume its 大三角 — astrologers describe the two
|
|
30
|
+
# together ("a grand trine with a kite"), so both are reported.
|
|
31
|
+
module Patterns
|
|
32
|
+
# Elements repeat every 4 signs starting from 牡羊座 (火).
|
|
33
|
+
ELEMENTS = ["火", "土", "風", "水"].freeze
|
|
34
|
+
|
|
35
|
+
# 北交點/南交點 are always exactly 180° apart by definition, so their
|
|
36
|
+
# mutual 對分相 carries no astrological information. That specific pair
|
|
37
|
+
# is therefore excluded from serving as the opposition leg of a T三角,
|
|
38
|
+
# 大十字, 神祕矩形 or 風箏. The nodes themselves remain valid participants
|
|
39
|
+
# — e.g. a node may still be the apex of a T三角, or oppose a planet.
|
|
40
|
+
NODE_AXIS = ["北交點", "南交點"].sort.freeze
|
|
41
|
+
|
|
42
|
+
# positions: { "太陽" => 123.45, ... } (ecliptic longitudes, degrees)
|
|
43
|
+
#
|
|
44
|
+
# Returns Array of:
|
|
45
|
+
# { "pattern_type" => "大三角", "planets" => [...], "element" => "火" | nil }
|
|
46
|
+
# { "pattern_type" => "T三角", "planets" => [...], "apex" => "火星" }
|
|
47
|
+
# { "pattern_type" => "大十字", "planets" => [...] }
|
|
48
|
+
# { "pattern_type" => "上帝之指", "planets" => [...], "apex" => "火星" }
|
|
49
|
+
# { "pattern_type" => "風箏", "planets" => [...], "apex" => "火星" }
|
|
50
|
+
# { "pattern_type" => "神祕矩形", "planets" => [...] }
|
|
51
|
+
# { "pattern_type" => "星群", "planets" => [...], "zodiac" => "牡羊座" }
|
|
52
|
+
def self.detect(positions)
|
|
53
|
+
names = positions.keys
|
|
54
|
+
sextiles = {}
|
|
55
|
+
trines = {}
|
|
56
|
+
squares = {}
|
|
57
|
+
oppositions = {}
|
|
58
|
+
quincunxes = {}
|
|
59
|
+
|
|
60
|
+
names.combination(2) do |a, b|
|
|
61
|
+
# minor: true so 補十二分相 (150°) is available for Yod detection;
|
|
62
|
+
# the major classifications are unaffected (major always wins).
|
|
63
|
+
type, _orb = Aspects.calculate(positions[a], positions[b], minor: true)
|
|
64
|
+
case type
|
|
65
|
+
when "六分相" then sextiles[[a, b].sort] = true
|
|
66
|
+
when "三分相" then trines[[a, b].sort] = true
|
|
67
|
+
when "四分相" then squares[[a, b].sort] = true
|
|
68
|
+
when "對分相" then oppositions[[a, b].sort] = true
|
|
69
|
+
when "補十二分相" then quincunxes[[a, b].sort] = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# See NODE_AXIS: the definitional node opposition never counts as
|
|
74
|
+
# the opposition leg of a T三角, 大十字, 神祕矩形 or 風箏.
|
|
75
|
+
oppositions.delete(NODE_AXIS)
|
|
76
|
+
|
|
77
|
+
grand_trines = detect_grand_trines(names, trines, positions)
|
|
78
|
+
grand_crosses = detect_grand_crosses(oppositions, squares)
|
|
79
|
+
t_squares = detect_t_squares(names, oppositions, squares)
|
|
80
|
+
|
|
81
|
+
# 大十字 subsumes its own 4 T三角s.
|
|
82
|
+
cross_sets = grand_crosses.map { |gc| gc["planets"] }
|
|
83
|
+
t_squares = t_squares.reject do |t|
|
|
84
|
+
cross_sets.any? { |set| (t["planets"] - set).empty? }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# See NODE_AXIS: a 南交點-apex T三角 mirroring a 北交點-apex one on
|
|
88
|
+
# the same opposition is the same physical configuration ("opposition
|
|
89
|
+
# squared by the node axis") — report it once, with apex 北交點.
|
|
90
|
+
t_squares = t_squares.reject do |t|
|
|
91
|
+
t["apex"] == "南交點" &&
|
|
92
|
+
t_squares.any? do |other|
|
|
93
|
+
other["apex"] == "北交點" && other["planets"][0, 2] == t["planets"][0, 2]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
yods = detect_yods(names, sextiles, quincunxes)
|
|
98
|
+
kites = detect_kites(grand_trines, names, oppositions, sextiles)
|
|
99
|
+
rectangles = detect_mystic_rectangles(oppositions, sextiles, trines)
|
|
100
|
+
stelliums = detect_stelliums(positions)
|
|
101
|
+
|
|
102
|
+
grand_trines + t_squares + grand_crosses +
|
|
103
|
+
yods + kites + rectangles + stelliums
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.detect_grand_trines(names, trines, positions)
|
|
107
|
+
names.combination(3).each_with_object([]) do |(a, b, c), out|
|
|
108
|
+
next unless pair?(trines, a, b) && pair?(trines, a, c) && pair?(trines, b, c)
|
|
109
|
+
|
|
110
|
+
elements = [a, b, c].map { |n| element_of(positions[n]) }
|
|
111
|
+
out << {
|
|
112
|
+
"pattern_type" => "大三角",
|
|
113
|
+
"planets" => [a, b, c],
|
|
114
|
+
"element" => elements.uniq.length == 1 ? elements.first : nil,
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.detect_t_squares(names, oppositions, squares)
|
|
120
|
+
oppositions.keys.each_with_object([]) do |(a, b), out|
|
|
121
|
+
names.each do |apex|
|
|
122
|
+
next if apex == a || apex == b
|
|
123
|
+
next unless pair?(squares, apex, a) && pair?(squares, apex, b)
|
|
124
|
+
|
|
125
|
+
out << {
|
|
126
|
+
"pattern_type" => "T三角",
|
|
127
|
+
"planets" => [a, b, apex],
|
|
128
|
+
"apex" => apex,
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.detect_grand_crosses(oppositions, squares)
|
|
135
|
+
found = {}
|
|
136
|
+
oppositions.keys.combination(2) do |(a, b), (c, d)|
|
|
137
|
+
next unless ([a, b] & [c, d]).empty?
|
|
138
|
+
next unless pair?(squares, a, c) && pair?(squares, a, d) &&
|
|
139
|
+
pair?(squares, b, c) && pair?(squares, b, d)
|
|
140
|
+
|
|
141
|
+
found[[a, b, c, d].sort] ||= {
|
|
142
|
+
"pattern_type" => "大十字",
|
|
143
|
+
"planets" => [a, b, c, d],
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
found.values
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 上帝之指 (Yod): a 六分相 base whose two ends both point 補十二分相
|
|
150
|
+
# (150°) at a common apex. A given trio admits at most one apex, so
|
|
151
|
+
# keying by the sorted trio dedups fully.
|
|
152
|
+
def self.detect_yods(names, sextiles, quincunxes)
|
|
153
|
+
found = {}
|
|
154
|
+
sextiles.keys.each do |(a, b)|
|
|
155
|
+
names.each do |apex|
|
|
156
|
+
next if apex == a || apex == b
|
|
157
|
+
next unless pair?(quincunxes, apex, a) && pair?(quincunxes, apex, b)
|
|
158
|
+
|
|
159
|
+
found[[a, b, apex].sort] ||= {
|
|
160
|
+
"pattern_type" => "上帝之指",
|
|
161
|
+
"planets" => [a, b, apex],
|
|
162
|
+
"apex" => apex,
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
found.values
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# 風箏 (Kite): a 大三角 plus a focal body that 對分相 one leg and 六分相
|
|
170
|
+
# the other two. Reported alongside the underlying 大三角, not instead
|
|
171
|
+
# of it.
|
|
172
|
+
def self.detect_kites(grand_trines, names, oppositions, sextiles)
|
|
173
|
+
found = {}
|
|
174
|
+
grand_trines.each do |gt|
|
|
175
|
+
trine = gt["planets"]
|
|
176
|
+
names.each do |focus|
|
|
177
|
+
next if trine.include?(focus)
|
|
178
|
+
|
|
179
|
+
trine.each do |opposed|
|
|
180
|
+
others = trine - [opposed]
|
|
181
|
+
next unless pair?(oppositions, focus, opposed)
|
|
182
|
+
next unless pair?(sextiles, focus, others[0]) &&
|
|
183
|
+
pair?(sextiles, focus, others[1])
|
|
184
|
+
|
|
185
|
+
found[(trine + [focus]).sort] ||= {
|
|
186
|
+
"pattern_type" => "風箏",
|
|
187
|
+
"planets" => trine + [focus],
|
|
188
|
+
"apex" => focus,
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
found.values
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# 神祕矩形 (Mystic Rectangle): two 對分相 pairs whose cross-connections
|
|
197
|
+
# are 六分相 (short sides) and 三分相 (long sides). The node-axis
|
|
198
|
+
# opposition is already excluded (see NODE_AXIS).
|
|
199
|
+
def self.detect_mystic_rectangles(oppositions, sextiles, trines)
|
|
200
|
+
found = {}
|
|
201
|
+
oppositions.keys.combination(2) do |(a, b), (c, d)|
|
|
202
|
+
next unless ([a, b] & [c, d]).empty?
|
|
203
|
+
|
|
204
|
+
ok = (pair?(sextiles, a, c) && pair?(sextiles, b, d) &&
|
|
205
|
+
pair?(trines, a, d) && pair?(trines, b, c)) ||
|
|
206
|
+
(pair?(sextiles, a, d) && pair?(sextiles, b, c) &&
|
|
207
|
+
pair?(trines, a, c) && pair?(trines, b, d))
|
|
208
|
+
next unless ok
|
|
209
|
+
|
|
210
|
+
found[[a, b, c, d].sort] ||= {
|
|
211
|
+
"pattern_type" => "神祕矩形",
|
|
212
|
+
"planets" => [a, b, c, d],
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
found.values
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# 星群 (Stellium): 3+ bodies occupying a single zodiac sign. Planet
|
|
219
|
+
# order within a sign follows the input order.
|
|
220
|
+
def self.detect_stelliums(positions)
|
|
221
|
+
by_sign = {}
|
|
222
|
+
positions.each do |name, deg|
|
|
223
|
+
(by_sign[Zodiac.sign_name(deg)] ||= []) << name
|
|
224
|
+
end
|
|
225
|
+
by_sign.select { |_sign, members| members.length >= 3 }
|
|
226
|
+
.map do |sign, members|
|
|
227
|
+
{
|
|
228
|
+
"pattern_type" => "星群",
|
|
229
|
+
"planets" => members,
|
|
230
|
+
"zodiac" => sign,
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def self.pair?(store, a, b)
|
|
236
|
+
store.key?([a, b].sort)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.element_of(degree)
|
|
240
|
+
ELEMENTS[((degree % 360).floor / 30) % 4]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private_class_method :detect_grand_trines, :detect_t_squares,
|
|
244
|
+
:detect_grand_crosses, :detect_yods, :detect_kites,
|
|
245
|
+
:detect_mystic_rectangles, :detect_stelliums,
|
|
246
|
+
:pair?, :element_of
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "houses"
|
|
4
|
+
|
|
5
|
+
module AstroChart
|
|
6
|
+
# Derived chart points that are not physical bodies:
|
|
7
|
+
# 福點 (Part of Fortune) and 莉莉絲 (mean Black Moon Lilith / mean lunar apogee).
|
|
8
|
+
module Points
|
|
9
|
+
# Part of Fortune (福點), ecliptic longitude in degrees 0-360.
|
|
10
|
+
#
|
|
11
|
+
# Day chart: ASC + Moon - Sun
|
|
12
|
+
# Night chart: ASC - Moon + Sun
|
|
13
|
+
def self.fortune(asc:, sun:, moon:, day_chart:)
|
|
14
|
+
longitude = day_chart ? asc + moon - sun : asc - moon + sun
|
|
15
|
+
longitude % 360.0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Whether the chart is a day birth: the Sun is above the horizon,
|
|
19
|
+
# i.e. the Sun falls in houses 7-12 for the given house cusps.
|
|
20
|
+
# Returns nil when the Sun cannot be placed (nil/empty inputs).
|
|
21
|
+
#
|
|
22
|
+
# Note: this matches the horizon only for quadrant systems whose cusp 1
|
|
23
|
+
# is the ascendant (e.g. Placidus). For sect determination independent
|
|
24
|
+
# of the display house system, use .day_chart_from_horizon? instead.
|
|
25
|
+
def self.day_chart?(sun_longitude, cusps)
|
|
26
|
+
house = Houses.find_house(sun_longitude, cusps)
|
|
27
|
+
return nil if house.nil?
|
|
28
|
+
|
|
29
|
+
house >= 7
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the chart is a day birth, judged directly from the horizon
|
|
33
|
+
# (ASC–DSC axis): the Sun is above the horizon when its ecliptic
|
|
34
|
+
# longitude lies in the half-circle from the descendant (ASC + 180°)
|
|
35
|
+
# forward to the ascendant — equivalent to quadrant houses 7-12.
|
|
36
|
+
# Sect is an astronomical fact, so this is independent of the display
|
|
37
|
+
# house system. Returns nil when either input is missing.
|
|
38
|
+
def self.day_chart_from_horizon?(sun_longitude, ascendant)
|
|
39
|
+
return nil if sun_longitude.nil? || ascendant.nil?
|
|
40
|
+
|
|
41
|
+
((sun_longitude - ascendant) % 360.0) >= 180.0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Antiscion (映點): the reflection of an ecliptic longitude across the
|
|
45
|
+
# Cancer–Capricorn solstice axis (0° 巨蟹 / 0° 摩羯). Two points are in
|
|
46
|
+
# antiscia when they are equidistant from that axis and thus receive the
|
|
47
|
+
# same amount of daylight; classically read as a hidden conjunction.
|
|
48
|
+
#
|
|
49
|
+
# antiscion = (180 − L) mod 360
|
|
50
|
+
#
|
|
51
|
+
# e.g. 15° 牡羊 (L=15) → 15° 處女 (165); a point on the axis maps to itself.
|
|
52
|
+
def self.antiscion(longitude)
|
|
53
|
+
(180.0 - longitude) % 360.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Contra-antiscion (反映點): reflection across the Aries–Libra equinox
|
|
57
|
+
# axis (0° 牡羊 / 0° 天秤) — the antiscion's opposite point.
|
|
58
|
+
#
|
|
59
|
+
# contra_antiscion = (360 − L) mod 360
|
|
60
|
+
def self.contra_antiscion(longitude)
|
|
61
|
+
(360.0 - longitude) % 360.0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Mean Black Moon Lilith (莉莉絲): ecliptic longitude of the mean lunar
|
|
65
|
+
# apogee, degrees 0-360, for a given Julian Day (UT).
|
|
66
|
+
#
|
|
67
|
+
# Uses the mean longitude of the lunar perigee polynomial (Meeus,
|
|
68
|
+
# "Astronomical Algorithms", mean elements of the lunar orbit) + 180°.
|
|
69
|
+
# T is measured in Julian centuries from J2000.0. The TT-UT difference
|
|
70
|
+
# is ignored: the apogee moves ~0.111°/day, so delta-T contributes well
|
|
71
|
+
# under 0.001° in 1900-2100.
|
|
72
|
+
def self.lilith(jd)
|
|
73
|
+
t = (jd - 2451545.0) / 36525.0
|
|
74
|
+
perigee = 83.3532465 +
|
|
75
|
+
4069.0137287 * t -
|
|
76
|
+
0.0103200 * t**2 -
|
|
77
|
+
t**3 / 80_053.0 +
|
|
78
|
+
t**4 / 18_999_000.0
|
|
79
|
+
(perigee + 180.0) % 360.0
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require_relative "zodiac"
|
|
3
|
+
require_relative "dignities"
|
|
4
|
+
|
|
5
|
+
module AstroChart
|
|
6
|
+
# Annual profection (小限法) — the traditional time-lord technique where each
|
|
7
|
+
# completed year of life advances the point of emphasis by one whole sign
|
|
8
|
+
# from the ascendant. Age 0 activates the 1st house (the ASC sign), age 1 the
|
|
9
|
+
# 2nd, and so on, cycling every 12 years. The traditional ruler of the
|
|
10
|
+
# profected sign is the Lord of the Year (年主星), the year's principal
|
|
11
|
+
# significator.
|
|
12
|
+
#
|
|
13
|
+
# Pure arithmetic on the ascendant longitude and an age in whole years.
|
|
14
|
+
module Profection
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# ascendant: ecliptic longitude of the ASC (degrees, 0-360)
|
|
18
|
+
# age: completed years of life (integer ≥ 0)
|
|
19
|
+
#
|
|
20
|
+
# Returns:
|
|
21
|
+
# { "age" => 36, "profected_house" => 1, "profected_sign" => "牡羊座",
|
|
22
|
+
# "year_lord" => "火星" }
|
|
23
|
+
def annual(ascendant, age)
|
|
24
|
+
raise ArgumentError, "age must be a non-negative integer" if age.nil? || age < 0
|
|
25
|
+
|
|
26
|
+
asc_sign_index = (ascendant % 360).floor / 30
|
|
27
|
+
steps = age % 12
|
|
28
|
+
sign = Zodiac::SIGNS[(asc_sign_index + steps) % 12]
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
"age" => age,
|
|
32
|
+
"profected_house" => steps + 1,
|
|
33
|
+
"profected_sign" => sign,
|
|
34
|
+
"year_lord" => Dignities::DOMICILE[sign],
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Convenience: derive the age (completed years) from birth and target dates
|
|
39
|
+
# (each a "YYYY-MM-DD" string or Date) and profect.
|
|
40
|
+
def at(ascendant, birth_date, target_date)
|
|
41
|
+
annual(ascendant, completed_years(to_date(birth_date), to_date(target_date)))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Whole years elapsed from birth to target (the person's age).
|
|
45
|
+
def completed_years(birth, target)
|
|
46
|
+
years = target.year - birth.year
|
|
47
|
+
had_birthday = (target.month > birth.month) ||
|
|
48
|
+
(target.month == birth.month && target.day >= birth.day)
|
|
49
|
+
had_birthday ? years : years - 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_date(value)
|
|
53
|
+
value.is_a?(Date) ? value : Date.parse(value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|