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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5bdf885e4ca0e21b77551f6427a178103ee21dbb51d90c20071ca6c49d7cdd57
4
+ data.tar.gz: 245dbf4c386e79531c8b80a3f211a556b6b1aeb1611c2658e9b7be7749319ee8
5
+ SHA512:
6
+ metadata.gz: 31d2de370880b1412f61d9bd96363e633c7992e55fc21b152fa3df272411f50af4c6501eb7c78274f8df69b0e0563f2ce79696c923ad0fd2430ea97ecf939674
7
+ data.tar.gz: d284de5b5f90eceea4ff5e979d28caf76df77269cc1f6c8b308889e971cf307d327b11d7e01567752135437c5f3294068521e64c2dc2df41a43c62a0888bb25f
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
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:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # Sekki24
2
+
3
+ [![CI](https://github.com/ydah/sekki24/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/sekki24/actions/workflows/main.yml)
4
+
5
+ Japanese seasonal and lunisolar calendar calculations in Ruby for 1900–2100.
6
+
7
+ - 24 solar terms (二十四節気)
8
+ - 72 microseasons (七十二候)
9
+ - Supplementary observances (雑節)
10
+ - New moons and Japanese lunisolar dates (旧暦)
11
+
12
+ ## Installation
13
+
14
+ Sekki24 requires Ruby 3.0 or later.
15
+
16
+ ```bash
17
+ gem install sekki24
18
+ ```
19
+
20
+ Or add it to a bundle:
21
+
22
+ ```bash
23
+ bundle add sekki24
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Solar terms
29
+
30
+ ```ruby
31
+ require "sekki24"
32
+
33
+ # UTC is used unless tz: is supplied.
34
+ terms = Sekki24.year(2026, tz: "+09:00")
35
+ terms.length # => 24
36
+
37
+ risshun = Sekki24.term(2026, :risshun, tz: "+09:00")
38
+ risshun.name_ja # => "立春"
39
+ risshun.reading # => "りっしゅん"
40
+ risshun.name_en # => "Start of spring"
41
+ risshun.name_zh # => "立春"
42
+ risshun.longitude # => 315
43
+ risshun.time # => 2026-02-04 05:02... +0900
44
+ risshun.to_date # => #<Date: 2026-02-04 ...>
45
+
46
+ Sekki24.current(Time.now, tz: "+09:00")
47
+ Sekki24.next_term(Time.now, tz: "+09:00")
48
+ Sekki24.prev_term(Time.now, tz: "+09:00")
49
+ Sekki24.on(Date.new(2026, 2, 4), tz: "+09:00") # => 立春
50
+ ```
51
+
52
+ ### Microseasons and observances
53
+
54
+ ```ruby
55
+ microseasons = Sekki24.kou_year(2026, tz: "+09:00")
56
+ microseasons.length # => 72
57
+
58
+ first = Sekki24.kou(2026, 1, tz: "+09:00")
59
+ first.name_ja # => "東風解凍"
60
+ first.reading # => "はるかぜこおりをとく"
61
+
62
+ observances = Sekki24.zassetsu_year(2026, tz: "+09:00")
63
+ observances.length # => 14
64
+ Sekki24.zassetsu(2026, :summer_doyo, tz: "+09:00")
65
+ Sekki24.zassetsu_on(Date.new(2026, 2, 3), tz: "+09:00")
66
+ ```
67
+
68
+ The 14 observances include four doyo periods, Setsubun, both Higan periods,
69
+ Hachijuhachiya, Nyubai, Hangesho, Nihyakutoka, Nihyakuhatsuka, and both
70
+ Shanichi days. Doyo and Higan include their complete date ranges.
71
+
72
+ `kou_year` associates three microseasons with each solar term in the requested
73
+ year. The final microseason can begin just after the Gregorian year boundary.
74
+
75
+ ### New moons and lunisolar dates
76
+
77
+ ```ruby
78
+ Sekki24.new_moons(2026, tz: "+09:00")
79
+ Sekki24.new_moon_before(Time.now)
80
+ Sekki24.new_moon_after(Time.now)
81
+
82
+ old_date = Sekki24.lunisolar(Date.new(2026, 2, 17), tz: "+09:00")
83
+ old_date.year # => 2026
84
+ old_date.month_name_ja # => "正月"
85
+ old_date.day # => 1
86
+ old_date.leap? # => false
87
+
88
+ Sekki24.lunisolar_year(2026, tz: "+09:00")
89
+ Sekki24.gregorian(2026, 1, 1, tz: "+09:00") # => 2026-02-17
90
+ ```
91
+
92
+ ## API
93
+
94
+ | Calendar | Methods on `Sekki24` |
95
+ | --- | --- |
96
+ | Solar terms | `year`, `term`, `current`, `next_term`, `prev_term`, `on` |
97
+ | Microseasons | `kou_year`, `kou`, `current_kou`, `next_kou`, `prev_kou` |
98
+ | Observances | `zassetsu_year`, `zassetsu`, `zassetsu_on`, `current_zassetsu` |
99
+ | New moons | `new_moons`, `new_moon_before`, `new_moon_after`, `moon_longitude` |
100
+ | Lunisolar calendar | `lunisolar`, `lunisolar_year`, `gregorian` |
101
+
102
+ `Term`, `Kou`, `Zassetsu`, `Lunisolar::Date`, and `Lunisolar::Month` are
103
+ immutable value objects with `#to_h`. `Term`, `Kou`, and `Zassetsu` also
104
+ implement `Comparable`.
105
+
106
+ ## Timezones
107
+
108
+ `tz:` accepts:
109
+
110
+ - a UTC offset string such as `"+09:00"` or `"-08:00"`;
111
+ - an offset in seconds such as `32_400`; or
112
+ - an object responding to `utc_to_local(Time)`, such as a loaded
113
+ `TZInfo::Timezone` instance.
114
+
115
+ Named-zone daylight-saving rules are used only when the caller supplies a
116
+ timezone object.
117
+
118
+ ## Precision
119
+
120
+ ```ruby
121
+ Sekki24.year(2026, precision: :precise) # default, within one minute
122
+ Sekki24.year(2026, precision: :fast) # approximately within 20 minutes
123
+ ```
124
+
125
+ `:precise` is validated against the National Astronomical Observatory of Japan
126
+ almanac. `precision:` applies to solar terms, microseasons, and supplementary
127
+ observances. New-moon and lunisolar APIs always use the precise lunar model.
128
+
129
+ Results are memoized. Use `Sekki24.clear_cache!` to clear all caches.
130
+
131
+ ## Command line
132
+
133
+ The executable prints text by default and supports JSON output:
134
+
135
+ ```bash
136
+ sekki24 2026 --tz +09:00
137
+ sekki24 2026 --tz +09:00 --format json
138
+ sekki24 2026 --tz +09:00 --calendar kou
139
+ sekki24 2026 --tz +09:00 --calendar zassetsu
140
+ sekki24 2026 --tz +09:00 --calendar new-moons
141
+ sekki24 2026 --tz +09:00 --calendar lunisolar
142
+ ```
143
+
144
+ Run `sekki24 --help` for every option.
145
+
146
+ ## Lunisolar calendar rules
147
+
148
+ Lunisolar months begin on the local civil date containing a new moon. When 13
149
+ months occur between winter-solstice months, the first month without a
150
+ principal solar term becomes a leap month.
151
+
152
+ For the ambiguous 2033 case, Sekki24 prioritizes the winter-solstice month and
153
+ uses the recommended leap eleventh month. The selected rule is available as
154
+ `Sekki24::Lunisolar::Calendar::LEAP_MONTH_RULE`.
155
+
156
+ Gregorian conversion is supported from 1900-01-01 through 2100-12-31. Lunar
157
+ year 1899 is accepted only because its final months overlap the start of that
158
+ range.
159
+
160
+ The historical Tenpo calendar was abolished in 1873. This API is a modern
161
+ astronomical reconstruction of its lunisolar rules, not an official Japanese
162
+ civil calendar.
163
+
164
+ ## Accuracy and implementation
165
+
166
+ - UTC is converted to Terrestrial Time with the Espenak–Meeus ΔT polynomials.
167
+ - Solar longitude uses the IMCCE VSOP87D Earth series in precise mode and the
168
+ Meeus Chapter 25 approximation in fast mode.
169
+ - Lunar longitude uses the principal Meeus periodic terms.
170
+ - Solar terms and new moons are solved numerically, with bisection fallbacks.
171
+
172
+ Coefficients are embedded in the gem; no ephemeris or calendar data is fetched
173
+ at runtime. Tests compare published solar terms, supplementary observances, and
174
+ every 2026 new moon against National Astronomical Observatory of Japan values.
175
+ They also cover all supported years, timezone boundaries, leap-month cases,
176
+ and Gregorian/lunisolar round trips.
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ bundle install
182
+ bundle exec rake
183
+ ```
184
+
185
+ CI tests Ruby 3.0, 3.2, 3.3, 3.4, 4.0, and Ruby head. actionlint and zizmor
186
+ check the GitHub Actions workflows.
187
+
188
+ ## License
189
+
190
+ Sekki24 is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/sekki24 ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "json"
6
+ require "optparse"
7
+ require "sekki24"
8
+
9
+ options = {
10
+ timezone: Sekki24::DEFAULT_TIMEZONE,
11
+ precision: Sekki24::DEFAULT_PRECISION,
12
+ format: "text",
13
+ calendar: "terms"
14
+ }
15
+
16
+ parser = OptionParser.new do |command|
17
+ command.banner = "Usage: sekki24 [YEAR] [options]"
18
+ command.separator ""
19
+ command.separator "Calculate a Japanese seasonal or lunisolar calendar for a year."
20
+ command.separator ""
21
+ command.on("--tz OFFSET", 'UTC offset such as "+09:00" (default: +00:00)') do |value|
22
+ options[:timezone] = value
23
+ end
24
+ command.on("--precision MODE", %w[precise fast], "precise or fast (default: precise)") do |value|
25
+ options[:precision] = value.to_sym
26
+ end
27
+ command.on("--format FORMAT", %w[text json], "text or json (default: text)") do |value|
28
+ options[:format] = value
29
+ end
30
+ command.on(
31
+ "--calendar TYPE",
32
+ %w[terms kou zassetsu new-moons lunisolar],
33
+ "terms, kou, zassetsu, new-moons, or lunisolar"
34
+ ) do |value|
35
+ options[:calendar] = value
36
+ end
37
+ command.on("-v", "--version", "Print the version") do
38
+ puts Sekki24::VERSION
39
+ exit
40
+ end
41
+ command.on("-h", "--help", "Print this help") do
42
+ puts command
43
+ exit
44
+ end
45
+ end
46
+
47
+ begin
48
+ parser.parse!
49
+ raise OptionParser::InvalidArgument, "too many arguments" if ARGV.length > 1
50
+
51
+ year = ARGV.empty? ? Date.today.year : Integer(ARGV.first, 10)
52
+ records = case options[:calendar]
53
+ when "terms"
54
+ Sekki24.year(year, tz: options[:timezone], precision: options[:precision])
55
+ when "kou"
56
+ Sekki24.kou_year(year, tz: options[:timezone], precision: options[:precision])
57
+ when "zassetsu"
58
+ Sekki24.zassetsu_year(year, tz: options[:timezone], precision: options[:precision])
59
+ when "new-moons"
60
+ Sekki24.new_moons(year, tz: options[:timezone])
61
+ when "lunisolar"
62
+ Sekki24.lunisolar_year(year, tz: options[:timezone])
63
+ end
64
+
65
+ serialize = nil
66
+ serialize = lambda do |value|
67
+ case value
68
+ when Time then value.iso8601
69
+ when Date then value.iso8601
70
+ when Array then value.map { |entry| serialize.call(entry) }
71
+ when Hash then value.transform_values { |entry| serialize.call(entry) }
72
+ else value
73
+ end
74
+ end
75
+ hashes = records.map do |record|
76
+ hash = record.respond_to?(:to_h) ? record.to_h : { phase: :new_moon, time: record, date: record.to_date }
77
+ hash = hash.merge(date: record.to_date) if record.respond_to?(:to_date) && !hash.key?(:date)
78
+ serialize.call(hash)
79
+ end
80
+
81
+ if options[:format] == "json"
82
+ puts JSON.pretty_generate(hashes)
83
+ else
84
+ records.each do |record|
85
+ case options[:calendar]
86
+ when "terms"
87
+ puts format("%s %s (%s) %3d°", record.time.iso8601, record.name_ja, record.key, record.longitude)
88
+ when "kou"
89
+ puts format("%s 第%02d候 %s %3d°", record.time.iso8601, record.ordinal, record.name_ja, record.longitude)
90
+ when "zassetsu"
91
+ period = record.date == record.end_date ? record.date.iso8601 : "#{record.date.iso8601}..#{record.end_date.iso8601}"
92
+ puts format("%-21s %s (%s)", period, record.name_ja, record.key)
93
+ when "new-moons"
94
+ puts record.iso8601
95
+ when "lunisolar"
96
+ puts format("%d年%-4s %s..%s %d日", record.year, record.name_ja, record.start_date, record.end_date, record.length)
97
+ end
98
+ end
99
+ end
100
+ rescue OptionParser::ParseError, ArgumentError, RangeError => error
101
+ warn "sekki24: #{error.message}"
102
+ warn parser
103
+ exit 1
104
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sekki24
4
+ module DeltaT
5
+ module_function
6
+
7
+ # Espenak & Meeus polynomials. The return value is TT - UT in seconds.
8
+ def seconds(decimal_year)
9
+ year = Float(decimal_year)
10
+
11
+ case year
12
+ when 1860...1900 then between_1860_and_1900(year - 1860.0)
13
+ when 1900...1920 then between_1900_and_1920(year - 1900.0)
14
+ when 1920...1941 then between_1920_and_1941(year - 1920.0)
15
+ when 1941...1961 then between_1941_and_1961(year - 1950.0)
16
+ when 1961...1986 then between_1961_and_1986(year - 1975.0)
17
+ when 1986...2005 then between_1986_and_2005(year - 2000.0)
18
+ when 2005...2050 then between_2005_and_2050(year - 2000.0)
19
+ when 2050..2150 then between_2050_and_2150(year)
20
+ else
21
+ raise RangeError, "delta T is supported for years 1860..2150"
22
+ end
23
+ end
24
+
25
+ def between_1860_and_1900(t)
26
+ 7.62 + (0.5737 * t) - (0.251754 * t**2) + (0.01680668 * t**3) -
27
+ (0.0004473624 * t**4) + (t**5 / 233_174.0)
28
+ end
29
+ private_class_method :between_1860_and_1900
30
+
31
+ def between_1900_and_1920(t)
32
+ -2.79 + (1.494119 * t) - (0.0598939 * t**2) + (0.0061966 * t**3) - (0.000197 * t**4)
33
+ end
34
+ private_class_method :between_1900_and_1920
35
+
36
+ def between_1920_and_1941(t)
37
+ 21.20 + (0.84493 * t) - (0.0761 * t**2) + (0.0020936 * t**3)
38
+ end
39
+ private_class_method :between_1920_and_1941
40
+
41
+ def between_1941_and_1961(t)
42
+ 29.07 + (0.407 * t) - (t**2 / 233.0) + (t**3 / 2547.0)
43
+ end
44
+ private_class_method :between_1941_and_1961
45
+
46
+ def between_1961_and_1986(t)
47
+ 45.45 + (1.067 * t) - (t**2 / 260.0) - (t**3 / 718.0)
48
+ end
49
+ private_class_method :between_1961_and_1986
50
+
51
+ def between_1986_and_2005(t)
52
+ 63.86 + (0.3345 * t) - (0.060374 * t**2) + (0.0017275 * t**3) +
53
+ (0.000651814 * t**4) + (0.00002373599 * t**5)
54
+ end
55
+ private_class_method :between_1986_and_2005
56
+
57
+ def between_2005_and_2050(t)
58
+ 62.92 + (0.32217 * t) + (0.005589 * t**2)
59
+ end
60
+ private_class_method :between_2005_and_2050
61
+
62
+ def between_2050_and_2150(year)
63
+ -20.0 + (32.0 * ((year - 1820.0) / 100.0)**2) - (0.5628 * (2150.0 - year))
64
+ end
65
+ private_class_method :between_2050_and_2150
66
+ end
67
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sekki24
4
+ module Finder
5
+ MEAN_SOLAR_MOTION = 0.9856
6
+ DERIVATIVE_HALF_WINDOW = 1.0 / 24.0
7
+ ANGULAR_TOLERANCE = 1e-6
8
+ NEWTON_ATTEMPTS = 5
9
+ MAX_NEWTON_STEP = 5.0
10
+ BISECTION_ATTEMPTS = 80
11
+
12
+ class ConvergenceError < Sekki24::Error; end
13
+
14
+ module_function
15
+
16
+ def find(year:, longitude:, solar:)
17
+ start = TimeScale.utc_to_jde(Time.utc(Integer(year), 1, 1))
18
+ find_from(start, longitude, solar)
19
+ end
20
+
21
+ def find_after(time:, longitude:, solar:)
22
+ start = TimeScale.utc_to_jde(time)
23
+ find_from(start, longitude, solar)
24
+ end
25
+
26
+ def find_from(start, longitude, solar)
27
+ estimate = start + (positive_difference(longitude, solar.longitude(start)) / MEAN_SOLAR_MOTION)
28
+ root = newton(estimate, longitude, solar)
29
+ TimeScale.jde_to_utc(root)
30
+ end
31
+ private_class_method :find_from
32
+
33
+ def newton(estimate, target, solar)
34
+ jde = estimate
35
+
36
+ NEWTON_ATTEMPTS.times do
37
+ error = signed_difference(solar.longitude(jde), target)
38
+ return jde if error.abs < ANGULAR_TOLERANCE
39
+
40
+ derivative = numerical_derivative(jde, solar)
41
+ break unless derivative.finite? && derivative.positive?
42
+
43
+ step = error / derivative
44
+ break if step.abs > MAX_NEWTON_STEP
45
+
46
+ jde -= step
47
+ end
48
+
49
+ bisect(estimate, target, solar)
50
+ end
51
+ private_class_method :newton
52
+
53
+ def numerical_derivative(jde, solar)
54
+ before = solar.longitude(jde - DERIVATIVE_HALF_WINDOW)
55
+ after = solar.longitude(jde + DERIVATIVE_HALF_WINDOW)
56
+ signed_difference(after, before) / (2.0 * DERIVATIVE_HALF_WINDOW)
57
+ end
58
+ private_class_method :numerical_derivative
59
+
60
+ def bisect(estimate, target, solar)
61
+ radius = 2.0
62
+ lower, upper, lower_error, upper_error = bracket(estimate, target, solar, radius)
63
+
64
+ while lower_error * upper_error > 0.0 && radius < 32.0
65
+ radius *= 2.0
66
+ lower, upper, lower_error, upper_error = bracket(estimate, target, solar, radius)
67
+ end
68
+
69
+ if lower_error * upper_error > 0.0
70
+ raise ConvergenceError, "could not bracket solar longitude #{target}°"
71
+ end
72
+
73
+ BISECTION_ATTEMPTS.times do
74
+ midpoint = (lower + upper) / 2.0
75
+ midpoint_error = signed_difference(solar.longitude(midpoint), target)
76
+ return midpoint if midpoint_error.abs < ANGULAR_TOLERANCE
77
+
78
+ if lower_error * midpoint_error <= 0.0
79
+ upper = midpoint
80
+ else
81
+ lower = midpoint
82
+ lower_error = midpoint_error
83
+ end
84
+ end
85
+
86
+ (lower + upper) / 2.0
87
+ end
88
+ private_class_method :bisect
89
+
90
+ def bracket(estimate, target, solar, radius)
91
+ lower = estimate - radius
92
+ upper = estimate + radius
93
+ [
94
+ lower,
95
+ upper,
96
+ signed_difference(solar.longitude(lower), target),
97
+ signed_difference(solar.longitude(upper), target)
98
+ ]
99
+ end
100
+ private_class_method :bracket
101
+
102
+ def positive_difference(target, actual)
103
+ (Float(target) - actual) % 360.0
104
+ end
105
+ private_class_method :positive_difference
106
+
107
+ def signed_difference(left, right)
108
+ ((left - right + 180.0) % 360.0) - 180.0
109
+ end
110
+ private_class_method :signed_difference
111
+ end
112
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sekki24
4
+ class Kou
5
+ include Comparable
6
+
7
+ ATTRIBUTES = %i[ordinal name_ja reading longitude term_key position time].freeze
8
+
9
+ attr_reader(*ATTRIBUTES)
10
+
11
+ def initialize(definition:, time:)
12
+ @ordinal = definition.ordinal
13
+ @name_ja = definition.name_ja
14
+ @reading = definition.reading
15
+ @longitude = definition.longitude
16
+ @term_key = definition.term_key
17
+ @position = definition.position
18
+ @time = time.dup.freeze
19
+ freeze
20
+ end
21
+
22
+ def <=>(other)
23
+ return unless other.respond_to?(:time)
24
+
25
+ time <=> other.time
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(Kou) && ordinal == other.ordinal && time == other.time
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, ordinal, time].hash
35
+ end
36
+
37
+ def to_date
38
+ time.to_date
39
+ end
40
+
41
+ def to_h
42
+ ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
43
+ end
44
+
45
+ def inspect
46
+ "#<#{self.class} 第#{ordinal}候 #{name_ja} #{time.iso8601} longitude=#{longitude}°>"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sekki24
4
+ module KouNames
5
+ Definition = Struct.new(:ordinal, :name_ja, :reading, :longitude, :term_key, :position, keyword_init: true)
6
+
7
+ NAMES = [
8
+ ["東風解凍", "はるかぜこおりをとく"], ["黄鶯睍睆", "うぐいすなく"], ["魚上氷", "うおこおりをいずる"],
9
+ ["土脉潤起", "つちのしょううるおいおこる"], ["霞始靆", "かすみはじめてたなびく"], ["草木萌動", "そうもくめばえいずる"],
10
+ ["蟄虫啓戸", "すごもりむしとをひらく"], ["桃始笑", "ももはじめてさく"], ["菜虫化蝶", "なむしちょうとなる"],
11
+ ["雀始巣", "すずめはじめてすくう"], ["桜始開", "さくらはじめてひらく"], ["雷乃発声", "かみなりすなわちこえをはっす"],
12
+ ["玄鳥至", "つばめきたる"], ["鴻雁北", "こうがんかえる"], ["虹始見", "にじはじめてあらわる"],
13
+ ["葭始生", "あしはじめてしょうず"], ["霜止出苗", "しもやみてなえいずる"], ["牡丹華", "ぼたんはなさく"],
14
+ ["蛙始鳴", "かわずはじめてなく"], ["蚯蚓出", "みみずいずる"], ["竹笋生", "たけのこしょうず"],
15
+ ["蚕起食桑", "かいこおきてくわをはむ"], ["紅花栄", "べにばなさかう"], ["麦秋至", "むぎのときいたる"],
16
+ ["螳螂生", "かまきりしょうず"], ["腐草為螢", "くされたるくさほたるとなる"], ["梅子黄", "うめのみきばむ"],
17
+ ["乃東枯", "なつかれくさかるる"], ["菖蒲華", "あやめはなさく"], ["半夏生", "はんげしょうず"],
18
+ ["温風至", "あつかぜいたる"], ["蓮始開", "はすはじめてひらく"], ["鷹乃学習", "たかすなわちわざをならう"],
19
+ ["桐始結花", "きりはじめてはなをむすぶ"], ["土潤溽暑", "つちうるおうてむしあつし"], ["大雨時行", "たいうときどきふる"],
20
+ ["涼風至", "すずかぜいたる"], ["寒蝉鳴", "ひぐらしなく"], ["蒙霧升降", "ふかききりまとう"],
21
+ ["綿柎開", "わたのはなしべひらく"], ["天地始粛", "てんちはじめてさむし"], ["禾乃登", "こくものすなわちみのる"],
22
+ ["草露白", "くさのつゆしろし"], ["鶺鴒鳴", "せきれいなく"], ["玄鳥去", "つばめさる"],
23
+ ["雷乃収声", "かみなりすなわちこえをおさむ"], ["蟄虫坏戸", "むしかくれてとをふさぐ"], ["水始涸", "みずはじめてかるる"],
24
+ ["鴻雁来", "こうがんきたる"], ["菊花開", "きくのはなひらく"], ["蟋蟀在戸", "きりぎりすとにあり"],
25
+ ["霜始降", "しもはじめてふる"], ["霎時施", "こさめときどきふる"], ["楓蔦黄", "もみじつたきばむ"],
26
+ ["山茶始開", "つばきはじめてひらく"], ["地始凍", "ちはじめてこおる"], ["金盞香", "きんせんかさく"],
27
+ ["虹蔵不見", "にじかくれてみえず"], ["朔風払葉", "きたかぜこのはをはらう"], ["橘始黄", "たちばなはじめてきばむ"],
28
+ ["閉塞成冬", "そらさむくふゆとなる"], ["熊蟄穴", "くまあなにこもる"], ["鱖魚群", "さけのうおむらがる"],
29
+ ["乃東生", "なつかれくさしょうず"], ["麋角解", "さわしかのつのおつる"], ["雪下出麦", "ゆきわたりてむぎのびる"],
30
+ ["芹乃栄", "せりすなわちさかう"], ["水泉動", "しみずあたたかをふくむ"], ["雉始雊", "きじはじめてなく"],
31
+ ["款冬華", "ふきのはなさく"], ["水沢腹堅", "さわみずこおりつめる"], ["鶏始乳", "にわとりはじめてとやにつく"]
32
+ ].map(&:freeze).freeze
33
+
34
+ POSITIONS = %i[initial secondary final].freeze
35
+
36
+ TERMS = Names::TERMS.each_with_index.flat_map do |term, term_index|
37
+ 3.times.map do |position_index|
38
+ ordinal = (term_index * 3) + position_index + 1
39
+ name_ja, reading = NAMES.fetch(ordinal - 1)
40
+ Definition.new(
41
+ ordinal: ordinal,
42
+ name_ja: name_ja,
43
+ reading: reading,
44
+ longitude: (term.longitude + (position_index * 5)) % 360,
45
+ term_key: term.key,
46
+ position: POSITIONS.fetch(position_index)
47
+ ).freeze
48
+ end
49
+ end.freeze
50
+
51
+ BY_ORDINAL = TERMS.to_h { |definition| [definition.ordinal, definition] }.freeze
52
+ module_function
53
+
54
+ def fetch(ordinal)
55
+ BY_ORDINAL.fetch(Integer(ordinal))
56
+ rescue ArgumentError, TypeError, KeyError
57
+ raise ArgumentError, "kou ordinal must be between 1 and 72"
58
+ end
59
+ end
60
+ end