├── .rspec ├── Gemfile ├── .gitignore ├── .travis.yml ├── lib ├── sun_times │ └── version.rb └── sun_times.rb ├── Rakefile ├── spec ├── spec_helper.rb └── unit │ └── ruby_sun_times_spec.rb ├── ruby-sun-times.gemspec ├── COPYING └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | doc/* 3 | pkg/* 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | branches: 3 | only: 4 | - master 5 | rvm: 6 | - 1.8.7 7 | - 1.9.3 8 | - 2.0.0 9 | - 2.1.0 10 | script: "bundle exec rspec" 11 | cache: bundler 12 | -------------------------------------------------------------------------------- /lib/sun_times/version.rb: -------------------------------------------------------------------------------- 1 | class SunTimes 2 | module VERSION #:nodoc: 3 | MAJOR = 0 4 | MINOR = 1 5 | TINY = 5 6 | 7 | STRING = [MAJOR, MINOR, TINY].join('.') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | task :default => :spec 6 | 7 | RSpec::Core::RakeTask.new do |t| 8 | t.pattern = 'spec/**/*_spec.rb' 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.expand_path('../lib', File.dirname(__FILE__)) 2 | 3 | if RUBY_VERSION >= '1.9' 4 | require 'simplecov' 5 | SimpleCov.start 6 | end 7 | 8 | require 'sun_times' 9 | 10 | RSpec.configure do |config| 11 | config.treat_symbols_as_metadata_keys_with_true_values = true 12 | config.run_all_when_everything_filtered = true 13 | config.filter_run :focus 14 | config.order = 'random' 15 | end 16 | -------------------------------------------------------------------------------- /ruby-sun-times.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH << File.expand_path('lib', File.dirname(__FILE__)) 3 | 4 | require 'sun_times/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'ruby-sun-times' 8 | spec.summary = 'Calculate sunrise and sunset times for a given time and place' 9 | spec.version = SunTimes::VERSION::STRING 10 | spec.licenses = ['MIT'] 11 | 12 | spec.homepage = 'https://github.com/joeyates/ruby-sun-times' 13 | spec.author = 'Joe Yates' 14 | spec.email = 'joe.g.yates@gmail.com' 15 | 16 | spec.files = `git ls-files -z`.split("\0") 17 | spec.executables = spec.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^spec/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_development_dependency 'pry' 22 | spec.add_development_dependency 'rspec' 23 | if RUBY_VERSION >= '1.9' 24 | spec.add_development_dependency 'simplecov', '~> 0.7.1' 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2014 Joe Yates 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-sun-times [![Build Status](https://travis-ci.org/joeyates/ruby-sun-times.png?branch=master)][Continuous Integration] 2 | 3 | *Calculates sunrise and sunset times* 4 | 5 | * [Source Code] 6 | * [API documentation] 7 | * [Rubygem] 8 | * [Continuous Integration] 9 | 10 | [Source Code]: https://github.com/joeyates/ruby-sun-times "Source code at GitHub" 11 | [API documentation]: http://rubydoc.info/gems/ruby-sun-times/frames "RDoc API Documentation at Rubydoc.info" 12 | [Rubygem]: http://rubygems.org/gems/ruby-sun-times "Ruby gem at rubygems.org" 13 | [Continuous Integration]: http://travis-ci.org/joeyates/ruby-sun-times "Build status by Travis-CI" 14 | 15 | The algorithm comes from the [Almanac for computers][almanac-for-computers-1991]. 16 | 17 | [almanac-for-computers-1991]: http://babel.hathitrust.org/cgi/pt?id=uc1.31822006852784;view=1up;seq=25 18 | 19 | # Usage 20 | 21 | ## Requiring 22 | 23 | In a Gemfile/Gemspec: 24 | 25 | ```ruby 26 | gem 'ruby-sun-times', require: 'sun_times' 27 | ``` 28 | 29 | Directly: 30 | 31 | ```ruby 32 | require 'sun_times' 33 | ``` 34 | 35 | ## Methods 36 | 37 | The two methods `rise` and `set` each return a Time. 38 | 39 | ```ruby 40 | day = Date.new(2010, 3, 8) 41 | latitude = 43.779 42 | longitude = 11.432 43 | sun_times = SunTimes.new 44 | sun_times.rise(day, latitude, longitude) # => 2010-03-08 05:39:53 UTC 45 | sun_times.set(day, latitude, longitude) # => 2010-03-08 17:11:16 UTC 46 | ``` 47 | 48 | # References 49 | 50 | * http://www.astro.uu.nl/~strous/AA/en/reken/zonpositie.html - Calculations 51 | * http://williams.best.vwh.net/sunrise_sunset_algorithm.htm - Algorithm 52 | * http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/264573 - Ken Bloom's implementation 53 | -------------------------------------------------------------------------------- /spec/unit/ruby_sun_times_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SunTimes do 4 | let(:options) { {} } 5 | let(:day) { Date.new(2010, 3, 8) } 6 | let(:latitude) { 43.779 } 7 | let(:longitude) { 11.432 } 8 | let(:set) { Time.gm(2010, 3, 8, 17, 11, 16) } 9 | let(:rise) { Time.gm(2010, 3, 8, 5, 39, 53) } 10 | let(:midsummer) { Date.new(2010, 6, 21) } 11 | let(:midwinter) { Date.new(2010, 12, 21) } 12 | let(:north_cape_latitude) { 71.170219 } 13 | let(:north_cape_longitude) { 25.785556 } 14 | 15 | subject { described_class.new(options) } 16 | 17 | shared_examples_for 'never sets and never rises options' do |method| 18 | context ':never_rises_result' do 19 | let(:options) do 20 | { 21 | :never_rises_result => :never_rises, 22 | :never_sets_result => :never_sets, 23 | } 24 | end 25 | 26 | context 'never rises' do 27 | it 'uses the supplied value' do 28 | result = subject.send(method, midwinter, north_cape_latitude, north_cape_longitude) 29 | expect(result).to eq(:never_rises) 30 | end 31 | end 32 | 33 | context 'never sets' do 34 | it 'uses the supplied value' do 35 | result = subject.send(method, midsummer, north_cape_latitude, north_cape_longitude) 36 | expect(result).to eq(:never_sets) 37 | end 38 | end 39 | end 40 | end 41 | 42 | shared_examples_for 'timezones' do |method| 43 | context 'with timezone' do 44 | let(:zone) { Rational(-8, 24) } 45 | let(:day_with_zone) { DateTime.new(2011, 12, 13, 0, 0, 0, zone) } 46 | 47 | it 'calculates according to the supplied value' do 48 | set = subject.send(method, day_with_zone, 45.52, -122.681944) 49 | result_datetime = DateTime.new(set.year, set.month, set.day, set.hour, set.min, set.sec, 0) 50 | 51 | expect(result_datetime).to be > day_with_zone 52 | expect(result_datetime).to be <= day_with_zone + 1 53 | end 54 | end 55 | end 56 | 57 | shared_examples_for 'no event' do |method| 58 | context 'midnight sun' do 59 | it 'is nil' do 60 | result = subject.send(method, midsummer, north_cape_latitude, north_cape_longitude) 61 | expect(result).to be_nil 62 | end 63 | end 64 | 65 | context 'continuous night' do 66 | it 'is nil' do 67 | result = subject.send(method, midwinter, north_cape_latitude, north_cape_longitude) 68 | expect(result).to be_nil 69 | end 70 | end 71 | end 72 | 73 | shared_examples_for 'limiting cases' do |method| 74 | context 'last day of the year' do 75 | let(:zone) { Rational(-8, 24) } 76 | let(:day) { DateTime.new(2013, 12, 31, 8, 59, 5, zone) } 77 | 78 | it 'calculates correctly' do 79 | subject.send(method, day, 47.5, -122) 80 | end 81 | end 82 | end 83 | 84 | # SunTimes.rise is deprecated, use Suntimes.new.set 85 | describe '.rise' do 86 | it 'returns the sunrise time' do 87 | result = described_class.rise(day, latitude, longitude) 88 | expect(result).to be_within(1).of(rise) 89 | end 90 | end 91 | 92 | # SunTimes.set is deprecated, use Suntimes.new.set 93 | describe '.set' do 94 | it 'returns the sunset time' do 95 | result = described_class.set(day, latitude, longitude) 96 | expect(result).to be_within(1).of(set) 97 | end 98 | end 99 | 100 | describe '#rise' do 101 | it 'returns the sunrise time' do 102 | result = subject.rise(day, latitude, longitude) 103 | expect(result).to be_within(1).of(rise) 104 | end 105 | 106 | include_examples 'timezones', :rise 107 | include_examples 'limiting cases', :rise 108 | include_examples 'no event', :rise 109 | 110 | context 'options' do 111 | include_examples 'never sets and never rises options', :rise 112 | end 113 | end 114 | 115 | describe '#set' do 116 | it 'returns the sunset time' do 117 | result = subject.set(day, latitude, longitude) 118 | expect(result).to be_within(1).of(set) 119 | end 120 | 121 | include_examples 'timezones', :set 122 | include_examples 'limiting cases', :set 123 | include_examples 'no event', :rise 124 | 125 | context 'options' do 126 | include_examples 'never sets and never rises options', :set 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/sun_times.rb: -------------------------------------------------------------------------------- 1 | # Algorithm from http://williams.best.vwh.net/sunrise_sunset_algorithm.htm 2 | 3 | require 'date' 4 | 5 | class SunTimes 6 | DEFAULT_ZENITH = 90.83333 7 | KNOWN_EVENTS = [:rise, :set] 8 | DEGREES_PER_HOUR = 360.0 / 24.0 9 | 10 | attr_reader :options 11 | 12 | # Deprecated: use SunTimes.new.rise(...) 13 | def self.rise(date, latitude, longitude, options = {}) 14 | new(options).rise(date, latitude, longitude) 15 | end 16 | 17 | # Deprecated: use SunTimes.new.set(...) 18 | def self.set(date, latitude, longitude, options = {}) 19 | new(options).set(date, latitude, longitude) 20 | end 21 | 22 | # Deprecated: use SunTimes.new.rise/set(...) 23 | def self.calculate(event, date, latitude, longitude, options = {}) 24 | new(options).calculate(event, date, latitude, longitude) 25 | end 26 | 27 | # * +options+ - 28 | # * :never_rises_result - the value to be returned if the sun never rises on the supplied date, 29 | # * :never_sets_result - the value to be returned if the sun never sets on the supplied date, 30 | # * :zenith - default 90.83333 31 | def initialize(options = {}) 32 | @options = { 33 | :never_sets_result => nil, 34 | :never_rises_result => nil, 35 | :zenith => DEFAULT_ZENITH, 36 | }.merge(options) 37 | end 38 | 39 | # Calculates the sunrise time for a specific date and location 40 | # 41 | # ==== Parameters 42 | # * +date+ - An object that responds to :to_datetime. 43 | # * +latitude+ - The latitude of the location in degrees. 44 | # * +longitude+ - The longitude of the location in degrees. 45 | # 46 | # ==== Example 47 | # SunTimes.new.rise(Date.new(2010, 3, 8), 43.779, 11.432) 48 | # > Mon Mar 08 05:39:53 UTC 2010 49 | def rise(date, latitude, longitude) 50 | calculate(:rise, date, latitude, longitude) 51 | end 52 | 53 | # calculates sunset, see #rise for parameters 54 | def set(date, latitude, longitude) 55 | calculate(:set, date, latitude, longitude) 56 | end 57 | 58 | private 59 | 60 | def calculate(event, date, latitude, longitude) 61 | datetime = to_datetime(date) 62 | raise "Unknown event '#{event}'" unless KNOWN_EVENTS.include?(event) 63 | 64 | # lngHour 65 | longitude_hour = longitude / DEGREES_PER_HOUR 66 | 67 | # t 68 | base_time = 69 | if event == :rise 70 | 6.0 71 | else 72 | 18.0 73 | end 74 | approximate_time = datetime.yday + (base_time - longitude_hour) / 24.0 75 | 76 | # M 77 | mean_sun_anomaly = (0.9856 * approximate_time) - 3.289 78 | 79 | # L 80 | sun_true_longitude = mean_sun_anomaly + 81 | (1.916 * Math.sin(degrees_to_radians(mean_sun_anomaly))) + 82 | (0.020 * Math.sin(2 * degrees_to_radians(mean_sun_anomaly))) + 83 | 282.634 84 | sun_true_longitude = coerce_degrees(sun_true_longitude) 85 | 86 | # RA 87 | tan_right_ascension = 0.91764 * Math.tan(degrees_to_radians(sun_true_longitude)) 88 | sun_right_ascension = radians_to_degrees(Math.atan(tan_right_ascension)) 89 | sun_right_ascension = coerce_degrees(sun_right_ascension) 90 | 91 | # right ascension value needs to be in the same quadrant as L 92 | sun_true_longitude_quadrant = (sun_true_longitude / 90.0).floor * 90.0 93 | sun_right_ascension_quadrant = (sun_right_ascension / 90.0).floor * 90.0 94 | sun_right_ascension += (sun_true_longitude_quadrant - sun_right_ascension_quadrant) 95 | 96 | # RA = RA / 15 97 | sun_right_ascension_hours = sun_right_ascension / DEGREES_PER_HOUR 98 | 99 | sin_declination = 0.39782 * Math.sin(degrees_to_radians(sun_true_longitude)) 100 | cos_declination = Math.cos(Math.asin(sin_declination)) 101 | 102 | cos_local_hour_angle = 103 | (Math.cos(degrees_to_radians(options[:zenith])) - (sin_declination * Math.sin(degrees_to_radians(latitude)))) / 104 | (cos_declination * Math.cos(degrees_to_radians(latitude))) 105 | 106 | # the sun never rises on this location (on the specified date) 107 | return options[:never_rises_result] if cos_local_hour_angle > 1 108 | # the sun never sets on this location (on the specified date) 109 | return options[:never_sets_result] if cos_local_hour_angle < -1 110 | 111 | # H 112 | suns_local_hour = 113 | if event == :rise 114 | 360 - radians_to_degrees(Math.acos(cos_local_hour_angle)) 115 | else 116 | radians_to_degrees(Math.acos(cos_local_hour_angle)) 117 | end 118 | 119 | # H = H / 15 120 | suns_local_hour_hours = suns_local_hour / DEGREES_PER_HOUR 121 | 122 | # T = H + RA - (0.06571 * t) - 6.622 123 | local_mean_time = suns_local_hour_hours + sun_right_ascension_hours - (0.06571 * approximate_time) - 6.622 124 | 125 | # UT = T - lngHour 126 | gmt_hours = local_mean_time - longitude_hour 127 | gmt_hours -= 24.0 if gmt_hours > 24 128 | gmt_hours += 24.0 if gmt_hours < 0 129 | 130 | offset_hours = datetime.offset * 24.0 131 | 132 | if gmt_hours + offset_hours < 0 133 | next_day = next_day(datetime) 134 | return calculate(event, next_day.new_offset, latitude, longitude) 135 | end 136 | if gmt_hours + offset_hours > 24 137 | previous_day = prev_day(datetime) 138 | return calculate(event, previous_day.new_offset, latitude, longitude) 139 | end 140 | 141 | hour = gmt_hours.floor 142 | hour_remainder = (gmt_hours.to_f - hour.to_f) * 60.0 143 | minute = hour_remainder.floor 144 | seconds = (hour_remainder - minute) * 60.0 145 | 146 | Time.gm(datetime.year, datetime.month, datetime.day, hour, minute, seconds) 147 | end 148 | 149 | ############################ 150 | # ruby 1.8 compatibility 151 | 152 | def to_datetime(date) 153 | if date.respond_to?(:to_datetime) 154 | date.to_datetime 155 | else 156 | values = [ 157 | date.year, 158 | date.month, 159 | date.day, 160 | ] 161 | [:hour, :minute, :second, :zone].each do |m| 162 | values << 163 | if date.respond_to?(m) 164 | date.send(m) 165 | else 166 | 0 167 | end 168 | end 169 | DateTime.new(*values) 170 | end 171 | end 172 | 173 | def prev_day(datetime) 174 | datetime - 1 175 | end 176 | 177 | def next_day(datetime) 178 | datetime + 1 179 | end 180 | 181 | ############################ 182 | 183 | def degrees_to_radians(d) 184 | d.to_f / 360.0 * 2.0 * Math::PI 185 | end 186 | 187 | def radians_to_degrees(r) 188 | r.to_f * 360.0 / (2.0 * Math::PI) 189 | end 190 | 191 | def coerce_degrees(d) 192 | if d < 0 193 | d += 360 194 | return coerce_degrees(d) 195 | end 196 | if d >= 360 197 | d -= 360 198 | return coerce_degrees(d) 199 | end 200 | d 201 | end 202 | end 203 | --------------------------------------------------------------------------------