moon_phase_tracker 1.3.2
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/.rubocop.yml +8 -0
- data/CHANGELOG.md +166 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/Rakefile +8 -0
- data/examples/eight_phases_example.rb +100 -0
- data/examples/rate_limiting_example.rb +117 -0
- data/examples/usage_example.rb +74 -0
- data/lib/moon_phase_tracker/client.rb +116 -0
- data/lib/moon_phase_tracker/phase/comparator.rb +42 -0
- data/lib/moon_phase_tracker/phase/formatter.rb +44 -0
- data/lib/moon_phase_tracker/phase/mapper.rb +56 -0
- data/lib/moon_phase_tracker/phase/parser.rb +36 -0
- data/lib/moon_phase_tracker/phase.rb +63 -0
- data/lib/moon_phase_tracker/phase_calculator/cycle_estimator.rb +51 -0
- data/lib/moon_phase_tracker/phase_calculator/phase_interpolator.rb +113 -0
- data/lib/moon_phase_tracker/phase_calculator.rb +45 -0
- data/lib/moon_phase_tracker/rate_limiter.rb +102 -0
- data/lib/moon_phase_tracker/tracker/date_parser.rb +32 -0
- data/lib/moon_phase_tracker/tracker/phase_formatter.rb +64 -0
- data/lib/moon_phase_tracker/tracker/phase_query_service.rb +71 -0
- data/lib/moon_phase_tracker/tracker/validators.rb +32 -0
- data/lib/moon_phase_tracker/tracker.rb +85 -0
- data/lib/moon_phase_tracker/version.rb +5 -0
- data/lib/moon_phase_tracker.rb +42 -0
- data/sig/moon_phase_tracker.rbs +4 -0
- metadata +100 -0
@@ -0,0 +1,100 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Add the lib directory to the load path
|
5
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
6
|
+
|
7
|
+
require 'moon_phase_tracker'
|
8
|
+
|
9
|
+
puts '=== Moon Phase Tracker - 8 Phases Example ==='
|
10
|
+
puts 'Demonstrating all 8 lunar phases (4 major + 4 intermediate)'
|
11
|
+
puts ''
|
12
|
+
|
13
|
+
begin
|
14
|
+
# Example 1: Get all 8 phases for August 2025
|
15
|
+
puts '🌙 All phases for August 2025:'
|
16
|
+
puts '=' * 40
|
17
|
+
phases = MoonPhaseTracker.all_phases_for_month(2025, 8)
|
18
|
+
|
19
|
+
if phases.any?
|
20
|
+
phases.each do |phase|
|
21
|
+
prefix = phase.interpolated ? '~ ' : ' '
|
22
|
+
puts "#{prefix}#{phase}"
|
23
|
+
end
|
24
|
+
|
25
|
+
major_count = phases.count { |p| !p.interpolated }
|
26
|
+
interpolated_count = phases.count(&:interpolated)
|
27
|
+
|
28
|
+
puts ''
|
29
|
+
puts "Total: #{phases.size} phases (#{major_count} major, #{interpolated_count} interpolated)"
|
30
|
+
puts '~ indicates interpolated phases'
|
31
|
+
else
|
32
|
+
puts 'No phases found for this period.'
|
33
|
+
end
|
34
|
+
|
35
|
+
puts ''
|
36
|
+
puts '-' * 50
|
37
|
+
puts ''
|
38
|
+
|
39
|
+
# Example 2: Compare 4 vs 8 phases
|
40
|
+
puts '🔍 Comparison: 4 Major Phases vs 8 Complete Phases'
|
41
|
+
puts '=' * 55
|
42
|
+
|
43
|
+
major_phases = MoonPhaseTracker.phases_for_month(2025, 8)
|
44
|
+
all_phases = MoonPhaseTracker.all_phases_for_month(2025, 8)
|
45
|
+
|
46
|
+
puts "Major phases only (#{major_phases.size}):"
|
47
|
+
major_phases.each { |phase| puts " #{phase}" }
|
48
|
+
|
49
|
+
puts ''
|
50
|
+
puts "All phases including interpolated (#{all_phases.size}):"
|
51
|
+
all_phases.each do |phase|
|
52
|
+
prefix = phase.interpolated ? '~ ' : ' '
|
53
|
+
puts "#{prefix}#{phase}"
|
54
|
+
end
|
55
|
+
|
56
|
+
puts ''
|
57
|
+
puts '-' * 50
|
58
|
+
puts ''
|
59
|
+
|
60
|
+
# Example 3: Show phase symbols
|
61
|
+
puts '🎭 Phase Symbols Reference'
|
62
|
+
puts '=' * 30
|
63
|
+
MoonPhaseTracker::Phase::PHASE_SYMBOLS.each do |type, symbol|
|
64
|
+
name = type.to_s.split('_').map(&:capitalize).join(' ')
|
65
|
+
puts "#{symbol} #{name}"
|
66
|
+
end
|
67
|
+
|
68
|
+
puts ''
|
69
|
+
puts '-' * 50
|
70
|
+
puts ''
|
71
|
+
|
72
|
+
# Example 4: Get phases from a specific date with 8 phases
|
73
|
+
puts '📅 8 Phases from a specific date (2025-08-01, 2 cycles)'
|
74
|
+
puts '=' * 60
|
75
|
+
date_phases = MoonPhaseTracker.all_phases_from_date('2025-08-01', 2)
|
76
|
+
|
77
|
+
if date_phases.any?
|
78
|
+
date_phases.each do |phase|
|
79
|
+
prefix = phase.interpolated ? '~ ' : ' '
|
80
|
+
puts "#{prefix}#{phase}"
|
81
|
+
end
|
82
|
+
|
83
|
+
major_count = date_phases.count { |p| !p.interpolated }
|
84
|
+
interpolated_count = date_phases.count(&:interpolated)
|
85
|
+
|
86
|
+
puts ''
|
87
|
+
puts "Total: #{date_phases.size} phases over 2 lunar cycles"
|
88
|
+
puts "(#{major_count} major, #{interpolated_count} interpolated)"
|
89
|
+
end
|
90
|
+
rescue MoonPhaseTracker::Error => e
|
91
|
+
puts "Error: #{e.message}"
|
92
|
+
rescue StandardError => e
|
93
|
+
puts "Unexpected error: #{e.message}"
|
94
|
+
puts 'This example requires an active internet connection to fetch moon phase data.'
|
95
|
+
end
|
96
|
+
|
97
|
+
puts ''
|
98
|
+
puts 'Note: This example uses real astronomical data from the US Naval Observatory.'
|
99
|
+
puts 'Interpolated phases (~) are calculated estimates between official phases.'
|
100
|
+
puts 'All dates are in ISO 8601 format (YYYY-MM-DD) and times are in UTC.'
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Example demonstrating rate limiting functionality in MoonPhaseTracker
|
5
|
+
# This example shows different ways to configure and use rate limiting
|
6
|
+
|
7
|
+
require_relative "../lib/moon_phase_tracker"
|
8
|
+
|
9
|
+
puts "=== MoonPhaseTracker Rate Limiting Examples ==="
|
10
|
+
puts
|
11
|
+
|
12
|
+
puts "1. Default Rate Limiting (1 request/second):"
|
13
|
+
puts "=" * 50
|
14
|
+
|
15
|
+
tracker = MoonPhaseTracker::Tracker.new
|
16
|
+
puts "Rate limit configuration: #{tracker.rate_limit_info}"
|
17
|
+
|
18
|
+
start_time = Time.now
|
19
|
+
puts "Making first request..."
|
20
|
+
phases1 = tracker.phases_for_year(2025)
|
21
|
+
first_request_time = Time.now - start_time
|
22
|
+
|
23
|
+
puts "Making second request (should be rate limited)..."
|
24
|
+
phases2 = tracker.phases_for_year(2024)
|
25
|
+
total_time = Time.now - start_time
|
26
|
+
|
27
|
+
puts "First request took: #{first_request_time.round(2)}s"
|
28
|
+
puts "Total time for both requests: #{total_time.round(2)}s"
|
29
|
+
puts "Rate limiting delay: #{(total_time - first_request_time).round(2)}s"
|
30
|
+
puts
|
31
|
+
|
32
|
+
puts "2. Custom Rate Limiting (3 requests/second, burst size 2):"
|
33
|
+
puts "=" * 55
|
34
|
+
|
35
|
+
custom_rate_limiter = MoonPhaseTracker::RateLimiter.new(
|
36
|
+
requests_per_second: 3.0,
|
37
|
+
burst_size: 2
|
38
|
+
)
|
39
|
+
|
40
|
+
custom_tracker = MoonPhaseTracker::Tracker.new(rate_limiter: custom_rate_limiter)
|
41
|
+
puts "Rate limit configuration: #{custom_tracker.rate_limit_info}"
|
42
|
+
|
43
|
+
start_time = Time.now
|
44
|
+
puts "Making burst requests (should be immediate)..."
|
45
|
+
custom_tracker.phases_for_year(2025)
|
46
|
+
custom_tracker.phases_for_year(2024)
|
47
|
+
burst_time = Time.now - start_time
|
48
|
+
|
49
|
+
puts "Making third request (should be rate limited)..."
|
50
|
+
custom_tracker.phases_for_year(2023)
|
51
|
+
total_time = Time.now - start_time
|
52
|
+
|
53
|
+
puts "Burst requests (2) took: #{burst_time.round(2)}s"
|
54
|
+
puts "Total time for 3 requests: #{total_time.round(2)}s"
|
55
|
+
puts "Rate limiting delay for 3rd request: #{(total_time - burst_time).round(2)}s"
|
56
|
+
puts
|
57
|
+
|
58
|
+
puts "3. Environment Variable Configuration:"
|
59
|
+
puts "=" * 42
|
60
|
+
|
61
|
+
ENV["MOON_PHASE_RATE_LIMIT"] = "2.5"
|
62
|
+
ENV["MOON_PHASE_BURST_SIZE"] = "3"
|
63
|
+
|
64
|
+
env_tracker = MoonPhaseTracker::Tracker.new
|
65
|
+
puts "Rate limit configuration from ENV: #{env_tracker.rate_limit_info}"
|
66
|
+
puts
|
67
|
+
|
68
|
+
puts "4. Disabled Rate Limiting:"
|
69
|
+
puts "=" * 27
|
70
|
+
|
71
|
+
ENV["MOON_PHASE_RATE_LIMIT"] = "0"
|
72
|
+
|
73
|
+
disabled_tracker = MoonPhaseTracker::Tracker.new
|
74
|
+
puts "Rate limit configuration: #{disabled_tracker.rate_limit_info || 'DISABLED'}"
|
75
|
+
|
76
|
+
start_time = Time.now
|
77
|
+
puts "Making multiple requests without rate limiting..."
|
78
|
+
5.times do |i|
|
79
|
+
disabled_tracker.phases_for_year(2025 - i)
|
80
|
+
end
|
81
|
+
total_time = Time.now - start_time
|
82
|
+
|
83
|
+
puts "5 requests took: #{total_time.round(2)}s (no rate limiting)"
|
84
|
+
puts
|
85
|
+
|
86
|
+
puts "5. Rate Limit Status Monitoring:"
|
87
|
+
puts "=" * 34
|
88
|
+
|
89
|
+
rate_limiter = MoonPhaseTracker::RateLimiter.new(
|
90
|
+
requests_per_second: 1.0,
|
91
|
+
burst_size: 2
|
92
|
+
)
|
93
|
+
|
94
|
+
puts "Initial status: #{rate_limiter.configuration}"
|
95
|
+
puts "Can proceed? #{rate_limiter.can_proceed?}"
|
96
|
+
|
97
|
+
puts "\nMaking first request..."
|
98
|
+
rate_limiter.throttle
|
99
|
+
puts "After 1st request: #{rate_limiter.configuration}"
|
100
|
+
puts "Can proceed? #{rate_limiter.can_proceed?}"
|
101
|
+
|
102
|
+
puts "\nMaking second request..."
|
103
|
+
rate_limiter.throttle
|
104
|
+
puts "After 2nd request: #{rate_limiter.configuration}"
|
105
|
+
puts "Can proceed? #{rate_limiter.can_proceed?}"
|
106
|
+
|
107
|
+
puts "\nWaiting for token replenishment..."
|
108
|
+
sleep(1.1)
|
109
|
+
puts "After waiting: #{rate_limiter.configuration}"
|
110
|
+
puts "Can proceed? #{rate_limiter.can_proceed?}"
|
111
|
+
|
112
|
+
puts
|
113
|
+
puts "=== Rate Limiting Examples Complete ==="
|
114
|
+
|
115
|
+
# Clean up environment variables
|
116
|
+
ENV.delete("MOON_PHASE_RATE_LIMIT")
|
117
|
+
ENV.delete("MOON_PHASE_BURST_SIZE")
|
@@ -0,0 +1,74 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative '../lib/moon_phase_tracker'
|
5
|
+
|
6
|
+
puts '=== Moon Phase Tracker - Usage Examples =='
|
7
|
+
puts
|
8
|
+
|
9
|
+
tracker = MoonPhaseTracker::Tracker.new
|
10
|
+
|
11
|
+
begin
|
12
|
+
puts '1. Moon phases for August 2025:'
|
13
|
+
puts '-' * 40
|
14
|
+
august_phases = MoonPhaseTracker.phases_for_month(2025, 8)
|
15
|
+
puts tracker.format_phases(august_phases, "#{MoonPhaseTracker::Tracker.month_name(8)} 2025 Phases")
|
16
|
+
puts
|
17
|
+
|
18
|
+
puts '2. All moon phases in 2025 (first 8):'
|
19
|
+
puts '-' * 50
|
20
|
+
year_phases = MoonPhaseTracker.phases_for_year(2025)
|
21
|
+
puts tracker.format_phases(year_phases.first(8), 'First 8 phases of 2025')
|
22
|
+
puts
|
23
|
+
|
24
|
+
puts '3. Next 6 phases starting from 2025-08-01:'
|
25
|
+
puts '-' * 45
|
26
|
+
future_phases = MoonPhaseTracker.phases_from_date('2025-08-01', 6)
|
27
|
+
puts tracker.format_phases(future_phases, 'Upcoming phases')
|
28
|
+
puts
|
29
|
+
|
30
|
+
puts '4. Current month phases:'
|
31
|
+
puts '-' * 25
|
32
|
+
current_phases = tracker.current_month_phases
|
33
|
+
if current_phases.any?
|
34
|
+
puts tracker.format_phases(current_phases, 'Current month phases')
|
35
|
+
else
|
36
|
+
puts 'No phases found for the current month.'
|
37
|
+
end
|
38
|
+
puts
|
39
|
+
|
40
|
+
puts '5. Next moon phase:'
|
41
|
+
puts '-' * 25
|
42
|
+
next_phase = tracker.next_phase
|
43
|
+
if next_phase
|
44
|
+
puts next_phase
|
45
|
+
puts "Details: #{next_phase.to_h}"
|
46
|
+
else
|
47
|
+
puts 'Could not retrieve the next phase.'
|
48
|
+
end
|
49
|
+
puts
|
50
|
+
|
51
|
+
puts '6. Different phase representations:'
|
52
|
+
puts '-' * 42
|
53
|
+
if august_phases.any?
|
54
|
+
phase = august_phases.first
|
55
|
+
puts "String: #{phase}"
|
56
|
+
puts "Hash: #{phase.to_h}"
|
57
|
+
puts "Symbol: #{phase.symbol}"
|
58
|
+
puts "Formatted date: #{phase.formatted_date}"
|
59
|
+
puts "Formatted time: #{phase.formatted_time}"
|
60
|
+
end
|
61
|
+
rescue MoonPhaseTracker::NetworkError => e
|
62
|
+
puts "Network error: #{e.message}"
|
63
|
+
puts 'Please check your internet connection.'
|
64
|
+
rescue MoonPhaseTracker::APIError => e
|
65
|
+
puts "API error: #{e.message}"
|
66
|
+
puts 'The service may be temporarily unavailable.'
|
67
|
+
rescue MoonPhaseTracker::InvalidDateError => e
|
68
|
+
puts "Date error: #{e.message}"
|
69
|
+
rescue StandardError => e
|
70
|
+
puts "Unexpected error: #{e.message}"
|
71
|
+
end
|
72
|
+
|
73
|
+
puts
|
74
|
+
puts '=== End of Examples ==='
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "json"
|
5
|
+
require "uri"
|
6
|
+
require_relative "rate_limiter"
|
7
|
+
|
8
|
+
module MoonPhaseTracker
|
9
|
+
class Client
|
10
|
+
BASE_URL = "https://aa.usno.navy.mil/api/moon/phases"
|
11
|
+
TIMEOUT = 10
|
12
|
+
|
13
|
+
def initialize(rate_limiter: nil)
|
14
|
+
@uri_cache = {}
|
15
|
+
@rate_limiter = rate_limiter || create_default_rate_limiter
|
16
|
+
end
|
17
|
+
|
18
|
+
def phases_from_date(date, num_phases = 12)
|
19
|
+
validate_date_format!(date)
|
20
|
+
validate_num_phases!(num_phases)
|
21
|
+
|
22
|
+
params = { date: date, nump: num_phases }
|
23
|
+
make_request("#{BASE_URL}/date", params)
|
24
|
+
end
|
25
|
+
|
26
|
+
def phases_for_year(year)
|
27
|
+
validate_year!(year)
|
28
|
+
|
29
|
+
params = { year: year }
|
30
|
+
make_request("#{BASE_URL}/year", params)
|
31
|
+
end
|
32
|
+
|
33
|
+
def rate_limit_info
|
34
|
+
@rate_limiter&.configuration
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def create_default_rate_limiter
|
40
|
+
return nil if ENV["MOON_PHASE_RATE_LIMIT"] == "0" || ENV["MOON_PHASE_RATE_LIMIT"] == "false"
|
41
|
+
|
42
|
+
RateLimiter.new
|
43
|
+
end
|
44
|
+
|
45
|
+
def make_request(endpoint, params = {})
|
46
|
+
uri = build_uri(endpoint, params)
|
47
|
+
|
48
|
+
@rate_limiter&.throttle
|
49
|
+
|
50
|
+
begin
|
51
|
+
response = fetch_with_timeout(uri)
|
52
|
+
parse_response(response)
|
53
|
+
rescue Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
|
54
|
+
raise NetworkError, "Request timeout: #{e.message}"
|
55
|
+
rescue Net::HTTPError, SocketError => e
|
56
|
+
raise NetworkError, "Network error: #{e.message}"
|
57
|
+
rescue JSON::ParserError => e
|
58
|
+
raise APIError, "Invalid JSON response: #{e.message}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def build_uri(endpoint, params)
|
63
|
+
cache_key = "#{endpoint}_#{params.hash}"
|
64
|
+
|
65
|
+
@uri_cache[cache_key] ||= begin
|
66
|
+
uri = URI(endpoint)
|
67
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
68
|
+
uri
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def fetch_with_timeout(uri)
|
73
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
74
|
+
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
75
|
+
request = Net::HTTP::Get.new(uri)
|
76
|
+
request["User-Agent"] = "MoonPhaseTracker/#{MoonPhaseTracker::VERSION}"
|
77
|
+
|
78
|
+
response = http.request(request)
|
79
|
+
|
80
|
+
unless response.is_a?(Net::HTTPSuccess)
|
81
|
+
raise APIError, "API request failed: #{response.code} - #{response.message}"
|
82
|
+
end
|
83
|
+
|
84
|
+
response
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def parse_response(response)
|
89
|
+
data = JSON.parse(response.body)
|
90
|
+
|
91
|
+
raise APIError, "API error: #{data['error']}" if data["error"]
|
92
|
+
|
93
|
+
data
|
94
|
+
end
|
95
|
+
|
96
|
+
def validate_date_format!(date)
|
97
|
+
return if date.match?(/^\d{4}-\d{1,2}-\d{1,2}$/)
|
98
|
+
|
99
|
+
raise InvalidDateError, "Date must be in YYYY-MM-DD format"
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_num_phases!(num_phases)
|
103
|
+
return if num_phases.is_a?(Integer) && num_phases.between?(1, 99)
|
104
|
+
|
105
|
+
raise InvalidDateError, "Number of phases must be between 1 and 99"
|
106
|
+
end
|
107
|
+
|
108
|
+
def validate_year!(year)
|
109
|
+
current_year = Date.today.year
|
110
|
+
|
111
|
+
return if year.is_a?(Integer) && year.between?(1700, current_year + 10)
|
112
|
+
|
113
|
+
raise InvalidDateError, "Year must be between 1700 and #{current_year + 10}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MoonPhaseTracker
|
4
|
+
class Phase
|
5
|
+
class Comparator
|
6
|
+
def self.compare_phases(phase_a, phase_b)
|
7
|
+
return nil unless phases_comparable?(phase_a, phase_b)
|
8
|
+
|
9
|
+
date_comparison = compare_dates(phase_a.date, phase_b.date)
|
10
|
+
return date_comparison unless date_comparison.zero?
|
11
|
+
|
12
|
+
compare_times(phase_a.time, phase_b.time)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.in_month?(phase_date, year, month)
|
16
|
+
return false unless phase_date
|
17
|
+
|
18
|
+
phase_date.year == year && phase_date.month == month
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.in_year?(phase_date, year)
|
22
|
+
return false unless phase_date
|
23
|
+
|
24
|
+
phase_date.year == year
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.phases_comparable?(phase_a, phase_b)
|
28
|
+
phase_a.is_a?(Phase) && phase_b.is_a?(Phase)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.compare_dates(date_a, date_b)
|
32
|
+
date_a <=> date_b
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.compare_times(time_a, time_b)
|
36
|
+
time_a <=> time_b
|
37
|
+
end
|
38
|
+
|
39
|
+
private_class_method :phases_comparable?, :compare_dates, :compare_times
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MoonPhaseTracker
|
4
|
+
class Phase
|
5
|
+
class Formatter
|
6
|
+
INVALID_DATE_MESSAGE = "Invalid date"
|
7
|
+
INVALID_TIME_MESSAGE = "Invalid time"
|
8
|
+
DATE_FORMAT = "%Y-%m-%d"
|
9
|
+
TIME_FORMAT = "%H:%M"
|
10
|
+
|
11
|
+
def self.format_date(date)
|
12
|
+
return INVALID_DATE_MESSAGE unless date
|
13
|
+
|
14
|
+
date.strftime(DATE_FORMAT)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.format_time(time)
|
18
|
+
return INVALID_TIME_MESSAGE unless time
|
19
|
+
|
20
|
+
time.strftime(TIME_FORMAT)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.format_phase_description(name, symbol, date, time)
|
24
|
+
formatted_date = format_date(date)
|
25
|
+
formatted_time = format_time(time)
|
26
|
+
|
27
|
+
"#{symbol} #{name} - #{formatted_date} at #{formatted_time}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.build_hash_representation(phase_attributes)
|
31
|
+
{
|
32
|
+
name: phase_attributes[:name],
|
33
|
+
phase_type: phase_attributes[:phase_type],
|
34
|
+
date: format_date(phase_attributes[:date]),
|
35
|
+
time: format_time(phase_attributes[:time]),
|
36
|
+
symbol: phase_attributes[:symbol],
|
37
|
+
iso_date: phase_attributes[:date]&.iso8601,
|
38
|
+
utc_time: phase_attributes[:time]&.utc&.iso8601,
|
39
|
+
interpolated: phase_attributes[:interpolated]
|
40
|
+
}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MoonPhaseTracker
|
4
|
+
class Phase
|
5
|
+
class Mapper
|
6
|
+
PHASE_NAMES = {
|
7
|
+
"New Moon" => :new_moon,
|
8
|
+
"First Quarter" => :first_quarter,
|
9
|
+
"Full Moon" => :full_moon,
|
10
|
+
"Last Quarter" => :last_quarter,
|
11
|
+
"Waxing Crescent" => :waxing_crescent,
|
12
|
+
"Waxing Gibbous" => :waxing_gibbous,
|
13
|
+
"Waning Gibbous" => :waning_gibbous,
|
14
|
+
"Waning Crescent" => :waning_crescent
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
PHASE_SYMBOLS = {
|
18
|
+
new_moon: "🌑",
|
19
|
+
waxing_crescent: "🌒",
|
20
|
+
first_quarter: "🌓",
|
21
|
+
waxing_gibbous: "🌔",
|
22
|
+
full_moon: "🌕",
|
23
|
+
waning_gibbous: "🌖",
|
24
|
+
last_quarter: "🌗",
|
25
|
+
waning_crescent: "🌘"
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
DEFAULT_PHASE_TYPE = :unknown
|
29
|
+
DEFAULT_SYMBOL = "🌘"
|
30
|
+
|
31
|
+
def self.map_phase_type(phase_name)
|
32
|
+
PHASE_NAMES[phase_name] || DEFAULT_PHASE_TYPE
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.get_phase_symbol(phase_type)
|
36
|
+
PHASE_SYMBOLS[phase_type] || DEFAULT_SYMBOL
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.available_phase_names
|
40
|
+
PHASE_NAMES.keys
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.available_phase_types
|
44
|
+
PHASE_NAMES.values
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.valid_phase_name?(phase_name)
|
48
|
+
PHASE_NAMES.key?(phase_name)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.valid_phase_type?(phase_type)
|
52
|
+
PHASE_SYMBOLS.key?(phase_type)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module MoonPhaseTracker
|
7
|
+
class Phase
|
8
|
+
class Parser
|
9
|
+
def self.build_date(phase_data)
|
10
|
+
year = phase_data["year"]
|
11
|
+
month = phase_data["month"]
|
12
|
+
day = phase_data["day"]
|
13
|
+
|
14
|
+
return nil unless valid_date_components?(year, month, day)
|
15
|
+
|
16
|
+
Date.new(year.to_i, month.to_i, day.to_i)
|
17
|
+
rescue Date::Error
|
18
|
+
nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.parse_time(time_string)
|
22
|
+
return nil unless time_string
|
23
|
+
|
24
|
+
Time.parse("#{time_string} UTC")
|
25
|
+
rescue ArgumentError
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.valid_date_components?(year, month, day)
|
30
|
+
year && month && day
|
31
|
+
end
|
32
|
+
|
33
|
+
private_class_method :valid_date_components?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "phase/parser"
|
4
|
+
require_relative "phase/formatter"
|
5
|
+
require_relative "phase/comparator"
|
6
|
+
require_relative "phase/mapper"
|
7
|
+
|
8
|
+
module MoonPhaseTracker
|
9
|
+
class Phase
|
10
|
+
include Comparable
|
11
|
+
|
12
|
+
attr_reader :name, :date, :time, :phase_type, :interpolated
|
13
|
+
|
14
|
+
def initialize(phase_data, interpolated: false)
|
15
|
+
@name = phase_data["phase"]
|
16
|
+
@phase_type = Mapper.map_phase_type(@name)
|
17
|
+
@date = Parser.build_date(phase_data)
|
18
|
+
@time = Parser.parse_time(phase_data["time"])
|
19
|
+
@interpolated = interpolated
|
20
|
+
end
|
21
|
+
|
22
|
+
def formatted_date
|
23
|
+
Formatter.format_date(@date)
|
24
|
+
end
|
25
|
+
|
26
|
+
def formatted_time
|
27
|
+
Formatter.format_time(@time)
|
28
|
+
end
|
29
|
+
|
30
|
+
def symbol
|
31
|
+
Mapper.get_phase_symbol(@phase_type)
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
Formatter.format_phase_description(@name, symbol, @date, @time)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_h
|
39
|
+
phase_attributes = {
|
40
|
+
name: @name,
|
41
|
+
phase_type: @phase_type,
|
42
|
+
date: @date,
|
43
|
+
time: @time,
|
44
|
+
symbol: symbol,
|
45
|
+
interpolated: @interpolated
|
46
|
+
}
|
47
|
+
|
48
|
+
Formatter.build_hash_representation(phase_attributes)
|
49
|
+
end
|
50
|
+
|
51
|
+
def <=>(other)
|
52
|
+
Comparator.compare_phases(self, other)
|
53
|
+
end
|
54
|
+
|
55
|
+
def in_month?(year, month)
|
56
|
+
Comparator.in_month?(@date, year, month)
|
57
|
+
end
|
58
|
+
|
59
|
+
def in_year?(year)
|
60
|
+
Comparator.in_year?(@date, year)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../phase"
|
4
|
+
|
5
|
+
module MoonPhaseTracker
|
6
|
+
class PhaseCalculator
|
7
|
+
class CycleEstimator
|
8
|
+
LUNAR_CYCLE_DAYS = 29.530588853
|
9
|
+
PHASE_INTERVAL_DAYS = LUNAR_CYCLE_DAYS / 4.0
|
10
|
+
DEFAULT_TIME = "12:00"
|
11
|
+
|
12
|
+
def estimate_next_cycle_phase(last_phase)
|
13
|
+
return nil unless valid_phase_for_estimation?(last_phase)
|
14
|
+
|
15
|
+
estimated_date = calculate_estimated_date(last_phase)
|
16
|
+
phase_data = build_estimated_phase_data(estimated_date, last_phase)
|
17
|
+
|
18
|
+
Phase.new(phase_data)
|
19
|
+
rescue Date::Error, ArgumentError => e
|
20
|
+
warn "Failed to estimate next cycle phase: #{e.class}"
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def valid_phase_for_estimation?(phase)
|
27
|
+
phase && phase.date
|
28
|
+
end
|
29
|
+
|
30
|
+
def calculate_estimated_date(last_phase)
|
31
|
+
last_phase.date + PHASE_INTERVAL_DAYS
|
32
|
+
end
|
33
|
+
|
34
|
+
def build_estimated_phase_data(estimated_date, reference_phase)
|
35
|
+
{
|
36
|
+
"phase" => "New Moon",
|
37
|
+
"year" => estimated_date.year,
|
38
|
+
"month" => estimated_date.month,
|
39
|
+
"day" => estimated_date.day,
|
40
|
+
"time" => determine_estimated_time(reference_phase)
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def determine_estimated_time(reference_phase)
|
45
|
+
return reference_phase.time.strftime("%H:%M") if reference_phase.time
|
46
|
+
|
47
|
+
DEFAULT_TIME
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|