astro_chart 0.1.0 → 0.2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +17 -16
  4. data/astro_chart.gemspec +7 -6
  5. data/lib/astro_chart/ephemeris.rb +76 -18
  6. data/lib/astro_chart/pure/core.rb +209 -0
  7. data/lib/astro_chart/pure/houses.rb +122 -0
  8. data/lib/astro_chart/pure/moon.rb +341 -0
  9. data/lib/astro_chart/pure/moon_elp.rb +931 -0
  10. data/lib/astro_chart/pure/pluto.rb +220 -0
  11. data/lib/astro_chart/pure/vsop87.rb +152 -0
  12. data/lib/astro_chart/pure/vsop87_data.rb +210 -0
  13. data/lib/astro_chart/pure.rb +63 -0
  14. data/lib/astro_chart/synastry.rb +118 -0
  15. data/lib/astro_chart/version.rb +1 -1
  16. data/lib/astro_chart.rb +6 -1
  17. metadata +19 -45
  18. data/ext/astro_chart/astro_chart_ext.c +0 -99
  19. data/ext/astro_chart/extconf.rb +0 -8
  20. data/ext/astro_chart/swecl.c +0 -6428
  21. data/ext/astro_chart/swedate.c +0 -588
  22. data/ext/astro_chart/swedate.h +0 -81
  23. data/ext/astro_chart/swedll.h +0 -403
  24. data/ext/astro_chart/sweephe4.c +0 -702
  25. data/ext/astro_chart/sweephe4.h +0 -239
  26. data/ext/astro_chart/swehel.c +0 -3511
  27. data/ext/astro_chart/swehouse.c +0 -3143
  28. data/ext/astro_chart/swehouse.h +0 -98
  29. data/ext/astro_chart/swejpl.c +0 -958
  30. data/ext/astro_chart/swejpl.h +0 -103
  31. data/ext/astro_chart/swemmoon.c +0 -1930
  32. data/ext/astro_chart/swemplan.c +0 -967
  33. data/ext/astro_chart/swemptab.h +0 -10640
  34. data/ext/astro_chart/swenut2000a.h +0 -2819
  35. data/ext/astro_chart/sweodef.h +0 -326
  36. data/ext/astro_chart/sweph.c +0 -8614
  37. data/ext/astro_chart/sweph.h +0 -849
  38. data/ext/astro_chart/swephexp.h +0 -1020
  39. data/ext/astro_chart/swephlib.c +0 -4634
  40. data/ext/astro_chart/swephlib.h +0 -189
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9e05e60924337f22af516c0830b2d569d802f09d1d39658b9a8633141553a11
4
- data.tar.gz: 4b479272b7a20c2a979d3db48983c1da9260ec9c2908274dee1f03326c4ac7da
3
+ metadata.gz: 203914237dcef72fb6ebc852749cf78811321155c0a509c0c8f711c1646cf7da
4
+ data.tar.gz: 87661219e2c6f29aa8b9330a5af0a4408ce65d342899a3e085ea3dee9a9f43f2
5
5
  SHA512:
