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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
module TimeScale
|
|
5
|
+
UNIX_EPOCH_JD = 2_440_587.5
|
|
6
|
+
SECONDS_PER_DAY = 86_400.0
|
|
7
|
+
OFFSET_PATTERN = /\A([+-])(\d{2}):(\d{2})\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def utc_to_jd(time)
|
|
12
|
+
utc_time = coerce_time(time).getutc
|
|
13
|
+
(utc_time.to_r / SECONDS_PER_DAY) + UNIX_EPOCH_JD
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def jd_to_utc(jd)
|
|
17
|
+
Time.at((Float(jd) - UNIX_EPOCH_JD) * SECONDS_PER_DAY).utc
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def utc_to_jde(time)
|
|
21
|
+
utc_time = coerce_time(time).getutc
|
|
22
|
+
utc_to_jd(utc_time) + (DeltaT.seconds(decimal_year(utc_time)) / SECONDS_PER_DAY)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def jde_to_utc(jde)
|
|
26
|
+
utc = jd_to_utc(jde)
|
|
27
|
+
2.times do
|
|
28
|
+
utc = jd_to_utc(Float(jde) - (DeltaT.seconds(decimal_year(utc)) / SECONDS_PER_DAY))
|
|
29
|
+
end
|
|
30
|
+
utc
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def decimal_year(time)
|
|
34
|
+
utc_time = coerce_time(time).getutc
|
|
35
|
+
year_start = Time.utc(utc_time.year, 1, 1)
|
|
36
|
+
next_year = Time.utc(utc_time.year + 1, 1, 1)
|
|
37
|
+
utc_time.year + ((utc_time - year_start) / (next_year - year_start))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def localize(time, timezone)
|
|
41
|
+
utc_time = coerce_time(time).getutc
|
|
42
|
+
offset = fixed_offset(timezone)
|
|
43
|
+
return utc_time.getlocal(offset) unless offset.nil?
|
|
44
|
+
|
|
45
|
+
local = timezone.utc_to_local(utc_time)
|
|
46
|
+
return local if local.is_a?(Time)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "timezone utc_to_local must return a Time"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def timezone_key(timezone)
|
|
52
|
+
offset = fixed_offset(timezone)
|
|
53
|
+
return [:offset, offset].freeze unless offset.nil?
|
|
54
|
+
|
|
55
|
+
identifier = timezone.identifier if timezone.respond_to?(:identifier)
|
|
56
|
+
[:timezone, identifier || timezone.object_id].freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fixed_offset(timezone)
|
|
60
|
+
return validate_offset(timezone) if timezone.is_a?(Integer)
|
|
61
|
+
return 0 if %w[UTC Z].include?(timezone)
|
|
62
|
+
|
|
63
|
+
if timezone.is_a?(String)
|
|
64
|
+
match = OFFSET_PATTERN.match(timezone)
|
|
65
|
+
raise ArgumentError, 'timezone must use the format "+09:00"' unless match
|
|
66
|
+
|
|
67
|
+
sign = match[1] == "+" ? 1 : -1
|
|
68
|
+
hours = match[2].to_i
|
|
69
|
+
minutes = match[3].to_i
|
|
70
|
+
raise ArgumentError, "timezone offset is out of range" if hours > 23 || minutes > 59
|
|
71
|
+
|
|
72
|
+
return validate_offset(sign * ((hours * 3600) + (minutes * 60)))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return nil if timezone.respond_to?(:utc_to_local)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "timezone must be an offset String, Integer, or timezone object"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def coerce_time(value)
|
|
81
|
+
return value if value.is_a?(Time)
|
|
82
|
+
|
|
83
|
+
raise TypeError, "expected Time, got #{value.class}"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :coerce_time
|
|
86
|
+
|
|
87
|
+
def validate_offset(offset)
|
|
88
|
+
raise ArgumentError, "timezone offset is out of range" unless (-86_399..86_399).cover?(offset)
|
|
89
|
+
|
|
90
|
+
offset
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_offset
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sekki24
|
|
4
|
+
class Zassetsu
|
|
5
|
+
include Comparable
|
|
6
|
+
|
|
7
|
+
ATTRIBUTES = %i[key name_ja reading category date end_date time longitude].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader(*ATTRIBUTES)
|
|
10
|
+
|
|
11
|
+
def initialize(key:, name_ja:, reading:, category:, date:, end_date: date, time: nil, longitude: nil)
|
|
12
|
+
@key = key
|
|
13
|
+
@name_ja = name_ja
|
|
14
|
+
@reading = reading
|
|
15
|
+
@category = category
|
|
16
|
+
@date = date.freeze
|
|
17
|
+
@end_date = end_date.freeze
|
|
18
|
+
@time = time&.dup&.freeze
|
|
19
|
+
@longitude = longitude
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def <=>(other)
|
|
24
|
+
return unless other.respond_to?(:date)
|
|
25
|
+
|
|
26
|
+
sort_key <=> other.sort_key
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def include?(value)
|
|
30
|
+
target = value.is_a?(Time) ? value.to_date : value
|
|
31
|
+
(date..end_date).cover?(target)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect
|
|
39
|
+
suffix = date == end_date ? date.iso8601 : "#{date.iso8601}..#{end_date.iso8601}"
|
|
40
|
+
"#<#{self.class} #{name_ja} (#{key}) #{suffix}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def sort_key
|
|
46
|
+
seconds = time ? (time.hour * 3600) + (time.min * 60) + time.sec : -1
|
|
47
|
+
[date.jd, seconds]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "zassetsu"
|
|
4
|
+
|
|
5
|
+
module Sekki24
|
|
6
|
+
module ZassetsuCalendar
|
|
7
|
+
NAMES = {
|
|
8
|
+
winter_doyo: ["冬土用", "ふゆどよう"],
|
|
9
|
+
setsubun: ["節分", "せつぶん"],
|
|
10
|
+
spring_higan: ["春彼岸", "はるひがん"],
|
|
11
|
+
spring_shanichi: ["春社日", "はるしゃにち"],
|
|
12
|
+
spring_doyo: ["春土用", "はるどよう"],
|
|
13
|
+
hachijuhachiya: ["八十八夜", "はちじゅうはちや"],
|
|
14
|
+
nyubai: ["入梅", "にゅうばい"],
|
|
15
|
+
hangesho: ["半夏生", "はんげしょう"],
|
|
16
|
+
summer_doyo: ["夏土用", "なつどよう"],
|
|
17
|
+
nihyakutoka: ["二百十日", "にひゃくとおか"],
|
|
18
|
+
nihyakuhatsuka: ["二百二十日", "にひゃくはつか"],
|
|
19
|
+
autumn_higan: ["秋彼岸", "あきひがん"],
|
|
20
|
+
autumn_shanichi: ["秋社日", "あきしゃにち"],
|
|
21
|
+
autumn_doyo: ["秋土用", "あきどよう"]
|
|
22
|
+
}.transform_values(&:freeze).freeze
|
|
23
|
+
|
|
24
|
+
SOLAR_EVENTS = {
|
|
25
|
+
winter_doyo: 297,
|
|
26
|
+
spring_doyo: 27,
|
|
27
|
+
nyubai: 80,
|
|
28
|
+
hangesho: 100,
|
|
29
|
+
summer_doyo: 117,
|
|
30
|
+
autumn_doyo: 207
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
@cache_mutex = Mutex.new
|
|
34
|
+
@year_cache = {}
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def year(year, tz:, precision:)
|
|
38
|
+
calendar_year = validate_year(year)
|
|
39
|
+
mode = normalize_precision(precision)
|
|
40
|
+
solar = Solar.model(mode)
|
|
41
|
+
cache_key = [calendar_year, TimeScale.timezone_key(tz), mode].freeze
|
|
42
|
+
cached = @cache_mutex.synchronize { @year_cache[cache_key] }
|
|
43
|
+
return cached if cached
|
|
44
|
+
|
|
45
|
+
calculated = calculate_year(calendar_year, tz, mode, solar).sort.freeze
|
|
46
|
+
@cache_mutex.synchronize { @year_cache[cache_key] ||= calculated }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(year, key, tz:, precision:)
|
|
50
|
+
normalized = key.to_sym
|
|
51
|
+
raise ArgumentError, "unknown zassetsu: #{key.inspect}" unless NAMES.key?(normalized)
|
|
52
|
+
|
|
53
|
+
self.year(year, tz: tz, precision: precision).find { |entry| entry.key == normalized }
|
|
54
|
+
rescue NoMethodError
|
|
55
|
+
raise ArgumentError, "unknown zassetsu: #{key.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on(date, tz:, precision:)
|
|
59
|
+
raise TypeError, "expected Date, got #{date.class}" unless date.is_a?(Date)
|
|
60
|
+
|
|
61
|
+
year(date.year, tz: tz, precision: precision).select { |entry| entry.date == date }.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def active(date, tz:, precision:)
|
|
65
|
+
raise TypeError, "expected Date or Time, got #{date.class}" unless date.is_a?(Date) || date.is_a?(Time)
|
|
66
|
+
|
|
67
|
+
local_date = date.is_a?(Time) ? TimeScale.localize(date, tz).to_date : date
|
|
68
|
+
year(local_date.year, tz: tz, precision: precision).select { |entry| entry.include?(local_date) }.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def clear_cache!
|
|
72
|
+
@cache_mutex.synchronize { @year_cache.clear }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def calculate_year(year, timezone, mode, solar)
|
|
78
|
+
terms = Sekki24.year(year, tz: timezone, precision: mode)
|
|
79
|
+
by_key = terms.to_h { |term| [term.key, term] }
|
|
80
|
+
solar_events = calculate_solar_events(year, timezone, solar, by_key)
|
|
81
|
+
|
|
82
|
+
solar_events + date_observances(by_key) + shanichi_observances(by_key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def calculate_solar_events(year, timezone, solar, terms)
|
|
86
|
+
SOLAR_EVENTS.map do |key, longitude|
|
|
87
|
+
utc_time = Finder.find(year: year, longitude: longitude, solar: solar)
|
|
88
|
+
local_time = TimeScale.localize(utc_time, timezone)
|
|
89
|
+
end_date = doyo_end_date(key, terms) || local_time.to_date
|
|
90
|
+
build(key, category: :solar_longitude, date: local_time.to_date, end_date: end_date,
|
|
91
|
+
time: local_time, longitude: longitude)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def date_observances(terms)
|
|
96
|
+
risshun = terms.fetch(:risshun).to_date
|
|
97
|
+
spring_equinox = terms.fetch(:shunbun).to_date
|
|
98
|
+
autumn_equinox = terms.fetch(:shubun).to_date
|
|
99
|
+
|
|
100
|
+
[
|
|
101
|
+
build(:setsubun, category: :calendar_day, date: risshun - 1),
|
|
102
|
+
build(:spring_higan, category: :period, date: spring_equinox - 3, end_date: spring_equinox + 3),
|
|
103
|
+
build(:hachijuhachiya, category: :counted_day, date: risshun + 87),
|
|
104
|
+
build(:nihyakutoka, category: :counted_day, date: risshun + 209),
|
|
105
|
+
build(:nihyakuhatsuka, category: :counted_day, date: risshun + 219),
|
|
106
|
+
build(:autumn_higan, category: :period, date: autumn_equinox - 3, end_date: autumn_equinox + 3)
|
|
107
|
+
]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def shanichi_observances(terms)
|
|
111
|
+
[
|
|
112
|
+
[:spring_shanichi, terms.fetch(:shunbun)],
|
|
113
|
+
[:autumn_shanichi, terms.fetch(:shubun)]
|
|
114
|
+
].map do |key, equinox|
|
|
115
|
+
build(key, category: :sexagenary_day, date: closest_tsuchinoe_day(equinox))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def closest_tsuchinoe_day(equinox)
|
|
120
|
+
center = equinox.to_date
|
|
121
|
+
candidates = (-5..5).map { |offset| center + offset }.select { |date| ((date.jd + 49) % 10) == 4 }
|
|
122
|
+
return candidates.first if candidates.length == 1
|
|
123
|
+
|
|
124
|
+
equinox.time.hour < 12 ? candidates.min : candidates.max
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def doyo_end_date(key, terms)
|
|
128
|
+
season_start = {
|
|
129
|
+
winter_doyo: :risshun,
|
|
130
|
+
spring_doyo: :rikka,
|
|
131
|
+
summer_doyo: :risshu,
|
|
132
|
+
autumn_doyo: :ritto
|
|
133
|
+
}[key]
|
|
134
|
+
terms.fetch(season_start).to_date - 1 if season_start
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build(key, **attributes)
|
|
138
|
+
name_ja, reading = NAMES.fetch(key)
|
|
139
|
+
Zassetsu.new(key: key, name_ja: name_ja, reading: reading, **attributes)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_year(year)
|
|
143
|
+
value = Integer(year)
|
|
144
|
+
return value if (MIN_YEAR..MAX_YEAR).cover?(value)
|
|
145
|
+
|
|
146
|
+
raise RangeError, "year must be between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
147
|
+
rescue ArgumentError, TypeError
|
|
148
|
+
raise ArgumentError, "year must be an Integer between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_precision(precision)
|
|
152
|
+
precision.to_sym
|
|
153
|
+
rescue NoMethodError
|
|
154
|
+
raise ArgumentError, "precision must be :fast or :precise"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class << self
|
|
160
|
+
def zassetsu_year(year, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
161
|
+
ZassetsuCalendar.year(year, tz: tz, precision: precision)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def zassetsu(year, key, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
165
|
+
ZassetsuCalendar.fetch(year, key, tz: tz, precision: precision)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def zassetsu_on(date, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
169
|
+
ZassetsuCalendar.on(date, tz: tz, precision: precision)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def current_zassetsu(date = Date.today, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
173
|
+
ZassetsuCalendar.active(date, tz: tz, precision: precision)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/sekki24.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sekki24/version"
|
|
4
|
+
|
|
5
|
+
module Sekki24
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "sekki24/names"
|
|
10
|
+
require_relative "sekki24/term"
|
|
11
|
+
require_relative "sekki24/delta_t"
|
|
12
|
+
require_relative "sekki24/time_scale"
|
|
13
|
+
require_relative "sekki24/solar"
|
|
14
|
+
require_relative "sekki24/finder"
|
|
15
|
+
|
|
16
|
+
module Sekki24
|
|
17
|
+
MIN_YEAR = 1900
|
|
18
|
+
MAX_YEAR = 2100
|
|
19
|
+
DEFAULT_TIMEZONE = "+00:00"
|
|
20
|
+
DEFAULT_PRECISION = :precise
|
|
21
|
+
|
|
22
|
+
@cache_mutex = Mutex.new
|
|
23
|
+
@year_cache = {}
|
|
24
|
+
@candidate_cache = {}
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def year(year, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
28
|
+
calendar_year = validate_year(year)
|
|
29
|
+
mode, solar = resolve_precision(precision)
|
|
30
|
+
cache_key = [calendar_year, TimeScale.timezone_key(tz), mode].freeze
|
|
31
|
+
cached = @cache_mutex.synchronize { @year_cache[cache_key] }
|
|
32
|
+
return cached if cached
|
|
33
|
+
|
|
34
|
+
calculated = calculate_year(calendar_year, tz, mode, solar)
|
|
35
|
+
@cache_mutex.synchronize { @year_cache[cache_key] ||= calculated }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def term(year, key, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
39
|
+
definition = Names.fetch(key)
|
|
40
|
+
self.year(year, tz: tz, precision: precision).find { |entry| entry.key == definition.key }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def current(time = Time.now, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
44
|
+
instant = ensure_time(time)
|
|
45
|
+
navigation_terms(instant, tz, precision).select { |entry| entry.time <= instant }.max ||
|
|
46
|
+
raise(RangeError, "no current solar term in the supported range")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def next_term(time = Time.now, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
50
|
+
instant = ensure_time(time)
|
|
51
|
+
navigation_terms(instant, tz, precision).select { |entry| entry.time > instant }.min ||
|
|
52
|
+
raise(RangeError, "no next solar term in the supported range")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prev_term(time = Time.now, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
56
|
+
instant = ensure_time(time)
|
|
57
|
+
navigation_terms(instant, tz, precision).select { |entry| entry.time < instant }.max ||
|
|
58
|
+
raise(RangeError, "no previous solar term in the supported range")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def on(date, tz: DEFAULT_TIMEZONE, precision: DEFAULT_PRECISION)
|
|
62
|
+
unless date.is_a?(Date)
|
|
63
|
+
raise TypeError, "expected Date, got #{date.class}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
year(date.year, tz: tz, precision: precision).find { |entry| entry.to_date == date }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear_cache!
|
|
70
|
+
@cache_mutex.synchronize do
|
|
71
|
+
@year_cache.clear
|
|
72
|
+
@candidate_cache.clear
|
|
73
|
+
end
|
|
74
|
+
SeventyTwoKou.clear_cache! if defined?(SeventyTwoKou)
|
|
75
|
+
ZassetsuCalendar.clear_cache! if defined?(ZassetsuCalendar)
|
|
76
|
+
Lunar::PhaseFinder.clear_cache! if defined?(Lunar::PhaseFinder)
|
|
77
|
+
Lunisolar::Calendar.clear_cache! if defined?(Lunisolar::Calendar)
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def calculate_year(calendar_year, timezone, mode, solar)
|
|
84
|
+
candidates = ((calendar_year - 1)..(calendar_year + 1)).flat_map do |utc_year|
|
|
85
|
+
candidate_times(utc_year, mode, solar)
|
|
86
|
+
end
|
|
87
|
+
terms = candidates.filter_map do |definition, utc_time|
|
|
88
|
+
local_time = TimeScale.localize(utc_time, timezone)
|
|
89
|
+
next unless local_time.year == calendar_year
|
|
90
|
+
|
|
91
|
+
Term.new(definition: definition, time: local_time)
|
|
92
|
+
end.sort.freeze
|
|
93
|
+
|
|
94
|
+
return terms if terms.length == 24
|
|
95
|
+
|
|
96
|
+
raise Error, "expected 24 solar terms for #{calendar_year}, got #{terms.length}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def candidate_times(year, mode, solar)
|
|
100
|
+
cache_key = [year, mode].freeze
|
|
101
|
+
cached = @cache_mutex.synchronize { @candidate_cache[cache_key] }
|
|
102
|
+
return cached if cached
|
|
103
|
+
|
|
104
|
+
calculated = Names::CALENDAR_ORDER.map do |definition|
|
|
105
|
+
utc_time = Finder.find(year: year, longitude: definition.longitude, solar: solar).freeze
|
|
106
|
+
[definition, utc_time].freeze
|
|
107
|
+
end.freeze
|
|
108
|
+
@cache_mutex.synchronize { @candidate_cache[cache_key] ||= calculated }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def navigation_terms(time, timezone, precision)
|
|
112
|
+
local_year = TimeScale.localize(time, timezone).year
|
|
113
|
+
years = ((local_year - 1)..(local_year + 1)).select { |value| (MIN_YEAR..MAX_YEAR).cover?(value) }
|
|
114
|
+
raise RangeError, "time must fall within years #{MIN_YEAR}..#{MAX_YEAR}" if years.empty?
|
|
115
|
+
|
|
116
|
+
years.flat_map { |value| year(value, tz: timezone, precision: precision) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_year(year)
|
|
120
|
+
value = Integer(year)
|
|
121
|
+
return value if (MIN_YEAR..MAX_YEAR).cover?(value)
|
|
122
|
+
|
|
123
|
+
raise RangeError, "year must be between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
124
|
+
rescue ArgumentError, TypeError
|
|
125
|
+
raise ArgumentError, "year must be an Integer between #{MIN_YEAR} and #{MAX_YEAR}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_precision(precision)
|
|
129
|
+
mode = precision.to_sym
|
|
130
|
+
[mode, Solar.model(mode)]
|
|
131
|
+
rescue NoMethodError
|
|
132
|
+
raise ArgumentError, "precision must be :fast or :precise"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_time(time)
|
|
136
|
+
return time if time.is_a?(Time)
|
|
137
|
+
|
|
138
|
+
raise TypeError, "expected Time, got #{time.class}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
require_relative "sekki24/seventy_two_kou"
|
|
144
|
+
require_relative "sekki24/zassetsu_calendar"
|
|
145
|
+
require_relative "sekki24/lunar/phase_finder"
|
|
146
|
+
require_relative "sekki24/lunisolar/calendar"
|
data/sekki24.gemspec
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/sekki24/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "sekki24"
|
|
7
|
+
spec.version = Sekki24::VERSION
|
|
8
|
+
spec.authors = ["Yudai Takada"]
|
|
9
|
+
spec.email = ["t.yudai92@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Calculate Japanese solar terms, observances, and lunisolar dates"
|
|
12
|
+
spec.description = <<~DESCRIPTION
|
|
13
|
+
Sekki24 calculates the 24 solar terms, 72 microseasons, supplementary
|
|
14
|
+
seasonal observances, new moons, and Japanese lunisolar dates for years
|
|
15
|
+
1900 through 2100 using pure Ruby and no runtime dependencies.
|
|
16
|
+
DESCRIPTION
|
|
17
|
+
spec.homepage = "https://rubygems.org/gems/sekki24"
|
|
18
|
+
spec.license = "MIT"
|
|
19
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
20
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
|
|
23
|
+
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
|
|
24
|
+
ls.readlines("\x0", chomp: true).reject do |f|
|
|
25
|
+
f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .github/])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
spec.bindir = "exe"
|
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
30
|
+
spec.require_paths = ["lib"]
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sekki24
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Sekki24 calculates the 24 solar terms, 72 microseasons, supplementary
|
|
14
|
+
seasonal observances, new moons, and Japanese lunisolar dates for years
|
|
15
|
+
1900 through 2100 using pure Ruby and no runtime dependencies.
|
|
16
|
+
email:
|
|
17
|
+
- t.yudai92@gmail.com
|
|
18
|
+
executables:
|
|
19
|
+
- sekki24
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- Rakefile
|
|
26
|
+
- exe/sekki24
|
|
27
|
+
- lib/sekki24.rb
|
|
28
|
+
- lib/sekki24/delta_t.rb
|
|
29
|
+
- lib/sekki24/finder.rb
|
|
30
|
+
- lib/sekki24/kou.rb
|
|
31
|
+
- lib/sekki24/kou_names.rb
|
|
32
|
+
- lib/sekki24/lunar/moon.rb
|
|
33
|
+
- lib/sekki24/lunar/periodic_terms.rb
|
|
34
|
+
- lib/sekki24/lunar/phase_finder.rb
|
|
35
|
+
- lib/sekki24/lunisolar/calendar.rb
|
|
36
|
+
- lib/sekki24/lunisolar/date.rb
|
|
37
|
+
- lib/sekki24/lunisolar/month.rb
|
|
38
|
+
- lib/sekki24/lunisolar/month_builder.rb
|
|
39
|
+
- lib/sekki24/names.rb
|
|
40
|
+
- lib/sekki24/nutation.rb
|
|
41
|
+
- lib/sekki24/seventy_two_kou.rb
|
|
42
|
+
- lib/sekki24/solar.rb
|
|
43
|
+
- lib/sekki24/solar/fast.rb
|
|
44
|
+
- lib/sekki24/solar/precise.rb
|
|
45
|
+
- lib/sekki24/solar/vsop87_earth.rb
|
|
46
|
+
- lib/sekki24/term.rb
|
|
47
|
+
- lib/sekki24/time_scale.rb
|
|
48
|
+
- lib/sekki24/version.rb
|
|
49
|
+
- lib/sekki24/zassetsu.rb
|
|
50
|
+
- lib/sekki24/zassetsu_calendar.rb
|
|
51
|
+
- sekki24.gemspec
|
|
52
|
+
homepage: https://rubygems.org/gems/sekki24
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
homepage_uri: https://rubygems.org/gems/sekki24
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 3.0.0
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 4.0.6
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: Calculate Japanese solar terms, observances, and lunisolar dates
|
|
75
|
+
test_files: []
|