sekki24 1.0.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/Rakefile +8 -0
- data/exe/sekki24 +104 -0
- data/lib/sekki24/delta_t.rb +67 -0
- data/lib/sekki24/finder.rb +112 -0
- data/lib/sekki24/kou.rb +49 -0
- data/lib/sekki24/kou_names.rb +60 -0
- data/lib/sekki24/lunar/moon.rb +66 -0
- data/lib/sekki24/lunar/periodic_terms.rb +31 -0
- data/lib/sekki24/lunar/phase_finder.rb +157 -0
- data/lib/sekki24/lunisolar/calendar.rb +129 -0
- data/lib/sekki24/lunisolar/date.rb +58 -0
- data/lib/sekki24/lunisolar/month.rb +67 -0
- data/lib/sekki24/lunisolar/month_builder.rb +143 -0
- data/lib/sekki24/names.rb +63 -0
- data/lib/sekki24/nutation.rb +31 -0
- data/lib/sekki24/seventy_two_kou.rb +129 -0
- data/lib/sekki24/solar/fast.rb +41 -0
- data/lib/sekki24/solar/precise.rb +45 -0
- data/lib/sekki24/solar/vsop87_earth.rb +227 -0
- data/lib/sekki24/solar.rb +21 -0
- data/lib/sekki24/term.rb +52 -0
- data/lib/sekki24/time_scale.rb +94 -0
- data/lib/sekki24/version.rb +5 -0
- data/lib/sekki24/zassetsu.rb +50 -0
- data/lib/sekki24/zassetsu_calendar.rb +176 -0
- data/lib/sekki24.rb +146 -0
- data/sekki24.gemspec +31 -0
- metadata +75 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "periodic_terms"
|
|
4
|
+
require_relative "../nutation"
|
|
5
|
+
|
|
6
|
+
module Sekki24
|
|
7
|
+
module Lunar
|
|
8
|
+
module Moon
|
|
9
|
+
J2000 = 2_451_545.0
|
|
10
|
+
JULIAN_CENTURY = 36_525.0
|
|
11
|
+
DEGREES_TO_RADIANS = Math::PI / 180.0
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def longitude(jde)
|
|
16
|
+
centuries = (Float(jde) - J2000) / JULIAN_CENTURY
|
|
17
|
+
arguments = fundamental_arguments(centuries)
|
|
18
|
+
correction = periodic_correction(arguments, centuries) + additive_correction(arguments, centuries)
|
|
19
|
+
|
|
20
|
+
(arguments.fetch(:moon_longitude) + correction + Nutation.longitude(jde)) % 360.0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fundamental_arguments(t)
|
|
24
|
+
{
|
|
25
|
+
moon_longitude: 218.3164477 + (481_267.88123421 * t) - (0.0015786 * t**2) +
|
|
26
|
+
(t**3 / 538_841.0) - (t**4 / 65_194_000.0),
|
|
27
|
+
elongation: 297.8501921 + (445_267.1114034 * t) - (0.0018819 * t**2) +
|
|
28
|
+
(t**3 / 545_868.0) - (t**4 / 113_065_000.0),
|
|
29
|
+
sun_anomaly: 357.5291092 + (35_999.0502909 * t) - (0.0001536 * t**2) +
|
|
30
|
+
(t**3 / 24_490_000.0),
|
|
31
|
+
moon_anomaly: 134.9633964 + (477_198.8675055 * t) + (0.0087414 * t**2) +
|
|
32
|
+
(t**3 / 69_699.0) - (t**4 / 14_712_000.0),
|
|
33
|
+
latitude_argument: 93.2720950 + (483_202.0175233 * t) - (0.0036539 * t**2) -
|
|
34
|
+
(t**3 / 3_526_000.0) + (t**4 / 863_310_000.0)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
private_class_method :fundamental_arguments
|
|
38
|
+
|
|
39
|
+
def periodic_correction(arguments, centuries)
|
|
40
|
+
eccentricity = 1.0 - (0.002516 * centuries) - (0.0000074 * centuries**2)
|
|
41
|
+
sum = PeriodicTerms::LONGITUDE.sum do |d, m, moon_m, f, coefficient|
|
|
42
|
+
angle = (d * arguments.fetch(:elongation)) + (m * arguments.fetch(:sun_anomaly)) +
|
|
43
|
+
(moon_m * arguments.fetch(:moon_anomaly)) + (f * arguments.fetch(:latitude_argument))
|
|
44
|
+
coefficient * eccentricity**m.abs * sin_degrees(angle)
|
|
45
|
+
end
|
|
46
|
+
sum / 1_000_000.0
|
|
47
|
+
end
|
|
48
|
+
private_class_method :periodic_correction
|
|
49
|
+
|
|
50
|
+
def additive_correction(arguments, centuries)
|
|
51
|
+
a1 = 119.75 + (131.849 * centuries)
|
|
52
|
+
a2 = 53.09 + (479_264.290 * centuries)
|
|
53
|
+
correction = (3_958 * sin_degrees(a1)) +
|
|
54
|
+
(1_962 * sin_degrees(arguments.fetch(:moon_longitude) - arguments.fetch(:latitude_argument))) +
|
|
55
|
+
(318 * sin_degrees(a2))
|
|
56
|
+
correction / 1_000_000.0
|
|
57
|
+
end
|
|
58
|
+
private_class_method :additive_correction
|
|
59
|
+
|
|
60
|
+
def sin_degrees(angle)
|
|
61
|
+
Math.sin(angle * DEGREES_TO_RADIANS)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :sin_degrees
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
module Lunar
|
|
5
|
+
module PeriodicTerms
|
|
6
|
+
# D, M, M', F and longitude coefficient in millionths of a degree.
|
|
7
|
+
LONGITUDE = [
|
|
8
|
+
[0, 0, 1, 0, 6_288_774], [2, 0, -1, 0, 1_274_027], [2, 0, 0, 0, 658_314],
|
|
9
|
+
[0, 0, 2, 0, 213_618], [0, 1, 0, 0, -185_116], [0, 0, 0, 2, -114_332],
|
|
10
|
+
[2, 0, -2, 0, 58_793], [2, -1, -1, 0, 57_066], [2, 0, 1, 0, 53_322],
|
|
11
|
+
[2, -1, 0, 0, 45_758], [0, 1, -1, 0, -40_923], [1, 0, 0, 0, -34_720],
|
|
12
|
+
[0, 1, 1, 0, -30_383], [2, 0, 0, -2, 15_327], [0, 0, 1, 2, -12_528],
|
|
13
|
+
[0, 0, 1, -2, 10_980], [4, 0, -1, 0, 10_675], [0, 0, 3, 0, 10_034],
|
|
14
|
+
[4, 0, -2, 0, 8_548], [2, 1, -1, 0, -7_888], [2, 1, 0, 0, -6_766],
|
|
15
|
+
[1, 0, -1, 0, -5_163], [1, 1, 0, 0, 4_987], [2, -1, 1, 0, 4_036],
|
|
16
|
+
[2, 0, 2, 0, 3_994], [4, 0, 0, 0, 3_861], [2, 0, -3, 0, 3_665],
|
|
17
|
+
[0, 1, -2, 0, -2_689], [2, 0, -1, 2, -2_602], [2, -1, -2, 0, 2_390],
|
|
18
|
+
[1, 0, 1, 0, -2_348], [2, -2, 0, 0, 2_236], [0, 1, 2, 0, -2_120],
|
|
19
|
+
[0, 2, 0, 0, -2_069], [2, -2, -1, 0, 2_048], [2, 0, 1, -2, -1_773],
|
|
20
|
+
[2, 0, 0, 2, -1_595], [4, -1, -1, 0, 1_215], [0, 0, 2, 2, -1_110],
|
|
21
|
+
[3, 0, -1, 0, -892], [2, 1, 1, 0, -810], [4, -1, -2, 0, 759],
|
|
22
|
+
[0, 2, -1, 0, -713], [2, 2, -1, 0, -700], [2, 1, -2, 0, 691],
|
|
23
|
+
[2, -1, 0, -2, 596], [4, 0, 1, 0, 549], [0, 0, 4, 0, 537],
|
|
24
|
+
[4, -1, 0, 0, 520], [1, 0, -2, 0, -487], [2, 1, 0, -2, -399],
|
|
25
|
+
[0, 0, 2, -2, -381], [1, 1, 1, 0, 351], [3, 0, -2, 0, -340],
|
|
26
|
+
[4, 0, -3, 0, 330], [2, -1, 2, 0, 327], [0, 2, 1, 0, -323],
|
|
27
|
+
[1, 1, -1, 0, 299], [2, 0, 3, 0, 294]
|
|
28
|
+
].map(&:freeze).freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "moon"
|
|
4
|
+
|
|
5
|
+
module Sekki24
|
|
6
|
+
module Lunar
|
|
7
|
+
module PhaseFinder
|
|
8
|
+
MEAN_NEW_MOON_EPOCH = 2_451_550.09765
|
|
9
|
+
SYNODIC_MONTH = 29.530588853
|
|
10
|
+
DERIVATIVE_HALF_WINDOW = 1.0 / 24.0
|
|
11
|
+
ANGULAR_TOLERANCE = 1e-7
|
|
12
|
+
NEWTON_ATTEMPTS = 8
|
|
13
|
+
|
|
14
|
+
@cache_mutex = Mutex.new
|
|
15
|
+
@root_cache = {}
|
|
16
|
+
@year_cache = {}
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def new_moons(year, tz:)
|
|
20
|
+
calendar_year = validate_year(year)
|
|
21
|
+
new_moons_for_calendar(calendar_year, tz: tz)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def new_moons_for_calendar(calendar_year, tz:)
|
|
25
|
+
cache_key = [calendar_year, TimeScale.timezone_key(tz)].freeze
|
|
26
|
+
cached = @cache_mutex.synchronize { @year_cache[cache_key] }
|
|
27
|
+
return cached if cached
|
|
28
|
+
|
|
29
|
+
roots = roots_near_year(calendar_year).filter_map do |time|
|
|
30
|
+
local = TimeScale.localize(time, tz)
|
|
31
|
+
local.freeze if local.year == calendar_year
|
|
32
|
+
end.freeze
|
|
33
|
+
@cache_mutex.synchronize { @year_cache[cache_key] ||= roots }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def new_moon_before(time)
|
|
37
|
+
raise TypeError, "expected Time, got #{time.class}" unless time.is_a?(Time)
|
|
38
|
+
|
|
39
|
+
jde = TimeScale.utc_to_jde(time)
|
|
40
|
+
lunation = ((jde - MEAN_NEW_MOON_EPOCH) / SYNODIC_MONTH).floor
|
|
41
|
+
root = root_for(lunation)
|
|
42
|
+
root > time ? root_for(lunation - 1) : root
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def new_moon_after(time)
|
|
46
|
+
raise TypeError, "expected Time, got #{time.class}" unless time.is_a?(Time)
|
|
47
|
+
|
|
48
|
+
jde = TimeScale.utc_to_jde(time)
|
|
49
|
+
lunation = ((jde - MEAN_NEW_MOON_EPOCH) / SYNODIC_MONTH).ceil
|
|
50
|
+
root = root_for(lunation)
|
|
51
|
+
root <= time ? root_for(lunation + 1) : root
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear_cache!
|
|
55
|
+
@cache_mutex.synchronize do
|
|
56
|
+
@root_cache.clear
|
|
57
|
+
@year_cache.clear
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def roots_near_year(year)
|
|
64
|
+
start_jde = TimeScale.utc_to_jde(Time.utc(year, 1, 1))
|
|
65
|
+
end_jde = TimeScale.utc_to_jde(Time.utc(year + 1, 1, 1))
|
|
66
|
+
first = ((start_jde - MEAN_NEW_MOON_EPOCH) / SYNODIC_MONTH).floor - 2
|
|
67
|
+
last = ((end_jde - MEAN_NEW_MOON_EPOCH) / SYNODIC_MONTH).ceil + 2
|
|
68
|
+
(first..last).map { |lunation| root_for(lunation) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def root_for(lunation)
|
|
72
|
+
cached = @cache_mutex.synchronize { @root_cache[lunation] }
|
|
73
|
+
return cached if cached
|
|
74
|
+
|
|
75
|
+
estimate = MEAN_NEW_MOON_EPOCH + (SYNODIC_MONTH * lunation)
|
|
76
|
+
utc = TimeScale.jde_to_utc(solve(estimate)).freeze
|
|
77
|
+
@cache_mutex.synchronize { @root_cache[lunation] ||= utc }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def solve(estimate)
|
|
81
|
+
jde = estimate
|
|
82
|
+
NEWTON_ATTEMPTS.times do
|
|
83
|
+
error = phase_error(jde)
|
|
84
|
+
return jde if error.abs < ANGULAR_TOLERANCE
|
|
85
|
+
|
|
86
|
+
derivative = numerical_derivative(jde)
|
|
87
|
+
break unless derivative.finite? && derivative.positive?
|
|
88
|
+
|
|
89
|
+
step = error / derivative
|
|
90
|
+
break if step.abs > 2.0
|
|
91
|
+
|
|
92
|
+
jde -= step
|
|
93
|
+
end
|
|
94
|
+
bisect(estimate - 2.0, estimate + 2.0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def numerical_derivative(jde)
|
|
98
|
+
before = phase_error(jde - DERIVATIVE_HALF_WINDOW)
|
|
99
|
+
after = phase_error(jde + DERIVATIVE_HALF_WINDOW)
|
|
100
|
+
signed_difference(after, before) / (2.0 * DERIVATIVE_HALF_WINDOW)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def bisect(lower, upper)
|
|
104
|
+
lower_error = phase_error(lower)
|
|
105
|
+
80.times do
|
|
106
|
+
midpoint = (lower + upper) / 2.0
|
|
107
|
+
midpoint_error = phase_error(midpoint)
|
|
108
|
+
return midpoint if midpoint_error.abs < ANGULAR_TOLERANCE
|
|
109
|
+
|
|
110
|
+
if lower_error * midpoint_error <= 0.0
|
|
111
|
+
upper = midpoint
|
|
112
|
+
else
|
|
113
|
+
lower = midpoint
|
|
114
|
+
lower_error = midpoint_error
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
(lower + upper) / 2.0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def phase_error(jde)
|
|
121
|
+
signed_difference(Moon.longitude(jde), Solar::Precise.longitude(jde))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def signed_difference(left, right)
|
|
125
|
+
((left - right + 180.0) % 360.0) - 180.0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_year(year)
|
|
129
|
+
value = Integer(year)
|
|
130
|
+
return value if (MIN_YEAR..MAX_YEAR).cover?(value)
|
|
131
|
+
|
|
132
|
+
raise RangeError, "year must be between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
133
|
+
rescue ArgumentError, TypeError
|
|
134
|
+
raise ArgumentError, "year must be an Integer between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class << self
|
|
141
|
+
def new_moons(year, tz: DEFAULT_TIMEZONE)
|
|
142
|
+
Lunar::PhaseFinder.new_moons(year, tz: tz)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def new_moon_before(time = Time.now)
|
|
146
|
+
Lunar::PhaseFinder.new_moon_before(time)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def new_moon_after(time = Time.now)
|
|
150
|
+
Lunar::PhaseFinder.new_moon_after(time)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def moon_longitude(time)
|
|
154
|
+
Lunar::Moon.longitude(TimeScale.utc_to_jde(time))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "month"
|
|
4
|
+
require_relative "date"
|
|
5
|
+
require_relative "month_builder"
|
|
6
|
+
|
|
7
|
+
module Sekki24
|
|
8
|
+
module Lunisolar
|
|
9
|
+
module Calendar
|
|
10
|
+
LEAP_MONTH_RULE = :winter_solstice_priority
|
|
11
|
+
MIN_LUNISOLAR_YEAR = MIN_YEAR - 1
|
|
12
|
+
MAX_LUNISOLAR_YEAR = MAX_YEAR
|
|
13
|
+
@cache_mutex = Mutex.new
|
|
14
|
+
@range_cache = {}
|
|
15
|
+
@year_cache = {}
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def convert(value, tz:)
|
|
19
|
+
local_date = local_date(value, tz)
|
|
20
|
+
validate_gregorian_year(local_date.year)
|
|
21
|
+
month = generated_range(local_date.year, tz).find { |entry| entry.include?(local_date) }
|
|
22
|
+
raise Error, "could not assign lunisolar month for #{local_date}" unless month
|
|
23
|
+
|
|
24
|
+
Date.new(
|
|
25
|
+
year: month.year,
|
|
26
|
+
month: month.month,
|
|
27
|
+
day: (local_date - month.start_date).to_i + 1,
|
|
28
|
+
leap: month.leap?,
|
|
29
|
+
gregorian_date: local_date,
|
|
30
|
+
month_length: month.length
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def year(year, tz:)
|
|
35
|
+
lunar_year = validate_lunar_year(year)
|
|
36
|
+
cache_key = [lunar_year, TimeScale.timezone_key(tz)].freeze
|
|
37
|
+
cached = @cache_mutex.synchronize { @year_cache[cache_key] }
|
|
38
|
+
return cached if cached
|
|
39
|
+
|
|
40
|
+
months = generated_range(lunar_year, tz).select { |month| month.year == lunar_year }.freeze
|
|
41
|
+
unless [12, 13].include?(months.length)
|
|
42
|
+
raise Error, "expected 12 or 13 lunisolar months for #{lunar_year}, got #{months.length}"
|
|
43
|
+
end
|
|
44
|
+
@cache_mutex.synchronize { @year_cache[cache_key] ||= months }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_gregorian(year, month, day, leap:, tz:)
|
|
48
|
+
lunar_year = validate_lunar_year(year)
|
|
49
|
+
month_number = Integer(month)
|
|
50
|
+
day_number = Integer(day)
|
|
51
|
+
raise ArgumentError, "leap must be true or false" unless [true, false].include?(leap)
|
|
52
|
+
unless (1..12).cover?(month_number)
|
|
53
|
+
raise ArgumentError, "lunar month must be between 1 and 12"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
target = self.year(lunar_year, tz: tz).find do |entry|
|
|
57
|
+
entry.month == month_number && entry.leap? == leap
|
|
58
|
+
end
|
|
59
|
+
raise ArgumentError, "lunisolar month does not exist" unless target
|
|
60
|
+
unless (1..target.length).cover?(day_number)
|
|
61
|
+
raise ArgumentError, "lunar day must be between 1 and #{target.length}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result = target.start_date + day_number - 1
|
|
65
|
+
validate_gregorian_year(result.year)
|
|
66
|
+
result
|
|
67
|
+
rescue TypeError
|
|
68
|
+
raise ArgumentError, "lunar month and day must be Integers"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def clear_cache!
|
|
72
|
+
@cache_mutex.synchronize do
|
|
73
|
+
@range_cache.clear
|
|
74
|
+
@year_cache.clear
|
|
75
|
+
end
|
|
76
|
+
MonthBuilder.clear_cache!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def generated_range(center_year, timezone)
|
|
82
|
+
cache_key = [center_year, TimeScale.timezone_key(timezone)].freeze
|
|
83
|
+
cached = @cache_mutex.synchronize { @range_cache[cache_key] }
|
|
84
|
+
return cached if cached
|
|
85
|
+
|
|
86
|
+
years = ((center_year - 2)..(center_year + 2)).to_a
|
|
87
|
+
generated = MonthBuilder.build(years, timezone)
|
|
88
|
+
@cache_mutex.synchronize { @range_cache[cache_key] ||= generated }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def local_date(value, timezone)
|
|
92
|
+
return value if value.instance_of?(::Date)
|
|
93
|
+
return TimeScale.localize(value, timezone).to_date if value.is_a?(Time)
|
|
94
|
+
|
|
95
|
+
raise TypeError, "expected Date or Time, got #{value.class}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_gregorian_year(year)
|
|
99
|
+
return year if (MIN_YEAR..MAX_YEAR).cover?(year)
|
|
100
|
+
|
|
101
|
+
raise RangeError, "date must fall within years #{MIN_YEAR}..#{MAX_YEAR}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_lunar_year(year)
|
|
105
|
+
value = Integer(year)
|
|
106
|
+
return value if (MIN_LUNISOLAR_YEAR..MAX_LUNISOLAR_YEAR).cover?(value)
|
|
107
|
+
|
|
108
|
+
raise RangeError, "lunisolar year must be between #{MIN_LUNISOLAR_YEAR} and #{MAX_LUNISOLAR_YEAR}"
|
|
109
|
+
rescue ArgumentError, TypeError
|
|
110
|
+
raise ArgumentError, "lunisolar year must be an Integer"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
def lunisolar(value, tz: DEFAULT_TIMEZONE)
|
|
118
|
+
Lunisolar::Calendar.convert(value, tz: tz)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def lunisolar_year(year, tz: DEFAULT_TIMEZONE)
|
|
122
|
+
Lunisolar::Calendar.year(year, tz: tz)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def gregorian(lunar_year, month, day, leap: false, tz: DEFAULT_TIMEZONE)
|
|
126
|
+
Lunisolar::Calendar.to_gregorian(lunar_year, month, day, leap: leap, tz: tz)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
module Lunisolar
|
|
5
|
+
class Date
|
|
6
|
+
attr_reader :year, :month, :day, :gregorian_date, :month_length
|
|
7
|
+
|
|
8
|
+
def initialize(year:, month:, day:, leap:, gregorian_date:, month_length:)
|
|
9
|
+
@year = year
|
|
10
|
+
@month = month
|
|
11
|
+
@day = day
|
|
12
|
+
@leap = leap
|
|
13
|
+
@gregorian_date = gregorian_date.freeze
|
|
14
|
+
@month_length = month_length
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def leap?
|
|
19
|
+
@leap
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def month_name_ja
|
|
23
|
+
prefix = leap? ? "閏" : ""
|
|
24
|
+
"#{prefix}#{Month::MONTH_NAMES.fetch(month - 1)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_date
|
|
28
|
+
gregorian_date
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(Date) && year == other.year && month == other.month &&
|
|
33
|
+
day == other.day && leap? == other.leap?
|
|
34
|
+
end
|
|
35
|
+
alias eql? ==
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
[self.class, year, month, day, leap?].hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
year: year,
|
|
44
|
+
month: month,
|
|
45
|
+
day: day,
|
|
46
|
+
leap: leap?,
|
|
47
|
+
month_name_ja: month_name_ja,
|
|
48
|
+
month_length: month_length,
|
|
49
|
+
gregorian_date: gregorian_date
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def inspect
|
|
54
|
+
"#<#{self.class} #{year}年#{month_name_ja}#{day}日 (#{gregorian_date.iso8601})>"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
module Lunisolar
|
|
5
|
+
class Month
|
|
6
|
+
MONTH_NAMES = %w[正月 二月 三月 四月 五月 六月 七月 八月 九月 十月 十一月 十二月].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :year, :month, :start_date, :end_date, :new_moon_time, :principal_longitudes
|
|
9
|
+
|
|
10
|
+
def initialize(year:, month:, leap:, start_date:, end_date:, new_moon_time:, principal_longitudes:)
|
|
11
|
+
@year = year
|
|
12
|
+
@month = month
|
|
13
|
+
@leap = leap
|
|
14
|
+
@start_date = start_date.freeze
|
|
15
|
+
@end_date = end_date.freeze
|
|
16
|
+
@new_moon_time = new_moon_time.dup.freeze
|
|
17
|
+
@principal_longitudes = principal_longitudes.dup.freeze
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def leap?
|
|
22
|
+
@leap
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def length
|
|
26
|
+
(end_date - start_date).to_i + 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name_ja
|
|
30
|
+
prefix = leap? ? "閏" : ""
|
|
31
|
+
"#{prefix}#{MONTH_NAMES.fetch(month - 1)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def include?(date)
|
|
35
|
+
(start_date..end_date).cover?(date)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ==(other)
|
|
39
|
+
other.is_a?(Month) && year == other.year && month == other.month &&
|
|
40
|
+
leap? == other.leap? && start_date == other.start_date
|
|
41
|
+
end
|
|
42
|
+
alias eql? ==
|
|
43
|
+
|
|
44
|
+
def hash
|
|
45
|
+
[self.class, year, month, leap?, start_date].hash
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
year: year,
|
|
51
|
+
month: month,
|
|
52
|
+
leap: leap?,
|
|
53
|
+
name_ja: name_ja,
|
|
54
|
+
start_date: start_date,
|
|
55
|
+
end_date: end_date,
|
|
56
|
+
length: length,
|
|
57
|
+
new_moon_time: new_moon_time,
|
|
58
|
+
principal_longitudes: principal_longitudes
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inspect
|
|
63
|
+
"#<#{self.class} #{year}年#{name_ja} #{start_date.iso8601}..#{end_date.iso8601}>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
module Lunisolar
|
|
5
|
+
module MonthBuilder
|
|
6
|
+
PRINCIPAL_LONGITUDES = (0.step(330, 30).to_a).freeze
|
|
7
|
+
|
|
8
|
+
RawMonth = Struct.new(
|
|
9
|
+
:start_date,
|
|
10
|
+
:end_date,
|
|
11
|
+
:new_moon_time,
|
|
12
|
+
:principal_longitudes,
|
|
13
|
+
:number,
|
|
14
|
+
:leap,
|
|
15
|
+
:year,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@cache_mutex = Mutex.new
|
|
20
|
+
@principal_cache = {}
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def build(years, timezone)
|
|
24
|
+
terms = principal_terms(years, timezone)
|
|
25
|
+
months = build_raw_months(years, timezone, terms)
|
|
26
|
+
assign_month_numbers(months, terms)
|
|
27
|
+
assign_lunar_years(months)
|
|
28
|
+
months.filter_map { |raw| build_month(raw) }.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear_cache!
|
|
32
|
+
@cache_mutex.synchronize { @principal_cache.clear }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_raw_months(years, timezone, principal_terms)
|
|
38
|
+
moons = years.flat_map do |year|
|
|
39
|
+
Lunar::PhaseFinder.new_moons_for_calendar(year, tz: timezone)
|
|
40
|
+
end.uniq.sort
|
|
41
|
+
moons.each_cons(2).map do |start_time, next_time|
|
|
42
|
+
start_date = start_time.to_date
|
|
43
|
+
end_date = next_time.to_date - 1
|
|
44
|
+
terms = principal_terms.select { |term| (start_date..end_date).cover?(term.fetch(:date)) }
|
|
45
|
+
RawMonth.new(
|
|
46
|
+
start_date: start_date,
|
|
47
|
+
end_date: end_date,
|
|
48
|
+
new_moon_time: start_time,
|
|
49
|
+
principal_longitudes: terms.map { |term| term.fetch(:longitude) }.freeze
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def principal_terms(years, timezone)
|
|
55
|
+
years.flat_map { |year| principal_terms_for_year(year, timezone) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def principal_terms_for_year(year, timezone)
|
|
59
|
+
cache_key = [year, TimeScale.timezone_key(timezone)].freeze
|
|
60
|
+
cached = @cache_mutex.synchronize { @principal_cache[cache_key] }
|
|
61
|
+
return cached if cached
|
|
62
|
+
|
|
63
|
+
calculated = PRINCIPAL_LONGITUDES.map do |longitude|
|
|
64
|
+
utc = Finder.find(year: year, longitude: longitude, solar: Solar::Precise)
|
|
65
|
+
local = TimeScale.localize(utc, timezone)
|
|
66
|
+
{ longitude: longitude, date: local.to_date }.freeze
|
|
67
|
+
end.freeze
|
|
68
|
+
@cache_mutex.synchronize { @principal_cache[cache_key] ||= calculated }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assign_month_numbers(months, principal_terms)
|
|
72
|
+
solstice_dates = principal_terms.select { |term| term.fetch(:longitude) == 270 }.map { |term| term.fetch(:date) }
|
|
73
|
+
anchors = solstice_dates.filter_map do |date|
|
|
74
|
+
index = months.index { |month| (month.start_date..month.end_date).cover?(date) }
|
|
75
|
+
[index, date] if index
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
anchors.each_cons(2) do |(left_index, _), (right_index, _)|
|
|
79
|
+
assign_solstice_segment(months, left_index, right_index)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def assign_solstice_segment(months, first_index, next_index)
|
|
84
|
+
span = next_index - first_index
|
|
85
|
+
unless [12, 13].include?(span)
|
|
86
|
+
raise Error, "unexpected lunisolar month count between winter solstices: #{span}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
leap_index = find_leap_index(months, first_index, next_index, span)
|
|
90
|
+
number = 11
|
|
91
|
+
(first_index...next_index).each do |index|
|
|
92
|
+
month = months.fetch(index)
|
|
93
|
+
if index == leap_index
|
|
94
|
+
month.leap = true
|
|
95
|
+
else
|
|
96
|
+
number = (number % 12) + 1 unless index == first_index
|
|
97
|
+
month.leap = false
|
|
98
|
+
end
|
|
99
|
+
month.number = number
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_leap_index(months, first_index, next_index, span)
|
|
104
|
+
return unless span == 13
|
|
105
|
+
|
|
106
|
+
index = ((first_index + 1)...next_index).find do |candidate|
|
|
107
|
+
months.fetch(candidate).principal_longitudes.empty?
|
|
108
|
+
end
|
|
109
|
+
raise Error, "could not identify leap month" unless index
|
|
110
|
+
|
|
111
|
+
index
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def assign_lunar_years(months)
|
|
115
|
+
assigned = months.select(&:number)
|
|
116
|
+
first_new_year = assigned.index { |month| month.number == 1 && !month.leap }
|
|
117
|
+
raise Error, "could not identify lunisolar new year" unless first_new_year
|
|
118
|
+
|
|
119
|
+
current_year = assigned.fetch(first_new_year).start_date.year
|
|
120
|
+
assigned.take(first_new_year).each { |month| month.year = current_year }
|
|
121
|
+
assigned.drop(first_new_year).each do |month|
|
|
122
|
+
current_year = month.start_date.year if month.number == 1 && !month.leap
|
|
123
|
+
month.year = current_year
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_month(raw)
|
|
128
|
+
return unless raw.number && raw.year
|
|
129
|
+
|
|
130
|
+
Month.new(
|
|
131
|
+
year: raw.year,
|
|
132
|
+
month: raw.number,
|
|
133
|
+
leap: raw.leap,
|
|
134
|
+
start_date: raw.start_date,
|
|
135
|
+
end_date: raw.end_date,
|
|
136
|
+
new_moon_time: raw.new_moon_time,
|
|
137
|
+
principal_longitudes: raw.principal_longitudes
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|