├── .github └── workflows │ └── test.yml ├── .gitignore ├── .style.yapf ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.cfg ├── setup.py ├── suncalc ├── __init__.py └── suncalc.py └── tests └── test_suncalc.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | # On every pull request, but only on push to master 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | 17 | steps: 18 | - uses: actions/checkout@v2.1.1 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install .['tests'] 29 | 30 | - name: Run tests 31 | run: | 32 | pytest 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | # Align closing bracket with visual indentation. 3 | align_closing_bracket_with_visual_indent=False 4 | 5 | # Allow dictionary keys to exist on multiple lines. For example: 6 | # 7 | # x = { 8 | # ('this is the first element of a tuple', 9 | # 'this is the second element of a tuple'): 10 | # value, 11 | # } 12 | allow_multiline_dictionary_keys=False 13 | 14 | # Allow lambdas to be formatted on more than one line. 15 | allow_multiline_lambdas=False 16 | 17 | # Allow splitting before a default / named assignment in an argument list. 18 | allow_split_before_default_or_named_assigns=True 19 | 20 | # Allow splits before the dictionary value. 21 | allow_split_before_dict_value=True 22 | 23 | # Let spacing indicate operator precedence. For example: 24 | # 25 | # a = 1 * 2 + 3 / 4 26 | # b = 1 / 2 - 3 * 4 27 | # c = (1 + 2) * (3 - 4) 28 | # d = (1 - 2) / (3 + 4) 29 | # e = 1 * 2 - 3 30 | # f = 1 + 2 + 3 + 4 31 | # 32 | # will be formatted as follows to indicate precedence: 33 | # 34 | # a = 1*2 + 3/4 35 | # b = 1/2 - 3*4 36 | # c = (1+2) * (3-4) 37 | # d = (1-2) / (3+4) 38 | # e = 1*2 - 3 39 | # f = 1 + 2 + 3 + 4 40 | # 41 | arithmetic_precedence_indication=False 42 | 43 | # Number of blank lines surrounding top-level function and class 44 | # definitions. 45 | blank_lines_around_top_level_definition=2 46 | 47 | # Insert a blank line before a class-level docstring. 48 | blank_line_before_class_docstring=False 49 | 50 | # Insert a blank line before a module docstring. 51 | blank_line_before_module_docstring=False 52 | 53 | # Insert a blank line before a 'def' or 'class' immediately nested 54 | # within another 'def' or 'class'. For example: 55 | # 56 | # class Foo: 57 | # # <------ this blank line 58 | # def method(): 59 | # ... 60 | blank_line_before_nested_class_or_def=False 61 | 62 | # Do not split consecutive brackets. Only relevant when 63 | # dedent_closing_brackets is set. For example: 64 | # 65 | # call_func_that_takes_a_dict( 66 | # { 67 | # 'key1': 'value1', 68 | # 'key2': 'value2', 69 | # } 70 | # ) 71 | # 72 | # would reformat to: 73 | # 74 | # call_func_that_takes_a_dict({ 75 | # 'key1': 'value1', 76 | # 'key2': 'value2', 77 | # }) 78 | coalesce_brackets=True 79 | 80 | # The column limit. 81 | column_limit=80 82 | 83 | # The style for continuation alignment. Possible values are: 84 | # 85 | # - SPACE: Use spaces for continuation alignment. This is default behavior. 86 | # - FIXED: Use fixed number (CONTINUATION_INDENT_WIDTH) of columns 87 | # (ie: CONTINUATION_INDENT_WIDTH/INDENT_WIDTH tabs or 88 | # CONTINUATION_INDENT_WIDTH spaces) for continuation alignment. 89 | # - VALIGN-RIGHT: Vertically align continuation lines to multiple of 90 | # INDENT_WIDTH columns. Slightly right (one tab or a few spaces) if 91 | # cannot vertically align continuation lines with indent characters. 92 | continuation_align_style=SPACE 93 | 94 | # Indent width used for line continuations. 95 | continuation_indent_width=4 96 | 97 | # Put closing brackets on a separate line, dedented, if the bracketed 98 | # expression can't fit in a single line. Applies to all kinds of brackets, 99 | # including function definitions and calls. For example: 100 | # 101 | # config = { 102 | # 'key1': 'value1', 103 | # 'key2': 'value2', 104 | # } # <--- this bracket is dedented and on a separate line 105 | # 106 | # time_series = self.remote_client.query_entity_counters( 107 | # entity='dev3246.region1', 108 | # key='dns.query_latency_tcp', 109 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), 110 | # start_ts=now()-timedelta(days=3), 111 | # end_ts=now(), 112 | # ) # <--- this bracket is dedented and on a separate line 113 | dedent_closing_brackets=False 114 | 115 | # Disable the heuristic which places each list element on a separate line 116 | # if the list is comma-terminated. 117 | disable_ending_comma_heuristic=False 118 | 119 | # Place each dictionary entry onto its own line. 120 | each_dict_entry_on_separate_line=True 121 | 122 | # Require multiline dictionary even if it would normally fit on one line. 123 | # For example: 124 | # 125 | # config = { 126 | # 'key1': 'value1' 127 | # } 128 | force_multiline_dict=False 129 | 130 | # The regex for an i18n comment. The presence of this comment stops 131 | # reformatting of that line, because the comments are required to be 132 | # next to the string they translate. 133 | i18n_comment= 134 | 135 | # The i18n function call names. The presence of this function stops 136 | # reformattting on that line, because the string it has cannot be moved 137 | # away from the i18n comment. 138 | i18n_function_call= 139 | 140 | # Indent blank lines. 141 | indent_blank_lines=False 142 | 143 | # Put closing brackets on a separate line, indented, if the bracketed 144 | # expression can't fit in a single line. Applies to all kinds of brackets, 145 | # including function definitions and calls. For example: 146 | # 147 | # config = { 148 | # 'key1': 'value1', 149 | # 'key2': 'value2', 150 | # } # <--- this bracket is indented and on a separate line 151 | # 152 | # time_series = self.remote_client.query_entity_counters( 153 | # entity='dev3246.region1', 154 | # key='dns.query_latency_tcp', 155 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), 156 | # start_ts=now()-timedelta(days=3), 157 | # end_ts=now(), 158 | # ) # <--- this bracket is indented and on a separate line 159 | indent_closing_brackets=False 160 | 161 | # Indent the dictionary value if it cannot fit on the same line as the 162 | # dictionary key. For example: 163 | # 164 | # config = { 165 | # 'key1': 166 | # 'value1', 167 | # 'key2': value1 + 168 | # value2, 169 | # } 170 | indent_dictionary_value=True 171 | 172 | # The number of columns to use for indentation. 173 | indent_width=4 174 | 175 | # Join short lines into one line. E.g., single line 'if' statements. 176 | join_multiple_lines=True 177 | 178 | # Do not include spaces around selected binary operators. For example: 179 | # 180 | # 1 + 2 * 3 - 4 / 5 181 | # 182 | # will be formatted as follows when configured with "*,/": 183 | # 184 | # 1 + 2*3 - 4/5 185 | no_spaces_around_selected_binary_operators=set() 186 | 187 | # Use spaces around default or named assigns. 188 | spaces_around_default_or_named_assign=False 189 | 190 | # Adds a space after the opening '{' and before the ending '}' dict delimiters. 191 | # 192 | # {1: 2} 193 | # 194 | # will be formatted as: 195 | # 196 | # { 1: 2 } 197 | spaces_around_dict_delimiters=False 198 | 199 | # Adds a space after the opening '[' and before the ending ']' list delimiters. 200 | # 201 | # [1, 2] 202 | # 203 | # will be formatted as: 204 | # 205 | # [ 1, 2 ] 206 | spaces_around_list_delimiters=False 207 | 208 | # Use spaces around the power operator. 209 | spaces_around_power_operator=True 210 | 211 | # Use spaces around the subscript / slice operator. For example: 212 | # 213 | # my_list[1 : 10 : 2] 214 | spaces_around_subscript_colon=False 215 | 216 | # Adds a space after the opening '(' and before the ending ')' tuple delimiters. 217 | # 218 | # (1, 2, 3) 219 | # 220 | # will be formatted as: 221 | # 222 | # ( 1, 2, 3 ) 223 | spaces_around_tuple_delimiters=False 224 | 225 | # The number of spaces required before a trailing comment. 226 | # This can be a single value (representing the number of spaces 227 | # before each trailing comment) or list of values (representing 228 | # alignment column values; trailing comments within a block will 229 | # be aligned to the first column value that is greater than the maximum 230 | # line length within the block). For example: 231 | # 232 | # With spaces_before_comment=5: 233 | # 234 | # 1 + 1 # Adding values 235 | # 236 | # will be formatted as: 237 | # 238 | # 1 + 1 # Adding values <-- 5 spaces between the end of the statement and comment 239 | # 240 | # With spaces_before_comment=15, 20: 241 | # 242 | # 1 + 1 # Adding values 243 | # two + two # More adding 244 | # 245 | # longer_statement # This is a longer statement 246 | # short # This is a shorter statement 247 | # 248 | # a_very_long_statement_that_extends_beyond_the_final_column # Comment 249 | # short # This is a shorter statement 250 | # 251 | # will be formatted as: 252 | # 253 | # 1 + 1 # Adding values <-- end of line comments in block aligned to col 15 254 | # two + two # More adding 255 | # 256 | # longer_statement # This is a longer statement <-- end of line comments in block aligned to col 20 257 | # short # This is a shorter statement 258 | # 259 | # a_very_long_statement_that_extends_beyond_the_final_column # Comment <-- the end of line comments are aligned based on the line length 260 | # short # This is a shorter statement 261 | # 262 | spaces_before_comment=2 263 | 264 | # Insert a space between the ending comma and closing bracket of a list, 265 | # etc. 266 | space_between_ending_comma_and_closing_bracket=True 267 | 268 | # Use spaces inside brackets, braces, and parentheses. For example: 269 | # 270 | # method_call( 1 ) 271 | # my_dict[ 3 ][ 1 ][ get_index( *args, **kwargs ) ] 272 | # my_set = { 1, 2, 3 } 273 | space_inside_brackets=False 274 | 275 | # Split before arguments 276 | split_all_comma_separated_values=False 277 | 278 | # Split before arguments, but do not split all subexpressions recursively 279 | # (unless needed). 280 | split_all_top_level_comma_separated_values=False 281 | 282 | # Split before arguments if the argument list is terminated by a 283 | # comma. 284 | split_arguments_when_comma_terminated=False 285 | 286 | # Set to True to prefer splitting before '+', '-', '*', '/', '//', or '@' 287 | # rather than after. 288 | split_before_arithmetic_operator=False 289 | 290 | # Set to True to prefer splitting before '&', '|' or '^' rather than 291 | # after. 292 | split_before_bitwise_operator=True 293 | 294 | # Split before the closing bracket if a list or dict literal doesn't fit on 295 | # a single line. 296 | split_before_closing_bracket=False 297 | 298 | # Split before a dictionary or set generator (comp_for). For example, note 299 | # the split before the 'for': 300 | # 301 | # foo = { 302 | # variable: 'Hello world, have a nice day!' 303 | # for variable in bar if variable != 42 304 | # } 305 | split_before_dict_set_generator=True 306 | 307 | # Split before the '.' if we need to split a longer expression: 308 | # 309 | # foo = ('This is a really long string: {}, {}, {}, {}'.format(a, b, c, d)) 310 | # 311 | # would reformat to something like: 312 | # 313 | # foo = ('This is a really long string: {}, {}, {}, {}' 314 | # .format(a, b, c, d)) 315 | split_before_dot=False 316 | 317 | # Split after the opening paren which surrounds an expression if it doesn't 318 | # fit on a single line. 319 | split_before_expression_after_opening_paren=False 320 | 321 | # If an argument / parameter list is going to be split, then split before 322 | # the first argument. 323 | split_before_first_argument=True 324 | 325 | # Set to True to prefer splitting before 'and' or 'or' rather than 326 | # after. 327 | split_before_logical_operator=True 328 | 329 | # Split named assignments onto individual lines. 330 | split_before_named_assigns=True 331 | 332 | # Set to True to split list comprehensions and generators that have 333 | # non-trivial expressions and multiple clauses before each of these 334 | # clauses. For example: 335 | # 336 | # result = [ 337 | # a_long_var + 100 for a_long_var in xrange(1000) 338 | # if a_long_var % 10] 339 | # 340 | # would reformat to something like: 341 | # 342 | # result = [ 343 | # a_long_var + 100 344 | # for a_long_var in xrange(1000) 345 | # if a_long_var % 10] 346 | split_complex_comprehension=True 347 | 348 | # The penalty for splitting right after the opening bracket. 349 | split_penalty_after_opening_bracket=0 350 | 351 | # The penalty for splitting the line after a unary operator. 352 | split_penalty_after_unary_operator=10000 353 | 354 | # The penalty of splitting the line around the '+', '-', '*', '/', '//', 355 | # ``%``, and '@' operators. 356 | split_penalty_arithmetic_operator=300 357 | 358 | # The penalty for splitting right before an if expression. 359 | split_penalty_before_if_expr=30 360 | 361 | # The penalty of splitting the line around the '&', '|', and '^' 362 | # operators. 363 | split_penalty_bitwise_operator=300 364 | 365 | # The penalty for splitting a list comprehension or generator 366 | # expression. 367 | split_penalty_comprehension=80 368 | 369 | # The penalty for characters over the column limit. 370 | split_penalty_excess_character=4500 371 | 372 | # The penalty incurred by adding a line split to the unwrapped line. The 373 | # more line splits added the higher the penalty. 374 | split_penalty_for_added_line_split=30 375 | 376 | # The penalty of splitting a list of "import as" names. For example: 377 | # 378 | # from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, 379 | # long_argument_2, 380 | # long_argument_3) 381 | # 382 | # would reformat to something like: 383 | # 384 | # from a_very_long_or_indented_module_name_yada_yad import ( 385 | # long_argument_1, long_argument_2, long_argument_3) 386 | split_penalty_import_names=0 387 | 388 | # The penalty of splitting the line around the 'and' and 'or' 389 | # operators. 390 | split_penalty_logical_operator=300 391 | 392 | # Use the Tab character for indentation. 393 | use_tabs=False 394 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.3] - 2023-04-18 4 | 5 | - Ensure pandas 2.0 compatibility (fix integer casting of datetimes) 6 | 7 | ## [0.1.2] - 2020-12-02 8 | 9 | - Try to catch NaN before passing to `datetime.utcfromtimestamp` 10 | 11 | ## [0.1.1] - 2020-11-20 12 | 13 | - Fix PyPI install by adding `MANIFEST.in` 14 | - Update documentation 15 | 16 | ## [0.1.0] - 2020-11-19 17 | 18 | - Initial release on PyPI 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Barron 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # suncalc-py 2 | 3 |

