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
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
|
+
[](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
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
|
data/lib/sekki24/kou.rb
ADDED
|
@@ -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
|