6
- metadata.gz: fa3265f1725729216373b9be2737f3958f4e743fb2f6731f5bbbdeaae183045091fdbfb5ea16647ed9c63b88c395047b72f5e580351364c8bc9697df8a626282
7
- data.tar.gz: 5af130c80b00a0715e520652eebb25dfbc533603360e38a3f6803e9c133f47fdbe8f345c116f7be75f43356b957bfe093d7369048a95ff06816142daff403531
6
+ metadata.gz: 81b2fd730e85708ecb9a9264bbfbd7bee4d6ea848c3554b12f4f98303a6f1f9432e7713e071191193035facb0a46f3c886efc1d7767c27711eb9384990766c6c
7
+ data.tar.gz: bd6534cf3b77dcd81e9b800a343cc594a08e48bfbc8419024b897e59af9f0beef41c4c6d3bdc01d1011c7568659c8322c9b71ff3f2f1cefb8c2931eb4e17aca3
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-07-24)
4
+
5
+ **License change: AGPL-3.0 → MIT.**
6
+
7
+ - New pure-Ruby ephemeris backend, now the default (`AstroChart.backend == :pure`):
8
+ - Planets: VSOP87D truncated series (Sun, Mercury–Neptune)
9
+ - Moon & true node: ELP-2000/82B truncated series (CDS VizieR VI/79)
10
+ - Pluto: Meeus Astronomical Algorithms Ch. 37 series (valid 1885–2099)
11
+ - Placidus houses, ΔT (Espenak & Meeus), IAU 1980 nutation
12
+ - Verified against Swiss Ephemeris 1900–2026: all bodies within
13
+ 0.014° (most < 0.001°), zero zodiac sign flips
14
+ - New `AstroChart::Synastry`: cross-chart aspects + house overlay
15
+ - **Removed** the bundled Swiss Ephemeris C extension (AGPL). The
16
+ `:swiss` backend now raises `LoadError` unless you compile and provide
17
+ the extension yourself (it is no longer shipped with this gem).
18
+ - Pure backend supports Placidus ("P") houses only; polar latitudes
19
+ raise `AstroChart::Pure::Core::DomainError` explicitly.
20
+ - Public interface unchanged: `Ephemeris.julday / calc_ut / houses`,
21
+ `Chart#generate` output structure identical.
22
+
23
+ ## 0.1.0
24
+
25
+ - Initial release (Swiss Ephemeris C extension, AGPL-3.0).
data/LICENSE CHANGED
@@ -1,20 +1,21 @@
1
- GNU AFFERO GENERAL PUBLIC LICENSE
2
- Version 3, 19 November 2007
1
+ MIT License
3
2
 
4
- Copyright (C) 2025 AstroChart Contributors
3
+ Copyright (c) 2025-2026 Huang Yudi
5
4
 
6
- This program is free software: you can redistribute it and/or modify
7
- it under the terms of the GNU Affero General Public License as published
8
- by the Free Software Foundation, either version 3 of the License, or
9
- (at your option) any later version.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
10
11
 
11
- This program is distributed in the hope that it will be useful,
12
- but WITHOUT ANY WARRANTY; without even the implied warranty of
13
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
- GNU Affero General Public License for more details.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
15
14
 
16
- You should have received a copy of the GNU Affero General Public License
17
- along with this program. If not, see <https://www.gnu.org/licenses/>.
18
-
19
- This software includes the Swiss Ephemeris library, which is also
20
- licensed under AGPL-3.0. See https://www.astro.com/swisseph/ for details.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/astro_chart.gemspec CHANGED
@@ -4,19 +4,20 @@ Gem::Specification.new do |spec|
4
4
  spec.name = "astro_chart"
5
5
  spec.version = AstroChart::VERSION
6
6
  spec.authors = ["Huang Yudi"]
7
- spec.summary = "Natal chart calculation using Swiss Ephemeris"
8
- spec.description = "A Ruby gem for natal astrology chart calculation, powered by Swiss Ephemeris C library with Moshier ephemeris. No external data files needed."
7
+ spec.summary = "Pure-Ruby natal chart calculation (planets, Placidus houses, aspects, synastry)"
8
+ spec.description = "Natal astrology chart calculation in pure Ruby: apparent planetary longitudes " \
9
+ "(VSOP87D, ELP-2000/82B moon, Meeus Pluto), Placidus houses, aspects and synastry. " \
10
+ "No C extension, no external data files. Implemented from public formulas " \
11
+ "(Meeus, Astronomical Algorithms 2nd ed.)."
9
12
  spec.homepage = "https://github.com/morriedig/astro_chart"
10
- spec.license = "AGPL-3.0"
13
+ spec.license = "MIT"
11
14
  spec.required_ruby_version = ">= 3.0"
12
15
 
13
- spec.files = Dir["lib/**/*.rb", "ext/**/*.{rb,c,h}", "LICENSE", "astro_chart.gemspec"]
16
+ spec.files = Dir["lib/**/*.rb", "LICENSE", "CHANGELOG.md", "astro_chart.gemspec"]
14
17
  spec.require_paths = ["lib"]
15
- spec.extensions = ["ext/astro_chart/extconf.rb"]
16
18
 
17
19
  spec.add_dependency "tzinfo", "~> 2.0"
18
20
 
