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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sekki24
4
+ VERSION = "1.0.0"
5
+ 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: []