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.
@@ -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
- @backend ||= DEFAULT_BACKEND
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
- case name
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
- return if defined?(AstroChart::Ext)
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 AstroChart.backend
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 AstroChart.backend
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 AstroChart.backend
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
@@ -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 = "P")
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