19
21
  spec.add_development_dependency "rspec", "~> 3.0"
20
22
  spec.add_development_dependency "rake", "~> 13.0"
21
- spec.add_development_dependency "rake-compiler", "~> 1.2"
22
23
  end
@@ -1,36 +1,94 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AstroChart
2
- # Thin Ruby wrapper around the C extension (AstroChart::Ext).
3
- # All public callers should go through this module so we can
4
- # swap the backend without touching the rest of the code.
4
+ DEFAULT_BACKEND = :pure
5
+
6
+ class << self
7
+ # Current ephemeris backend (:pure or :swiss). Defaults to :pure.
8
+ def backend
9
+ @backend ||= DEFAULT_BACKEND
10
+ end
11
+
12
+ # Switch the ephemeris backend.
13
+ #
14
+ # AstroChart.backend = :pure # pure-Ruby (default, no C extension needed)
15
+ # 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
+ 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
31
+ end
32
+
33
+ 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
45
+ end
46
+ end
47
+
48
+ # Backend-agnostic ephemeris facade. All public callers go through this
49
+ # module so the backend (:pure / :swiss) can be swapped without touching
50
+ # the rest of the code.
5
51
  module Ephemeris
52
+ # SE-convention planet ids (numeric literals so the :pure default
53
+ # works without the C extension loaded; values match AstroChart::Ext
54
+ # constants when the extension is present).
6
55
  PLANETS = {
7
- "太陽" => Ext::SUN,
8
- "月亮" => Ext::MOON,
9
- "水星" => Ext::MERCURY,
10
- "金星" => Ext::VENUS,
11
- "火星" => Ext::MARS,
12
- "木星" => Ext::JUPITER,
13
- "土星" => Ext::SATURN,
14
- "天王星" => Ext::URANUS,
15
- "海王星" => Ext::NEPTUNE,
16
- "冥王星" => Ext::PLUTO,
17
- "北交點" => Ext::TRUE_NODE,
56
+ "太陽" => 0, # SUN
57
+ "月亮" => 1, # MOON
58
+ "水星" => 2, # MERCURY
59
+ "金星" => 3, # VENUS
60
+ "火星" => 4, # MARS
61
+ "木星" => 5, # JUPITER
62
+ "土星" => 6, # SATURN
63
+ "天王星" => 7, # URANUS
64
+ "海王星" => 8, # NEPTUNE
65
+ "冥王星" => 9, # PLUTO
66
+ "北交點" => 11, # TRUE_NODE
18
67
  }.freeze
19
68
 
20
69
  # Convert date/time to Julian Day number.
21
70
  def self.julday(year, month, day, hour)
22
- Ext.julday(year, month, day, hour)
71
+ case AstroChart.backend
72
+ when :swiss then Ext.julday(year, month, day, hour)
73
+ else Pure.julday(year, month, day, hour)
74
+ end
23
75
  end
24
76
 
25
- # Calculate planet ecliptic longitude (degrees 0-360).
77
+ # Calculate planet apparent ecliptic longitude (degrees 0-360).
26
78
  def self.calc_ut(jd, planet_id)
27
- Ext.calc_ut(jd, planet_id)
79
+ case AstroChart.backend
80
+ when :swiss then Ext.calc_ut(jd, planet_id)
81
+ else Pure.calc_ut(jd, planet_id)
82
+ end
28
83
  end
29
84
 
30
85
  # Calculate house cusps + ascendant.
31
86
  # Returns { "cusps" => [12 floats], "ascendant" => float, "mc" => float }
32
87
  def self.houses(jd, latitude, longitude, system = "P")
33
- Ext.houses(jd, latitude, longitude, system.ord)
88
+ case AstroChart.backend
89
+ when :swiss then Ext.houses(jd, latitude, longitude, system.ord)
90
+ else Pure.houses(jd, latitude, longitude, system)
91
+ end
34
92
  end
35
93
  end
36
94
  end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AstroChart
