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.
@@ -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
@@ -0,0 +1,108 @@
1
+ require_relative "pure"
2
+ require_relative "ephemeris"
3
+ require_relative "zodiac"
4
+ require_relative "aspects"
5
+ require_relative "houses"
6
+ require_relative "planets"
7
+ require_relative "synastry"
8
+
9
+ module AstroChart
10
+ # Transits (行運): where the planets are in the sky right now (or at any
11
+ # moment), compared against a natal chart.
12
+ #
13
+ # Pure composition of existing modules — Planets.calculate_positions gives
14
+ # the sky positions for any JD, Houses.find_house places them into the
15
+ # natal houses, and Aspects.calculate scores transit-to-natal contacts.
16
+ #
17
+ # Typical usage with a Chart#generate result:
18
+ #
19
+ # jd = AstroChart::TimeConversion.to_julian_day("2026-07-24", "12:00", "Asia/Taipei")
20
+ # AstroChart::Transits.at(jd) # 天象快照 { "太陽" => 121.9, ... }
21
+ # AstroChart::Transits.against(natal_chart, jd) # 行運行星落入本命宮位 + 行運相位
22
+ module Transits
23
+ # Bodies used for transit-to-natal aspects. Mirrors Synastry: 南交點 is
24
+ # excluded because it sits exactly opposite 北交點 — every south-node
25
+ # aspect would just mirror a north-node one and double the noise.
26
+ ASPECT_BODIES = Synastry::BODIES
27
+
28
+ # Sky snapshot at a Julian Day.
29
+ #
30
+ # Returns { "太陽" => 123.45, ..., "南交點" => 303.45 } — ecliptic
31
+ # longitudes (total degrees 0-360) for the 12 bodies
32
+ # (Ephemeris::PLANETS + 南交點 = 北交點 + 180°).
33
+ def self.at(jd)
34
+ Planets.calculate_positions(jd)
35
+ end
36
+
37
+ # Transits against a natal chart (a Chart#generate result hash).
38
+ #
39
+ # jd: Julian Day of the transit moment.
40
+ # orb_limit: keep only aspects with orb <= limit (default 3.0 — transit
41
+ # practice uses much tighter orbs than the natal defaults in Aspects).
42
+ #
43
+ # Returns:
44
+ # {
45
+ # "planets" => [ { "planet" => "木星", "zodiac" => "獅子座",
46
+ # "degree" => 5.1, "total_degree" => 125.1,
47
+ # "natal_house" => 7 }, ... 12 entries ],
48
+ # "aspects" => [ { "transit_planet" => "土星", "natal_planet" => "太陽",
49
+ # "aspect_type" => "四分相", "orb" => 1.23 }, ... ]
50
+ # }
51
+ #
52
+ # aspects are sorted by orb (tightest first).
53
+ def self.against(natal_chart, jd, orb_limit: 3.0)
54
+ transit_positions = at(jd)
55
+ natal_positions = Synastry.positions_from_chart(natal_chart)
56
+ natal_cusps = Synastry.cusps_from_chart(natal_chart)
57
+
58
+ {
59
+ "planets" => planet_details(transit_positions, natal_cusps),
60
+ "aspects" => aspects_to_natal(transit_positions, natal_positions,
61
+ orb_limit: orb_limit,
62
+ keys: %w[transit_planet natal_planet]),
63
+ }
64
+ end
65
+
66
+ # Build the per-planet detail list, placing each transiting body into
67
+ # the natal houses.
68
+ def self.planet_details(positions, natal_cusps)
69
+ positions.map do |name, pos|
70
+ {
71
+ "planet" => name,
72
+ "zodiac" => Zodiac.sign_name(pos),
73
+ "degree" => (pos % 30).round(4),
74
+ "total_degree" => pos.round(4),
75
+ "natal_house" => Houses.find_house(pos, natal_cusps),
76
+ }
77
+ end
78
+ end
79
+
80
+ # Aspects from moving positions to natal positions, filtered by
81
+ # orb_limit and sorted by orb. `keys` names the two hash keys so
82
+ # Progressions can reuse this with "progressed_planet".
83
+ def self.aspects_to_natal(moving_positions, natal_positions, orb_limit:,
84
+ keys: %w[transit_planet natal_planet])
85
+ moving_key, natal_key = keys
86
+ results = []
87
+
88
+ moving_positions.each do |m_name, m_pos|
89
+ next unless ASPECT_BODIES.include?(m_name)
90
+
91
+ natal_positions.each do |n_name, n_pos|
92
+ aspect_type, orb = Aspects.calculate(m_pos, n_pos)
93
+ next if aspect_type.nil?
94
+ next if orb_limit && orb > orb_limit
95
+
96
+ results << {
97
+ moving_key => m_name,
98
+ natal_key => n_name,
99
+ "aspect_type" => aspect_type,
100
+ "orb" => orb,
101
+ }
102
+ end
103
+ end
104
+
105
+ results.sort_by { |r| r["orb"] }
106
+ end
107
+ end
108
+ end
@@ -1,3 +1,3 @@
1
1
  module AstroChart
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/astro_chart.rb CHANGED
@@ -10,8 +10,21 @@ require_relative "astro_chart/aspects"
10
10
  require_relative "astro_chart/houses"
