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