4
+ module Pure
5
+ # Core — MIT-safe 純 Ruby 天文基建(零外部依賴)。
6
+ #
7
+ # 只使用公開文獻公式,不參考 Swiss Ephemeris C 原始碼:
8
+ # - 儒略日:Jean Meeus, "Astronomical Algorithms" 2nd ed., Ch. 7 (eq. 7.1)
9
+ # - ΔT(TT−UT):Espenak & Meeus 多項式擬合
10
+ # (NASA Eclipse website, "Polynomial Expressions for Delta T (ΔT)",
11
+ # Five Millennium Canon of Solar Eclipses: -1999 to +3000)
12
+ # - 章動(Δψ / Δε):Meeus Ch. 22(IAU 1980 序列截斷,振幅最大 13 項)
13
+ # - 平黃赤交角:Meeus eq. 22.2
14
+ # - 平恆星時 GMST:Meeus Ch. 12 (eq. 12.4,等同 IAU 1982 GMST)
15
+ # - 視恆星時 = GMST + Δψ·cos(ε_true)(Meeus Ch. 12)
16
+ #
17
+ # 時間尺度慣例(呼叫者必須遵守,弄錯會差 ~ΔT ≈ 70s → 月亮 ~0.01°):
18
+ # - julday / gmst_deg / apparent_sidereal_deg 吃 **JD(UT)**
19
+ # - nutation / mean_obliquity / true_obliquity 吃 **JD(TT)**(先過 jd_tt 換算)
20
+ #
21
+ # 公開 API(module_function,皆回傳 Float 或 [Float, Float]):
22
+ # Core.julday(year, month, day, hour) -> JD(UT);hour 為十進位 UT 小時
23
+ # Core.delta_t(jd_ut) -> ΔT 秒(TT − UT)
24
+ # Core.jd_tt(jd_ut) -> JD(TT) = jd_ut + ΔT/86400
25
+ # Core.nutation(jd_tt) -> [Δψ_deg, Δε_deg](度)
26
+ # Core.mean_obliquity(jd_tt) -> ε₀(度)
27
+ # Core.true_obliquity(jd_tt) -> ε = ε₀ + Δε(度)
28
+ # Core.gmst_deg(jd_ut) -> 格林威治平恆星時(度,[0,360))
29
+ # Core.apparent_sidereal_deg(jd_ut) -> 格林威治視恆星時(度,[0,360))
30
+ # Core.norm360(deg) -> 正規化到 [0, 360)
31
+ # Core::DomainError -> 超出適用範圍時 raise(顯性失敗)
32
+ module Core
33
+ DEG2RAD = Math::PI / 180.0
34
+ RAD2DEG = 180.0 / Math::PI
35
+
36
+ # 超出公式適用範圍或演算法未收斂時丟出——失敗必須顯性,
37
+ # 不可靜默回傳一個看似正常、實則錯誤的值。
38
+ class DomainError < StandardError; end
39
+
40
+ module_function
41
+
42
+ # --- 儒略日(Meeus 7.1,Gregorian) ---
43
+ # hour 為十進位 UT 小時(例 1.5 = 01:30 UT)
44
+ def julday(year, month, day, hour)
45
+ y = year
46
+ m = month
47
+ if m <= 2
48
+ y -= 1
49
+ m += 12
50
+ end
51
+ a = (y / 100.0).floor
52
+ b = 2 - a + (a / 4.0).floor
53
+ (365.25 * (y + 4716)).floor + (30.6001 * (m + 1)).floor +
54
+ day + hour / 24.0 + b - 1524.5
55
+ end
56
+
57
+ # --- ΔT = TT − UT(秒) ---
58
+ # Espenak & Meeus 分段多項式(NASA Eclipse website,涵蓋 -1999..+3000)。
59
+ # 決策年 y 用儒略年近似(y = 2000 + (JD-J2000)/365.25),與 E&M 定義的
60
+ # y = year + (month-0.5)/12 差 < 半個月,對逐年平滑的 ΔT 曲線影響 < 0.01s。
61
+ # 注意:2005 之後為預測值,與實測(IERS)可差數秒 → 月亮 ~0.001°,
62
+ # 在本專案 0.01° 精度預算內。
63
+ def delta_t(jd_ut)
64
+ y = 2000.0 + (jd_ut - 2451545.0) / 365.25
65
+
66
+ if y < -500.0
67
+ u = (y - 1820.0) / 100.0
68
+ -20.0 + 32.0 * u * u
69
+ elsif y < 500.0
70
+ u = y / 100.0
71
+ 10583.6 - 1014.41 * u + 33.78311 * u**2 - 5.952053 * u**3 -
72
+ 0.1798452 * u**4 + 0.022174192 * u**5 + 0.0090316521 * u**6
73
+ elsif y < 1600.0
74
+ u = (y - 1000.0) / 100.0
75
+ 1574.2 - 556.01 * u + 71.23472 * u**2 + 0.319781 * u**3 -
76
+ 0.8503463 * u**4 - 0.005050998 * u**5 + 0.0083572073 * u**6
77
+ elsif y < 1700.0
78
+ t = y - 1600.0
79
+ 120.0 - 0.9808 * t - 0.01532 * t**2 + t**3 / 7129.0
80
+ elsif y < 1800.0
81
+ t = y - 1700.0
82
+ 8.83 + 0.1603 * t - 0.0059285 * t**2 + 0.00013336 * t**3 - t**4 / 1_174_000.0
83
+ elsif y < 1860.0
84
+ t = y - 1800.0
85
+ 13.72 - 0.332447 * t + 0.0068612 * t**2 + 0.0041116 * t**3 -
86
+ 0.00037436 * t**4 + 0.0000121272 * t**5 - 0.0000001699 * t**6 +
87
+ 0.000000000875 * t**7
88
+ elsif y < 1900.0
89
+ t = y - 1860.0
90
+ 7.62 + 0.5737 * t - 0.251754 * t**2 + 0.01680668 * t**3 -
91
+ 0.0004473624 * t**4 + t**5 / 233_174.0
92
+ elsif y < 1920.0
93
+ t = y - 1900.0
94
+ -2.79 + 1.494119 * t - 0.0598939 * t**2 + 0.0061966 * t**3 - 0.000197 * t**4
95
+ elsif y < 1941.0
96
+ t = y - 1920.0
97
+ 21.20 + 0.84493 * t - 0.076100 * t**2 + 0.0020936 * t**3
98
+ elsif y < 1961.0
99
+ t = y - 1950.0
100
+ 29.07 + 0.407 * t - t**2 / 233.0 + t**3 / 2547.0
101
+ elsif y < 1986.0
102
+ t = y - 1975.0
103
+ 45.45 + 1.067 * t - t**2 / 260.0 - t**3 / 718.0
104
+ elsif y < 2005.0
105
+ t = y - 2000.0
106
+ 63.86 + 0.3345 * t - 0.060374 * t**2 + 0.0017275 * t**3 +
107
+ 0.000651814 * t**4 + 0.00002373599 * t**5
108
+ elsif y < 2050.0
109
+ t = y - 2000.0
110
+ 62.92 + 0.32217 * t + 0.005589 * t**2
111
+ elsif y < 2150.0
112
+ -20.0 + 32.0 * ((y - 1820.0) / 100.0)**2 - 0.5628 * (2150.0 - y)
113
+ else
114
+ u = (y - 1820.0) / 100.0
115
+ -20.0 + 32.0 * u * u
116
+ end
117
+ end
118
+
119
+ # --- JD(TT) = JD(UT) + ΔT/86400 ---
120
+ def jd_tt(jd_ut)
121
+ jd_ut + delta_t(jd_ut) / 86_400.0
122
+ end
123
+
124
+ # --- 章動(Meeus Ch. 22,IAU 1980 截斷序列) ---
125
+ # 係數單位:0.0001 角秒。取振幅最大的 13 項(Δψ 主項 -17.1996" sinΩ 起),
126
+ # 截斷誤差 < 0.0004",遠小於 0.01° 目標。
127
+ # 各項引數為 [D, M, M', F, Ω] 的整數倍組合(Meeus Table 22.A 前段)。
128
+ NUTATION_TERMS = [
129
+ # [D, M, Mp, F, Om, psi_sin, psi_sin_t, eps_cos, eps_cos_t]
130
+ [0, 0, 0, 0, 1, -171_996.0, -174.2, 92_025.0, 8.9],
131
+ [-2, 0, 0, 2, 2, -13_187.0, -1.6, 5736.0, -3.1],
132
+ [0, 0, 0, 2, 2, -2274.0, -0.2, 977.0, -0.5],
133
+ [0, 0, 0, 0, 2, 2062.0, 0.2, -895.0, 0.5],
134
+ [0, 1, 0, 0, 0, 1426.0, -3.4, 54.0, -0.1],
135
+ [0, 0, 1, 0, 0, 712.0, 0.1, -7.0, 0.0],
136
+ [-2, 1, 0, 2, 2, -517.0, 1.2, 224.0, -0.6],
137
+ [0, 0, 0, 2, 1, -386.0, -0.4, 200.0, 0.0],
138
+ [0, 0, 1, 2, 2, -301.0, 0.0, 129.0, -0.1],
139
+ [-2, -1, 0, 2, 2, 217.0, -0.5, -95.0, 0.3],
140
+ [-2, 0, 1, 0, 0, -158.0, 0.0, 0.0, 0.0],
141
+ [-2, 0, 0, 2, 1, 129.0, 0.1, -70.0, 0.0],
142
+ [0, 0, -1, 2, 2, 123.0, 0.0, -53.0, 0.0]
143
+ ].freeze
144
+
145
+ # 回傳 [Δψ_deg, Δε_deg](度)。引數時間尺度為 **TT**。
146
+ def nutation(jd_tt)
147
+ t = (jd_tt - 2451545.0) / 36525.0
148
+
149
+ # 基本引數(Meeus Ch. 22,度)
150
+ d = 297.85036 + 445_267.111480 * t - 0.0019142 * t * t + t**3 / 189_474.0
151
+ m = 357.52772 + 35_999.050340 * t - 0.0001603 * t * t - t**3 / 300_000.0
152
+ mp = 134.96298 + 477_198.867398 * t + 0.0086972 * t * t + t**3 / 56_250.0
153
+ f = 93.27191 + 483_202.017538 * t - 0.0036825 * t * t + t**3 / 327_270.0
154
+ om = 125.04452 - 1934.136261 * t + 0.0020708 * t * t + t**3 / 450_000.0
155
+
156
+ dpsi_01as = 0.0 # 單位 0.0001"
157
+ deps_01as = 0.0
158
+ NUTATION_TERMS.each do |cd, cm, cmp, cf, com, ps, pst, ec, ect|
159
+ arg = (cd * d + cm * m + cmp * mp + cf * f + com * om) * DEG2RAD
160
+ dpsi_01as += (ps + pst * t) * Math.sin(arg)
161
+ deps_01as += (ec + ect * t) * Math.cos(arg)
162
+ end
163
+
164
+ [dpsi_01as * 0.0001 / 3600.0, deps_01as * 0.0001 / 3600.0]
165
+ end
166
+
167
+ # --- 平黃赤交角(Meeus 22.2,度)。引數時間尺度為 **TT**。 ---
168
+ # 23°26'21.448" - 46.8150"T - 0.00059"T² + 0.001813"T³
169
+ def mean_obliquity(jd_tt)
170
+ t = (jd_tt - 2451545.0) / 36525.0
171
+ 23.0 + 26.0 / 60.0 + 21.448 / 3600.0 -
172
+ (46.8150 * t + 0.00059 * t * t - 0.001813 * t**3) / 3600.0
173
+ end
174
+
175
+ # --- 真黃赤交角 ε = ε₀ + Δε(度)。引數時間尺度為 **TT**。 ---
176
+ def true_obliquity(jd_tt)
177
+ mean_obliquity(jd_tt) + nutation(jd_tt)[1]
178
+ end
179
+
180
+ # --- 平恆星時 GMST(Meeus 12.4 / IAU 1982),回傳度。引數為 **UT**。 ---
181
+ def gmst_deg(jd_ut)
182
+ t = (jd_ut - 2451545.0) / 36525.0
183
+ norm360(
184
+ 280.46061837 +
185
+ 360.98564736629 * (jd_ut - 2451545.0) +
186
+ 0.000387933 * t * t -
187
+ t * t * t / 38_710_000.0
188
+ )
189
+ end
190
+
191
+ # --- 視恆星時 = GMST + Δψ·cos(ε_true)(度)。引數為 **UT**。 ---
192
+ # 章動與交角的引數在內部換算為 TT。
193
+ def apparent_sidereal_deg(jd_ut)
194
+ tt = jd_tt(jd_ut)
195
+ dpsi_deg, = nutation(tt)
196
+ eps_rad = true_obliquity(tt) * DEG2RAD
197
+ norm360(gmst_deg(jd_ut) + dpsi_deg * Math.cos(eps_rad))
198
+ end
199
+
200
+ # --- 正規化到 [0, 360) ---
201
+ def norm360(deg)
202
+ deg %= 360.0
203
+ # Ruby 的 float % 對正模數永不為負,但極小負數(如 -1e-16 % 360.0)
204
+ # 會因浮點捨入得到恰好 360.0,違反 [0, 360) 契約 → 下游 floor(deg/30) 會得 12。
205
+ deg >= 360.0 ? 0.0 : deg
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core"
4
+
5
+ module AstroChart
6
+ module Pure
7
+ # Houses — MIT-safe 純 Ruby Placidus 宮位(ASC / MC / 12 宮頭)。
8
+ #
9
+ # 零外部依賴、不參考 Swiss Ephemeris C 原始碼,僅使用公開文獻公式:
10
+ # - 視恆星時 / 章動 / 黃赤交角:見 Core(Meeus Ch. 12 / 22)
11
+ # - ASC / MC:標準球面天文公式(Meeus Ch. 13 座標轉換推得)
12
+ # - Placidus 宮頭:古典半弧迭代法(半日弧 SA = arccos(-tanφ·tanδ),
13
+ # 宮頭 11/12 位於 RAMC 東側 1/3、2/3 半日弧處;宮頭 2/3 以半夜弧對應)
14
+ #
15
+ # 公開 API:
16
+ # Houses.calc(jd_ut, lat, lon)
17
+ # -> { "cusps" => [12 floats], "ascendant" => Float, "mc" => Float }
18
+ # 欄位與 AstroChart::Ext.houses(jd, lat, lon, "P".ord) 相同。
19
+ #
20
+ # 適用範圍:|lat| < ~66°(Placidus 在極圈內無定義)。
21
+ # 超出範圍或迭代未收斂時 raise Core::DomainError(顯性失敗,不靜默 clamp)。
22
+ module Houses
23
+ DEG2RAD = Core::DEG2RAD
24
+ RAD2DEG = Core::RAD2DEG
25
+
26
+ module_function
27
+
28
+ # jd_ut: UT 儒略日;lat: 地理緯度(北正);lon: 地理經度(東正)
29
+ def calc(jd_ut, lat, lon)
30
+ tt = Core.jd_tt(jd_ut)
31
+ eps = Core.true_obliquity(tt) * DEG2RAD # 真黃赤交角(弧度)
32
+ ast_deg = Core.apparent_sidereal_deg(jd_ut) # 視恆星時(度)
33
+ armc = Core.norm360(ast_deg + lon) # 天頂赤經 RAMC
34
+
35
+ phi = lat * DEG2RAD
36
+ mc = mc_longitude(armc, eps)
37
+ asc = asc_longitude(armc, eps, phi)
38
+
39
+ # Placidus 半弧迭代:宮頭 11、12(地平上,RAMC 東側)與 2、3(地平下)
40
+ c11 = placidus_cusp(armc, eps, phi, 30.0, 1.0 / 3.0, :diurnal)
41
+ c12 = placidus_cusp(armc, eps, phi, 60.0, 2.0 / 3.0, :diurnal)
42
+ c2 = placidus_cusp(armc, eps, phi, 120.0, 2.0 / 3.0, :nocturnal)
43
+ c3 = placidus_cusp(armc, eps, phi, 150.0, 1.0 / 3.0, :nocturnal)
44
+
45
+ cusps = Array.new(12)
46
+ cusps[0] = asc # 第 1 宮 = ASC
47
+ cusps[1] = c2
48
+ cusps[2] = c3
49
+ cusps[3] = Core.norm360(mc + 180.0) # 第 4 宮 = MC 對沖
50
+ cusps[4] = Core.norm360(c11 + 180.0)
51
+ cusps[5] = Core.norm360(c12 + 180.0)
52
+ cusps[6] = Core.norm360(asc + 180.0) # 第 7 宮 = ASC 對沖
53
+ cusps[7] = Core.norm360(c2 + 180.0)
54
+ cusps[8] = Core.norm360(c3 + 180.0)
55
+ cusps[9] = mc # 第 10 宮 = MC
56
+ cusps[10] = c11
57
+ cusps[11] = c12
58
+
59
+ { "cusps" => cusps, "ascendant" => asc, "mc" => mc }
60
+ end
61
+
62
+ # --- MC:天頂赤經 → 黃道經度 ---
63
+ # tan λ_MC = tan(ARMC) / cos ε,以 atan2 處理象限
64
+ def mc_longitude(armc_deg, eps)
65
+ ra = armc_deg * DEG2RAD
66
+ Core.norm360(Math.atan2(Math.sin(ra), Math.cos(ra) * Math.cos(eps)) * RAD2DEG)
67
+ end
68
+
69
+ # --- ASC:標準公式 ---
70
+ # λ_ASC = atan2( cos ARMC, -(sin ARMC · cos ε + tan φ · sin ε) )
71
+ def asc_longitude(armc_deg, eps, phi)
72
+ ra = armc_deg * DEG2RAD
73
+ Core.norm360(
74
+ Math.atan2(
75
+ Math.cos(ra),
76
+ -(Math.sin(ra) * Math.cos(eps) + Math.tan(phi) * Math.sin(eps))
77
+ ) * RAD2DEG
78
+ )
79
+ end
80
+
81
+ # --- Placidus 宮頭半弧迭代 ---
82
+ # 定義:黃道上一點 (α, δ),半日弧 SA_d = arccos(-tanφ·tanδ)、半夜弧 SA_n = arccos(tanφ·tanδ)。
83
+ # 宮頭 11:α = RAMC + (1/3)·SA_d 宮頭 12:α = RAMC + (2/3)·SA_d
84
+ # 宮頭 2 :α = RAMC + 180 - (2/3)·SA_n 宮頭 3 :α = RAMC + 180 - (1/3)·SA_n
85
+ # 黃道點的赤緯由 tan δ = tan ε · sin α 得出,固定點迭代至收斂。
86
+ # offset_deg:初值偏移(赤道情形的精確解);frac:半弧比例;mode:地平上/下。
87
+ def placidus_cusp(armc_deg, eps, phi, offset_deg, frac, mode)
88
+ tan_phi = Math.tan(phi)
89
+ tan_eps = Math.tan(eps)
90
+ ra = (armc_deg + offset_deg) * DEG2RAD
91
+ armc_rad = armc_deg * DEG2RAD
92
+
93
+ converged = false
94
+ 60.times do
95
+ dec = Math.atan(tan_eps * Math.sin(ra)) # 黃道點赤緯
96
+ x = tan_phi * Math.tan(dec)
97
+ if x.abs > 1.0
98
+ # 極圈內該點無升落,Placidus 無定義。顯性失敗,不靜默 clamp。
99
+ raise Core::DomainError,
100
+ "Placidus 在此緯度無定義(|tanφ·tanδ| = #{x.abs.round(4)} > 1,lat=#{(phi * RAD2DEG).round(4)}°)"
101
+ end
102
+ new_ra =
103
+ if mode == :diurnal
104
+ armc_rad + frac * Math.acos(-x)
105
+ else
106
+ armc_rad + Math::PI - frac * Math.acos(x)
107
+ end
108
+ step = (new_ra - ra).abs
109
+ ra = new_ra # 先採納本次結果,收斂時回傳的才是最終值
110
+ if step < 1e-9 * DEG2RAD
111
+ converged = true
112
+ break
113
+ end
114
+ end
115
+ raise Core::DomainError, "Placidus 宮頭迭代 60 次未收斂" unless converged
116
+
117
+ # 赤經 → 黃道經度:tan λ = tan α / cos ε
118
+ Core.norm360(Math.atan2(Math.sin(ra), Math.cos(ra) * Math.cos(eps)) * RAD2DEG)
119
+ end
120
+ end
121
+ end
122
+ end