11
11
  require_relative "astro_chart/time_conversion"
12
12
  require_relative "astro_chart/planets"
13
+ require_relative "astro_chart/points"
14
+ require_relative "astro_chart/patterns"
15
+ require_relative "astro_chart/stats"
13
16
  require_relative "astro_chart/synastry"
14
17
  require_relative "astro_chart/chart"
18
+ require_relative "astro_chart/transits"
19
+ require_relative "astro_chart/progressions"
20
+ require_relative "astro_chart/composite"
21
+ require_relative "astro_chart/solar_return"
22
+ require_relative "astro_chart/draconic"
23
+ require_relative "astro_chart/dignities"
24
+ require_relative "astro_chart/profection"
25
+ require_relative "astro_chart/transit_timing"
26
+ require_relative "astro_chart/solar_arc"
27
+ require_relative "astro_chart/lunar_return"
15
28
 
16
29
  module AstroChart
17
30
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: astro_chart
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Huang Yudi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-08-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tzinfo
@@ -52,10 +52,12 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '13.0'
55
- description: 'Natal astrology chart calculation in pure Ruby: apparent planetary longitudes
56
- (VSOP87D, ELP-2000/82B moon, Meeus Pluto), Placidus houses, aspects and synastry.
57
- No C extension, no external data files. Implemented from public formulas (Meeus,
58
- Astronomical Algorithms 2nd ed.).'
55
+ description: 'Astrology chart calculation in pure Ruby: apparent planetary longitudes
56
+ (VSOP87D, ELP-2000/82B moon, Meeus Pluto), Placidus and whole-sign houses, retrograde
57
+ flags, aspects, derived points (Part of Fortune, mean Lilith), aspect-pattern detection
58
+ and element statistics, plus synastry, transits, secondary progressions, composite
59
+ charts and solar returns. No C extension, no external data files. Implemented from
60
+ public formulas (Meeus, Astronomical Algorithms 2nd ed.).'
59
61
  email:
60
62
  executables: []
61
63
  extensions: []
@@ -63,13 +65,22 @@ extra_rdoc_files: []
63
65
  files:
64
66
  - CHANGELOG.md
65
67
  - LICENSE
68
+ - README.md
66
69
  - astro_chart.gemspec
67
70
  - lib/astro_chart.rb
68
71
  - lib/astro_chart/aspects.rb
69
72
  - lib/astro_chart/chart.rb
73
+ - lib/astro_chart/composite.rb
74
+ - lib/astro_chart/dignities.rb
75
+ - lib/astro_chart/draconic.rb
70
76
  - lib/astro_chart/ephemeris.rb
71
77
  - lib/astro_chart/houses.rb
78
+ - lib/astro_chart/lunar_return.rb
79
+ - lib/astro_chart/patterns.rb
72
80
  - lib/astro_chart/planets.rb
81
+ - lib/astro_chart/points.rb
82
+ - lib/astro_chart/profection.rb
83
+ - lib/astro_chart/progressions.rb
73
84
  - lib/astro_chart/pure.rb
74
85
  - lib/astro_chart/pure/core.rb
75
86
  - lib/astro_chart/pure/houses.rb
@@ -78,8 +89,13 @@ files:
78
89
  - lib/astro_chart/pure/pluto.rb
79
90
  - lib/astro_chart/pure/vsop87.rb
80
91
  - lib/astro_chart/pure/vsop87_data.rb
92
+ - lib/astro_chart/solar_arc.rb
93
+ - lib/astro_chart/solar_return.rb
94
+ - lib/astro_chart/stats.rb
81
95
  - lib/astro_chart/synastry.rb
82
96
  - lib/astro_chart/time_conversion.rb
97
+ - lib/astro_chart/transit_timing.rb
98
+ - lib/astro_chart/transits.rb
83
99
  - lib/astro_chart/version.rb
84
100
  - lib/astro_chart/zodiac.rb
85
101
  homepage: https://github.com/morriedig/astro_chart
@@ -104,5 +120,6 @@ requirements: []
104
120
  rubygems_version: 3.4.10
105
121
  signing_key:
106
122
  specification_version: 4
107
- summary: Pure-Ruby natal chart calculation (planets, Placidus houses, aspects, synastry)
123
+ summary: Pure-Ruby astrology chart calculation (planets, Placidus/whole-sign houses,
124
+ aspects, synastry, transits, progressions, composite, solar returns)
108
125
  test_files: []