Module: AstroChart::LunarReturn

Defined in:
lib/astro_chart/lunar_return.rb

Overview

Lunar return chart (月亮回歸盤): the chart cast for the exact UTC instant the transiting Moon returns to its natal longitude — a monthly (~27.32-day) cycle, the lunar analogue of the solar return.

natal  = AstroChart::Chart.new(...).generate
result = AstroChart::LunarReturn.for_date(natal, "2026-07-24")
result["return_jd"]       # Julian Day (UT) of the nearest return
result["return_time_utc"] # ISO8601 UTC
result["chart"]           # full chart at the return instant

The instant is found by Newton iteration on the Moon's longitude starting from the target date, so it converges to the return nearest that date (within ~±½ cycle). Chart building and JD↔UTC reuse SolarReturn.

Defined Under Namespace

Classes: ConvergenceError

Constant Summary collapse

MOON_ID =
Ephemeris::PLANETS["月亮"]
CONVERGENCE_DEG =
1e-4
MAX_ITERATIONS =

more than the Sun: the Moon's speed varies ~11-15°/day

40

Class Method Summary collapse

Class Method Details

.find_return_jd(target_deg, target_date) ⇒ Object

Newton iteration on the Moon's longitude from the target date.

Raises:

  • (ArgumentError)


53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/astro_chart/lunar_return.rb', line 53

def self.find_return_jd(target_deg, target_date)
  y, m, d = target_date.split("-").map(&:to_i)
  raise ArgumentError, "invalid date: #{target_date.inspect}" if y.nil? || m.nil? || d.nil?

  jd = Ephemeris.julday(y, m, d, 0.0)

  MAX_ITERATIONS.times do
    delta = SolarReturn.angle_delta(target_deg - Ephemeris.calc_ut(jd, MOON_ID))
    return jd if delta.abs < CONVERGENCE_DEG

    jd += delta / moon_speed(jd)
  end

  raise ConvergenceError,
        "lunar return did not converge within #{MAX_ITERATIONS} iterations " \
        "(date=#{target_date}, target=#{target_deg})"
end

.for_date(natal_chart, target_date, latitude: nil, longitude: nil, timezone: nil) ⇒ Object

natal_chart: a Chart#generate result hash. target_date: "YYYY-MM-DD" — the returned instant is the lunar return nearest this date. Location defaults to the natal coordinates; pass latitude:/longitude: (and timezone:, informational) to relocate.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/astro_chart/lunar_return.rb', line 30

def self.for_date(natal_chart, target_date, latitude: nil, longitude: nil, timezone: nil)
  natal_moon = natal_moon_degree(natal_chart)

  input = natal_chart["input"] || {}
  coords = input["coordinates"] || {}
  lat = (latitude || coords["latitude"])
  lng = (longitude || coords["longitude"])
  tz  = (timezone || input["timezone"])
  if lat.nil? || lng.nil?
    raise ArgumentError, "no coordinates: natal chart input has none and none were given"
  end

  jd = find_return_jd(natal_moon, target_date)

  {
    "return_jd"       => jd,
    "return_time_utc" => SolarReturn.jd_to_utc_iso8601(jd),
    "location"        => { "latitude" => lat.to_f, "longitude" => lng.to_f, "timezone" => tz },
    "chart"           => SolarReturn.build_chart_at(jd, lat.to_f, lng.to_f),
  }
end

.moon_speed(jd, step = 0.02) ⇒ Object

Moon's longitudinal speed (deg/day) via central difference (~13.2).



72
73
74
75
76
# File 'lib/astro_chart/lunar_return.rb', line 72

def self.moon_speed(jd, step = 0.02)
  SolarReturn.angle_delta(
    Ephemeris.calc_ut(jd + step, MOON_ID) - Ephemeris.calc_ut(jd - step, MOON_ID)
  ) / (2.0 * step)
end