4 | 5 | Test 6 | 7 | 8 | Package version 9 | 10 | 11 | Downloads 12 | 13 |

14 | 15 | 16 | A fast, vectorized Python implementation of [`suncalc.js`][suncalc-js] for 17 | calculating sun position and sunlight phases (times for sunrise, sunset, dusk, 18 | etc.) for the given location and time. 19 | 20 | [suncalc-js]: https://github.com/mourner/suncalc 21 | 22 | While other similar libraries exist, I didn't originally encounter any that met 23 | my requirements of being openly-licensed, vectorized, and simple to use 1. 24 | 25 | ## Install 26 | 27 | ``` 28 | pip install suncalc 29 | ``` 30 | 31 | ## Using 32 | 33 | ### Example 34 | 35 | `suncalc` is designed to work both with single values and with arrays of values. 36 | 37 | First, import the module: 38 | 39 | ```py 40 | from suncalc import get_position, get_times 41 | from datetime import datetime 42 | ``` 43 | 44 | There are currently two methods: `get_position`, to get the sun azimuth and 45 | altitude (in radians) for a given date and position, and `get_times`, to get sunlight phases 46 | for a given date and position. 47 | 48 | ```py 49 | date = datetime.now() 50 | lon = 20 51 | lat = 45 52 | get_position(date, lon, lat) 53 | # {'azimuth': -0.8619668996997687, 'altitude': 0.5586446727994595} 54 | 55 | get_times(date, lon, lat) 56 | # {'solar_noon': Timestamp('2020-11-20 08:47:08.410863770'), 57 | # 'nadir': Timestamp('2020-11-19 20:47:08.410863770'), 58 | # 'sunrise': Timestamp('2020-11-20 03:13:22.645455322'), 59 | # 'sunset': Timestamp('2020-11-20 14:20:54.176272461'), 60 | # 'sunrise_end': Timestamp('2020-11-20 03:15:48.318936035'), 61 | # 'sunset_start': Timestamp('2020-11-20 14:18:28.502791748'), 62 | # 'dawn': Timestamp('2020-11-20 02:50:00.045539551'), 63 | # 'dusk': Timestamp('2020-11-20 14:44:16.776188232'), 64 | # 'nautical_dawn': Timestamp('2020-11-20 02:23:10.019832520'), 65 | # 'nautical_dusk': Timestamp('2020-11-20 15:11:06.801895264'), 66 | # 'night_end': Timestamp('2020-11-20 01:56:36.144269287'), 67 | # 'night': Timestamp('2020-11-20 15:37:40.677458252'), 68 | # 'golden_hour_end': Timestamp('2020-11-20 03:44:46.795967773'), 69 | # 'golden_hour': Timestamp('2020-11-20 13:49:30.025760010')} 70 | ``` 71 | 72 | These methods also work for _arrays_ of data, and since the implementation is 73 | vectorized it's much faster than a for loop in Python. 74 | 75 | ```py 76 | import pandas as pd 77 | 78 | df = pd.DataFrame({ 79 | 'date': [date] * 10, 80 | 'lon': [lon] * 10, 81 | 'lat': [lat] * 10 82 | }) 83 | pd.DataFrame(get_position(df['date'], df['lon'], df['lat'])) 84 | # azimuth altitude 85 | # 0 -1.485509 -1.048223 86 | # 1 -1.485509 -1.048223 87 | # ... 88 | 89 | pd.DataFrame(get_times(df['date'], df['lon'], df['lat']))['solar_noon'] 90 | # 0 2020-11-20 08:47:08.410863872+00:00 91 | # 1 2020-11-20 08:47:08.410863872+00:00 92 | # ... 93 | # Name: solar_noon, dtype: datetime64[ns, UTC] 94 | ``` 95 | 96 | If you want to join this data back to your `DataFrame`, you can use `pd.concat`: 97 | 98 | ```py 99 | times = pd.DataFrame(get_times(df['date'], df['lon'], df['lat'])) 100 | pd.concat([df, times], axis=1) 101 | ``` 102 | 103 | ### API 104 | 105 | #### `get_position` 106 | 107 | Calculate sun position (azimuth and altitude) for a given date and 108 | latitude/longitude 109 | 110 | - `date` (`datetime` or a pandas series of datetimes): date and time to find sun position of. **Datetime must be in UTC**. 111 | - `lng` (`float` or numpy array of `float`): longitude to find sun position of 112 | - `lat` (`float` or numpy array of `float`): latitude to find sun position of 113 | 114 | Returns a `dict` with two keys: `azimuth` and `altitude`. If the input values 115 | were singletons, the `dict`'s values will be floats. Otherwise they'll be numpy 116 | arrays of floats. 117 | 118 | #### `get_times` 119 | 120 | - `date` (`datetime` or a pandas series of datetimes): date and time to find sunlight phases of. **Datetime must be in UTC**. 121 | - `lng` (`float` or numpy array of `float`): longitude to find sunlight phases of 122 | - `lat` (`float` or numpy array of `float`): latitude to find sunlight phases of 123 | - `height` (`float` or numpy array of `float`, default `0`): observer height in meters 124 | - `times` (`Iterable[Tuple[float, str, str]]`): an iterable defining the angle above the horizon and strings for custom sunlight phases. The default is: 125 | 126 | ```py 127 | # (angle, morning name, evening name) 128 | DEFAULT_TIMES = [ 129 | (-0.833, 'sunrise', 'sunset'), 130 | (-0.3, 'sunrise_end', 'sunset_start'), 131 | (-6, 'dawn', 'dusk'), 132 | (-12, 'nautical_dawn', 'nautical_dusk'), 133 | (-18, 'night_end', 'night'), 134 | (6, 'golden_hour_end', 'golden_hour') 135 | ] 136 | ``` 137 | 138 | Returns a `dict` where the keys are `solar_noon`, `nadir`, plus any keys passed 139 | in the `times` argument. If the input values were singletons, the `dict`'s 140 | values will be of type `datetime.datetime` (or `pd.Timestamp` if you have pandas 141 | installed, which is a subclass of and therefore compatible with 142 | `datetime.datetime`). Otherwise they'll be pandas `DateTime` series. **The 143 | returned times will be in UTC.** 144 | 145 | ## Benchmark 146 | 147 | This benchmark is to show that the vectorized implementation is nearly 100x 148 | faster than a for loop in Python. 149 | 150 | First set up a `DataFrame` with random data. Here I create 100,000 rows. 151 | 152 | ```py 153 | from suncalc import get_position, get_times 154 | import pandas as pd 155 | 156 | def random_dates(start, end, n=10): 157 | """Create an array of random dates""" 158 | start_u = start.value//10**9 159 | end_u = end.value//10**9 160 | return pd.to_datetime(np.random.randint(start_u, end_u, n), unit='s') 161 | 162 | start = pd.to_datetime('2015-01-01') 163 | end = pd.to_datetime('2018-01-01') 164 | dates = random_dates(start, end, n=100_000) 165 | 166 | lons = np.random.uniform(low=-179, high=179, size=(100_000,)) 167 | lats = np.random.uniform(low=-89, high=89, size=(100_000,)) 168 | 169 | df = pd.DataFrame({'date': dates, 'lat': lats, 'lon': lons}) 170 | ``` 171 | 172 | Then compute `SunCalc.get_position` two ways: the first using the vectorized 173 | implementation and the second using `df.apply`, which is equivalent to a for 174 | loop. The first is more than **100x faster** than the second. 175 | 176 | ```py 177 | %timeit get_position(df['date'], df['lon'], df['lat']) 178 | # 41.4 ms ± 437 µs per loop (mean ± std. dev. of 7 runs, 10 loops each) 179 | 180 | %timeit df.apply(lambda row: get_position(row['date'], row['lon'], row['lat']), axis=1) 181 | # 4.89 s ± 184 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 182 | ``` 183 | 184 | Likewise, compute `SunCalc.get_times` the same two ways: first using the 185 | vectorized implementation and the second using `df.apply`. The first is **2800x 186 | faster** than the second! Some of the difference here is that under the hood the 187 | non-vectorized approach uses `pd.to_datetime` while the vectorized 188 | implementation uses `np.astype('datetime64[ns, UTC]')`. `pd.to_datetime` is 189 | really slow!! 190 | 191 | ```py 192 | %timeit get_times(df['date'], df['lon'], df['lat']) 193 | # 55.3 ms ± 1.91 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) 194 | 195 | %time df.apply(lambda row: get_times(row['date'], row['lon'], row['lat']), axis=1) 196 | # CPU times: user 2min 33s, sys: 288 ms, total: 2min 34s 197 | # Wall time: 2min 34s 198 | ``` 199 | 200 | --- 201 | 202 | 1: [`pyorbital`](https://github.com/pytroll/pyorbital) looks great but is 203 | GPL3-licensed; [`pysolar`](https://github.com/pingswept/pysolar) is also 204 | GPL3-licensed; [`pyEphem`](https://rhodesmill.org/pyephem/) is LGPL3-licensed. 205 | [`suncalcPy`](https://github.com/Broham/suncalcPy) is another port of 206 | `suncalc.js`, and is MIT-licensed, but doesn't use Numpy and thus isn't 207 | vectorized. I recently discovered [`sunpy`](https://github.com/sunpy/sunpy) and 208 | [`astropy`](https://github.com/astropy/astropy), both of which probably would've 209 | worked but I didn't see them at first and they look quite complex for this 210 | simple task... 211 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:suncalc/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [flake8] 15 | max-line-length = 80 16 | 17 | [isort] 18 | multi_line_output = 4 19 | 20 | [pycodestyle] 21 | max-line-length = 80 22 | 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """The setup script.""" 3 | 4 | from setuptools import find_packages, setup 5 | 6 | with open('README.md') as f: 7 | readme = f.read() 8 | 9 | with open('CHANGELOG.md') as history_file: 10 | history = history_file.read() 11 | 12 | requirements = ['numpy'] 13 | test_requirements = ['pytest', 'pandas'] 14 | setup_requirements = ['setuptools >= 38.6.0'] 15 | 16 | extra_reqs = {'pandas': ['pandas'], 'tests': test_requirements} 17 | 18 | # yapf: disable 19 | setup( 20 | author="Kyle Barron", 21 | author_email='kylebarron2@gmail.com', 22 | python_requires='>=3.6', 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Natural Language :: English', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | 'Programming Language :: Python :: 3.11', 35 | ], 36 | description="A fast, vectorized Python port of suncalc.js", 37 | install_requires=requirements, 38 | license="MIT license", 39 | long_description=readme + '\n\n' + history, 40 | long_description_content_type='text/markdown', 41 | keywords=['suncalc', 'sun'], 42 | name='suncalc', 43 | packages=find_packages(include=['suncalc', 'suncalc.*']), 44 | setup_requires=setup_requirements, 45 | extras_require=extra_reqs, 46 | test_suite='tests', 47 | tests_require=test_requirements, 48 | url='https://github.com/kylebarron/suncalc-py', 49 | version='0.1.3', 50 | zip_safe=False, 51 | ) 52 | -------------------------------------------------------------------------------- /suncalc/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = """Kyle Barron""" 2 | __email__ = 'kylebarron2@gmail.com' 3 | __version__ = '0.1.3' 4 | 5 | from .suncalc import get_position, get_times 6 | -------------------------------------------------------------------------------- /suncalc/suncalc.py: -------------------------------------------------------------------------------- 1 | """ 2 | suncalc-py is ported from suncalc.js under the BSD-2-Clause license. 3 | 4 | Copyright (c) 2014, Vladimir Agafonkin 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are 8 | permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this list of 11 | conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, this list 14 | of conditions and the following disclaimer in the documentation and/or other materials 15 | provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 18 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 19 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 23 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 24 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | """ 27 | 28 | from datetime import datetime 29 | from typing import Iterable, Tuple 30 | 31 | import numpy as np 32 | 33 | try: 34 | import pandas as pd 35 | except ImportError: 36 | pd = None 37 | 38 | # shortcuts for easier to read formulas 39 | PI = np.pi 40 | sin = np.sin 41 | cos = np.cos 42 | tan = np.tan 43 | asin = np.arcsin 44 | atan = np.arctan2 45 | acos = np.arccos 46 | rad = PI / 180 47 | 48 | # sun times configuration (angle, morning name, evening name) 49 | DEFAULT_TIMES = [ 50 | (-0.833, 'sunrise', 'sunset'), 51 | (-0.3, 'sunrise_end', 'sunset_start'), 52 | (-6, 'dawn', 'dusk'), 53 | (-12, 'nautical_dawn', 'nautical_dusk'), 54 | (-18, 'night_end', 'night'), 55 | (6, 'golden_hour_end', 'golden_hour') 56 | ] # yapf: disable 57 | 58 | # date/time constants and conversions 59 | dayMs = 1000 * 60 * 60 * 24 60 | J1970 = 2440588 61 | J2000 = 2451545 62 | 63 | 64 | def to_milliseconds(date): 65 | # datetime.datetime 66 | if isinstance(date, datetime): 67 | return date.timestamp() * 1000 68 | 69 | # Pandas series of Pandas datetime objects 70 | if pd and pd.api.types.is_datetime64_any_dtype(date): 71 | # A datetime-like series coerce to int is (always?) in nanoseconds 72 | return date.astype('int64') / 10 ** 6 73 | 74 | # Single pandas Timestamp 75 | if pd and isinstance(date, pd.Timestamp): 76 | date = date.to_numpy() 77 | 78 | # Numpy datetime64 79 | if np.issubdtype(date.dtype, np.datetime64): 80 | return date.astype('datetime64[ms]').astype('int64') 81 | 82 | # Last-ditch effort 83 | if pd: 84 | return np.array(pd.to_datetime(date).astype('int64') / 10 ** 6) 85 | 86 | raise ValueError(f'Unknown date type: {type(date)}') 87 | 88 | 89 | def to_julian(date): 90 | return to_milliseconds(date) / dayMs - 0.5 + J1970 91 | 92 | 93 | def from_julian(j): 94 | ms_date = (j + 0.5 - J1970) * dayMs 95 | 96 | if pd: 97 | # If a single value, coerce to a pd.Timestamp 98 | if np.prod(np.array(ms_date).shape) == 1: 99 | return pd.to_datetime(ms_date, unit='ms') 100 | 101 | # .astype(datetime) is much faster than pd.to_datetime but it only works 102 | # on series of dates, not on a single pd.Timestamp, so I fall back to 103 | # pd.to_datetime for that. 104 | try: 105 | return (pd.Series(ms_date) * 1e6).astype('datetime64[ns, UTC]') 106 | except TypeError: 107 | return pd.to_datetime(ms_date, unit='ms') 108 | 109 | # ms_date could be iterable 110 | try: 111 | return np.array([ 112 | datetime.utcfromtimestamp(x / 1000) 113 | if not np.isnan(x) else np.datetime64('NaT') for x in ms_date]) 114 | 115 | except TypeError: 116 | return datetime.utcfromtimestamp( 117 | ms_date / 1000) if not np.isnan(ms_date) else np.datetime64('NaT') 118 | 119 | 120 | def to_days(date): 121 | return to_julian(date) - J2000 122 | 123 | 124 | # general calculations for position 125 | 126 | # obliquity of the Earth 127 | e = rad * 23.4397 128 | 129 | 130 | def right_ascension(l, b): 131 | return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)) 132 | 133 | 134 | def declination(l, b): 135 | return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)) 136 | 137 | 138 | def azimuth(H, phi, dec): 139 | return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)) 140 | 141 | 142 | def altitude(H, phi, dec): 143 | return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)) 144 | 145 | 146 | def sidereal_time(d, lw): 147 | return rad * (280.16 + 360.9856235 * d) - lw 148 | 149 | 150 | def astro_refraction(h): 151 | # the following formula works for positive altitudes only. 152 | # if h = -0.08901179 a div/0 would occur. 153 | h = np.maximum(h, 0) 154 | 155 | # formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus 156 | # (Willmann-Bell, Richmond) 1998. 1.02 / tan(h + 10.26 / (h + 5.10)) h in 157 | # degrees, result in arc minutes -> converted to rad: 158 | return 0.0002967 / np.tan(h + 0.00312536 / (h + 0.08901179)) 159 | 160 | 161 | # general sun calculations 162 | 163 | 164 | def solar_mean_anomaly(d): 165 | return rad * (357.5291 + 0.98560028 * d) 166 | 167 | 168 | def ecliptic_longitude(M): 169 | # equation of center 170 | C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)) 171 | 172 | # perihelion of the Earth 173 | P = rad * 102.9372 174 | 175 | return M + C + P + PI 176 | 177 | 178 | def sun_coords(d): 179 | M = solar_mean_anomaly(d) 180 | L = ecliptic_longitude(M) 181 | 182 | return {'dec': declination(L, 0), 'ra': right_ascension(L, 0)} 183 | 184 | 185 | # calculations for sun times 186 | J0 = 0.0009 187 | 188 | 189 | def julian_cycle(d, lw): 190 | return np.round(d - J0 - lw / (2 * PI)) 191 | 192 | 193 | def approx_transit(Ht, lw, n): 194 | return J0 + (Ht + lw) / (2 * PI) + n 195 | 196 | 197 | def solar_transit_j(ds, M, L): 198 | return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L) 199 | 200 | 201 | def hour_angle(h, phi, d): 202 | return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))) 203 | 204 | 205 | def observer_angle(height): 206 | return -2.076 * np.sqrt(height) / 60 207 | 208 | 209 | def get_set_j(h, lw, phi, dec, n, M, L): 210 | """Get set time for the given sun altitude 211 | """ 212 | w = hour_angle(h, phi, dec) 213 | a = approx_transit(w, lw, n) 214 | return solar_transit_j(a, M, L) 215 | 216 | 217 | def get_position(date, lng, lat): 218 | """Calculate sun position for a given date and latitude/longitude 219 | """ 220 | lw = rad * -lng 221 | phi = rad * lat 222 | d = to_days(date) 223 | 224 | c = sun_coords(d) 225 | H = sidereal_time(d, lw) - c['ra'] 226 | 227 | return { 228 | 'azimuth': azimuth(H, phi, c['dec']), 229 | 'altitude': altitude(H, phi, c['dec'])} 230 | 231 | 232 | def get_times( 233 | date, 234 | lng, 235 | lat, 236 | height=0, 237 | times: Iterable[Tuple[float, str, str]] = DEFAULT_TIMES): 238 | """Calculate sun times 239 | 240 | Calculate sun times for a given date, latitude/longitude, and, 241 | optionally, the observer height (in meters) relative to the horizon 242 | """ 243 | # If inputs are vectors (or some list-like type), then coerce them to 244 | # numpy arrays 245 | # 246 | # When inputs are pandas series, then intermediate objects will also be 247 | # pandas series, and you won't be able to do 2d broadcasting. 248 | try: 249 | len(date) 250 | len(lat) 251 | len(lng) 252 | array_input = True 253 | date = np.array(date) 254 | lat = np.array(lat) 255 | lng = np.array(lng) 256 | except TypeError: 257 | array_input = False 258 | 259 | lw = rad * -lng 260 | phi = rad * lat 261 | 262 | dh = observer_angle(height) 263 | 264 | d = to_days(date) 265 | n = julian_cycle(d, lw) 266 | ds = approx_transit(0, lw, n) 267 | 268 | M = solar_mean_anomaly(ds) 269 | L = ecliptic_longitude(M) 270 | dec = declination(L, 0) 271 | 272 | Jnoon = solar_transit_j(ds, M, L) 273 | 274 | result = { 275 | 'solar_noon': from_julian(Jnoon), 276 | 'nadir': from_julian(Jnoon - 0.5)} 277 | 278 | angles = np.array([time[0] for time in times]) 279 | h0 = (angles + dh) * rad 280 | 281 | # If array input, add an axis to allow 2d broadcasting 282 | if array_input: 283 | h0 = h0[:, np.newaxis] 284 | 285 | # Need to add an axis for 2D broadcasting 286 | Jset = get_set_j(h0, lw, phi, dec, n, M, L) 287 | Jrise = Jnoon - (Jset - Jnoon) 288 | 289 | for idx, time in enumerate(times): 290 | if array_input: 291 | result[time[1]] = from_julian(Jrise[idx, :]) 292 | result[time[2]] = from_julian(Jset[idx, :]) 293 | else: 294 | result[time[1]] = from_julian(Jrise[idx]) 295 | result[time[2]] = from_julian(Jset[idx]) 296 | 297 | return result 298 | 299 | 300 | # moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html 301 | # formulas 302 | 303 | 304 | def moon_coords(d): 305 | """Geocentric ecliptic coordinates of the moon 306 | """ 307 | 308 | # ecliptic longitude 309 | L = rad * (218.316 + 13.176396 * d) 310 | # mean anomaly 311 | M = rad * (134.963 + 13.064993 * d) 312 | # mean distance 313 | F = rad * (93.272 + 13.229350 * d) 314 | 315 | # longitude 316 | l = L + rad * 6.289 * sin(M) 317 | # latitude 318 | b = rad * 5.128 * sin(F) 319 | # distance to the moon in km 320 | dt = 385001 - 20905 * cos(M) 321 | 322 | return {'ra': right_ascension(l, b), 'dec': declination(l, b), 'dist': dt} 323 | 324 | 325 | def getMoonPosition(date, lat, lng): 326 | 327 | lw = rad * -lng 328 | phi = rad * lat 329 | d = to_days(date) 330 | 331 | c = moon_coords(d) 332 | H = sidereal_time(d, lw) - c['ra'] 333 | h = altitude(H, phi, c['dec']) 334 | 335 | # formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus 336 | # (Willmann-Bell, Richmond) 1998. 337 | pa = atan(sin(H), tan(phi) * cos(c['dec']) - sin(c['dec']) * cos(H)) 338 | 339 | # altitude correction for refraction 340 | h = h + astro_refraction(h) 341 | 342 | return { 343 | 'azimuth': azimuth(H, phi, c['dec']), 344 | 'altitude': h, 345 | 'distance': c['dist'], 346 | 'parallacticAngle': pa} 347 | 348 | 349 | # calculations for illumination parameters of the moon, based on 350 | # http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and Chapter 48 351 | # of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, 352 | # Richmond) 1998. 353 | 354 | 355 | def getMoonIllumination(date): 356 | 357 | d = to_days(date) 358 | s = sun_coords(d) 359 | m = moon_coords(d) 360 | 361 | # distance from Earth to Sun in km 362 | sdist = 149598000 363 | 364 | phi = acos( 365 | sin(s['dec']) * sin(m['dec']) + 366 | cos(s['dec']) * cos(m['dec']) * cos(s['ra'] - m['ra'])) 367 | inc = atan(sdist * sin(phi), m['dist'] - sdist * cos(phi)) 368 | angle = atan( 369 | cos(s['dec']) * sin(s['ra'] - m['ra']), 370 | sin(s['dec']) * cos(m['dec']) - 371 | cos(s['dec']) * sin(m['dec']) * cos(s['ra'] - m['ra'])) 372 | 373 | return { 374 | 'fraction': (1 + cos(inc)) / 2, 375 | 'phase': 0.5 + 0.5 * inc * np.sign(angle) / PI, 376 | 'angle': angle} 377 | 378 | 379 | # def hoursLater(date, h): 380 | # # TODO: pythonize 381 | # return new Date(date.valueOf() + h * dayMs / 24) 382 | 383 | # calculations for moon rise/set times are based on 384 | # http://www.stargazing.net/kepler/moonrise.html article 385 | 386 | # def getMoonTimes(date, lat, lng, inUTC): 387 | # var t = new Date(date); 388 | # if (inUTC) t.setUTCHours(0, 0, 0, 0); 389 | # else t.setHours(0, 0, 0, 0); 390 | # 391 | # var hc = 0.133 * rad, 392 | # h0 = SunCalc.getMoonPosition(t, lat, lng).altitude - hc, 393 | # h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx; 394 | # 395 | # // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) 396 | # for (var i = 1; i <= 24; i += 2) { 397 | # h1 = SunCalc.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc; 398 | # h2 = SunCalc.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc; 399 | # 400 | # a = (h0 + h2) / 2 - h1; 401 | # b = (h2 - h0) / 2; 402 | # xe = -b / (2 * a); 403 | # ye = (a * xe + b) * xe + h1; 404 | # d = b * b - 4 * a * h1; 405 | # roots = 0; 406 | # 407 | # if (d >= 0) { 408 | # dx = Math.sqrt(d) / (Math.abs(a) * 2); 409 | # x1 = xe - dx; 410 | # x2 = xe + dx; 411 | # if (Math.abs(x1) <= 1) roots++; 412 | # if (Math.abs(x2) <= 1) roots++; 413 | # if (x1 < -1) x1 = x2; 414 | # } 415 | # 416 | # if (roots === 1) { 417 | # if (h0 < 0) rise = i + x1; 418 | # else set = i + x1; 419 | # 420 | # } else if (roots === 2) { 421 | # rise = i + (ye < 0 ? x2 : x1); 422 | # set = i + (ye < 0 ? x1 : x2); 423 | # } 424 | # 425 | # if (rise && set) break; 426 | # 427 | # h0 = h2; 428 | # } 429 | # 430 | # var result = {}; 431 | # 432 | # if (rise) result.rise = hoursLater(t, rise); 433 | # if (set) result.set = hoursLater(t, set); 434 | # 435 | # if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true; 436 | # 437 | # return result; 438 | # }; 439 | -------------------------------------------------------------------------------- /tests/test_suncalc.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from suncalc import get_position, get_times 7 | 8 | date = datetime(2013, 3, 5, tzinfo=timezone.utc) 9 | lat = 50.5 10 | lng = 30.5 11 | height = 2000 12 | 13 | testTimes = { 14 | 'solar_noon': '2013-03-05T10:10:57Z', 15 | 'nadir': '2013-03-04T22:10:57Z', 16 | 'sunrise': '2013-03-05T04:34:56Z', 17 | 'sunset': '2013-03-05T15:46:57Z', 18 | 'sunrise_end': '2013-03-05T04:38:19Z', 19 | 'sunset_start': '2013-03-05T15:43:34Z', 20 | 'dawn': '2013-03-05T04:02:17Z', 21 | 'dusk': '2013-03-05T16:19:36Z', 22 | 'nautical_dawn': '2013-03-05T03:24:31Z', 23 | 'nautical_dusk': '2013-03-05T16:57:22Z', 24 | 'night_end': '2013-03-05T02:46:17Z', 25 | 'night': '2013-03-05T17:35:36Z', 26 | 'golden_hour_end': '2013-03-05T05:19:01Z', 27 | 'golden_hour': '2013-03-05T15:02:52Z'} 28 | 29 | heightTestTimes = { 30 | 'solar_noon': '2013-03-05T10:10:57Z', 31 | 'nadir': '2013-03-04T22:10:57Z', 32 | 'sunrise': '2013-03-05T04:25:07Z', 33 | 'sunset': '2013-03-05T15:56:46Z'} 34 | 35 | 36 | def test_get_position(): 37 | """getPosition returns azimuth and altitude for the given time and location 38 | """ 39 | pos = get_position(date, lng, lat) 40 | assert np.isclose(pos['azimuth'], -2.5003175907168385) 41 | assert np.isclose(pos['altitude'], -0.7000406838781611) 42 | 43 | 44 | def test_get_times(): 45 | """getTimes returns sun phases for the given date and location 46 | """ 47 | times = get_times(date, lng, lat) 48 | for key, value in testTimes.items(): 49 | assert times[key].strftime("%Y-%m-%dT%H:%M:%SZ") == value 50 | 51 | 52 | def test_get_times_height(): 53 | """getTimes adjusts sun phases when additionally given the observer height 54 | """ 55 | times = get_times(date, lng, lat, height) 56 | for key, value in heightTestTimes.items(): 57 | assert times[key].strftime("%Y-%m-%dT%H:%M:%SZ") == value 58 | 59 | 60 | def test_get_position_pandas_single_timestamp(): 61 | ts_date = pd.Timestamp(date) 62 | 63 | pos = get_position(ts_date, lng, lat) 64 | assert np.isclose(pos['azimuth'], -2.5003175907168385) 65 | assert np.isclose(pos['altitude'], -0.7000406838781611) 66 | 67 | 68 | def test_get_position_pandas_datetime_series(): 69 | df = pd.DataFrame({'date': [date] * 3, 'lat': [lat] * 3, 'lng': [lng] * 3}) 70 | 71 | pos = pd.DataFrame(get_position(df['date'], df['lng'], df['lat'])) 72 | 73 | assert pos.shape == (3, 2) 74 | assert all(x in pos.columns for x in ['azimuth', 'altitude']) 75 | assert pos.dtypes['azimuth'] == np.dtype('float64') 76 | assert pos.dtypes['altitude'] == np.dtype('float64') 77 | 78 | assert np.isclose(pos['azimuth'].iloc[0], -2.5003175907168385) 79 | assert np.isclose(pos['altitude'].iloc[0], -0.7000406838781611) 80 | 81 | 82 | def test_get_times_pandas_single(): 83 | times = get_times(date, lng, lat) 84 | 85 | assert isinstance(times['solar_noon'], pd.Timestamp) 86 | 87 | 88 | def test_get_times_datetime_single(): 89 | times = get_times(date, lng, lat) 90 | 91 | # This is true because pd.Timestamp is an instance of datetime.datetime 92 | assert isinstance(times['solar_noon'], datetime) 93 | 94 | 95 | def test_get_times_arrays(): 96 | df = pd.DataFrame({'date': [date] * 3, 'lat': [lat] * 3, 'lng': [lng] * 3}) 97 | 98 | times = pd.DataFrame(get_times(df['date'], df['lng'], df['lat'])) 99 | 100 | assert pd.api.types.is_datetime64_any_dtype(times['solar_noon']) 101 | 102 | assert times['solar_noon'].iloc[0].strftime( 103 | "%Y-%m-%dT%H:%M:%SZ") == testTimes['solar_noon'] 104 | 105 | 106 | def test_get_times_for_high_latitudes(): 107 | """getTimes may fail (maybe only on Windows?) for high latitudes in the summer 108 | 109 | See https://github.com/kylebarron/suncalc-py/issues/4 110 | """ 111 | date = datetime(2020, 5, 26, 0, 0, 0) 112 | lng = -114.0719 113 | lat = 51.0447 114 | 115 | # Make sure this doesn't raise an exception (though it will emit a warning 116 | # due to a division error) 117 | times = get_times(date, lng, lat) 118 | 119 | 120 | # t.test('getMoonPosition returns moon position data given time and location', function (t) { 121 | # var moonPos = SunCalc.getMoonPosition(date, lng, lat); 122 | # 123 | # t.ok(near(moonPos.azimuth, -0.9783999522438226), 'azimuth'); 124 | # t.ok(near(moonPos.altitude, 0.014551482243892251), 'altitude'); 125 | # t.ok(near(moonPos.distance, 364121.37256256194), 'distance'); 126 | # t.end(); 127 | # }); 128 | # 129 | # t.test('getMoonIllumination returns fraction and angle of moon\'s illuminated limb and phase', function (t) { 130 | # var moonIllum = SunCalc.getMoonIllumination(date); 131 | # 132 | # t.ok(near(moonIllum.fraction, 0.4848068202456373), 'fraction'); 133 | # t.ok(near(moonIllum.phase, 0.7548368838538762), 'phase'); 134 | # t.ok(near(moonIllum.angle, 1.6732942678578346), 'angle'); 135 | # t.end(); 136 | # }); 137 | # 138 | # t.test('getMoonTimes returns moon rise and set times', function (t) { 139 | # var moonTimes = SunCalc.getMoonTimes(new Date('2013-03-04UTC'), lng, lat, true); 140 | # 141 | # t.equal(moonTimes.rise.toUTCString(), 'Mon, 04 Mar 2013 23:54:29 GMT'); 142 | # t.equal(moonTimes.set.toUTCString(), 'Mon, 04 Mar 2013 07:47:58 GMT'); 143 | # 144 | # t.end(); 145 | # }); 146 | --------------------------------------------------------------------------------