├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── appveyor.yml ├── doc └── source │ ├── Makefile │ ├── _fake_links.rst │ ├── about.rst │ ├── calendar_classes.rst │ ├── compound_timeboard.png │ ├── conf.py │ ├── data_model.rst │ ├── doing_calculations.rst │ ├── exceptions.rst │ ├── index.rst │ ├── installation.rst │ ├── interval_class.rst │ ├── make.bat │ ├── making_a_timeboard.rst │ ├── organizing_classes.rst │ ├── quick_start.rst │ ├── release_notes.rst │ ├── requirements.txt │ ├── simple_timeboard.png │ ├── timeboard_class.rst │ ├── timeboard_logo.png │ ├── use_cases.ipynb │ ├── using_preconfigured.rst │ └── workshift_class.rst ├── requirements.txt ├── setup.py └── timeboard ├── VERSION.txt ├── __init__.py ├── calendars ├── RU.py ├── UK.py ├── US.py ├── __init__.py └── calendarbase.py ├── core.py ├── exceptions.py ├── interval.py ├── tests ├── __init__.py ├── calendars │ ├── __init__.py │ ├── test_calendars_generic.py │ └── test_calendars_ru_us_uk.py ├── test_frames.py ├── test_interval_methods.py ├── test_intervals.py ├── test_intervals_compound.py ├── test_organizers.py ├── test_partitioning.py ├── test_patterns.py ├── test_timeboard.py ├── test_utils.py ├── test_workshift.py └── test_workshift_compound.py ├── timeboard.py ├── utils.py ├── when.py └── workshift.py /.gitignore: -------------------------------------------------------------------------------- 1 | ## PROJECT SPECIFIC 2 | 3 | stuff/ 4 | test.bat 5 | test-cov.bat 6 | coverage.html/ 7 | .idea/ 8 | 9 | ## IDEA 10 | 11 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 12 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 13 | 14 | # User-specific stuff: 15 | .idea/**/workspace.xml 16 | .idea/**/tasks.xml 17 | .idea/dictionaries 18 | 19 | # Sensitive or high-churn files: 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.xml 23 | .idea/**/dataSources.local.xml 24 | .idea/**/sqlDataSources.xml 25 | .idea/**/dynamic.xml 26 | .idea/**/uiDesigner.xml 27 | 28 | # Gradle: 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # CMake 33 | cmake-build-debug/ 34 | 35 | # Mongo Explorer plugin: 36 | .idea/**/mongoSettings.xml 37 | 38 | ## File-based project format: 39 | *.iws 40 | 41 | ## Plugin-specific files: 42 | 43 | # IntelliJ 44 | out/ 45 | 46 | # mpeltonen/sbt-idea plugin 47 | .idea_modules/ 48 | 49 | # JIRA plugin 50 | atlassian-ide-plugin.xml 51 | 52 | # Cursive Clojure plugin 53 | .idea/replstate.xml 54 | 55 | # Crashlytics plugin (for Android Studio and IntelliJ) 56 | com_crashlytics_export_strings.xml 57 | crashlytics.properties 58 | crashlytics-build.properties 59 | fabric.properties 60 | 61 | ### PYTHON 62 | 63 | # Byte-compiled / optimized / DLL files 64 | __pycache__/ 65 | *.py[cod] 66 | *$py.class 67 | 68 | # C extensions 69 | *.so 70 | 71 | # Distribution / packaging 72 | .Python 73 | build/ 74 | develop-eggs/ 75 | dist/ 76 | downloads/ 77 | eggs/ 78 | .eggs/ 79 | lib/ 80 | lib64/ 81 | parts/ 82 | sdist/ 83 | var/ 84 | wheels/ 85 | *.egg-info/ 86 | .installed.cfg 87 | *.egg 88 | MANIFEST 89 | 90 | # PyInstaller 91 | # Usually these files are written by a python script from a template 92 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 93 | *.manifest 94 | *.spec 95 | 96 | # Installer logs 97 | pip-log.txt 98 | pip-delete-this-directory.txt 99 | 100 | # Unit test / coverage reports 101 | htmlcov/ 102 | .tox/ 103 | .coverage 104 | .coverage.* 105 | .cache 106 | nosetests.xml 107 | coverage.xml 108 | *.cover 109 | .hypothesis/ 110 | 111 | # Translations 112 | *.mo 113 | *.pot 114 | 115 | # Django stuff: 116 | *.log 117 | .static_storage/ 118 | .media/ 119 | local_settings.py 120 | 121 | # Flask stuff: 122 | instance/ 123 | .webassets-cache 124 | 125 | # Scrapy stuff: 126 | .scrapy 127 | 128 | # Sphinx documentation 129 | docs/_build/ 130 | # added by me 131 | doc/_build/ 132 | doc/source/_build 133 | doc/_temp 134 | 135 | # PyBuilder 136 | target/ 137 | 138 | # Jupyter Notebook 139 | .ipynb_checkpoints 140 | 141 | # pyenv 142 | .python-version 143 | 144 | # celery beat schedule file 145 | celerybeat-schedule 146 | 147 | # SageMath parsed files 148 | *.sage.py 149 | 150 | # Environments 151 | .env 152 | .venv 153 | env/ 154 | venv/ 155 | ENV/ 156 | env.bak/ 157 | venv.bak/ 158 | 159 | # Spyder project settings 160 | .spyderproject 161 | .spyproject 162 | 163 | # Rope project settings 164 | .ropeproject 165 | 166 | # mkdocs documentation 167 | /site 168 | 169 | # mypy 170 | .mypy_cache/ 171 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.8" 5 | - "3.9" 6 | - "3.10" 7 | # command to install dependencies 8 | install: 9 | - pip install -r requirements.txt 10 | # command to run tests 11 | script: 12 | - py.test # or pytest for Python versions 3.6 and newer -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 3-Clause BSD License 2 | 3 | Copyright (c) 2018, Maxim Mamaev 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from this 18 | software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 22 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 23 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 24 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | include MANIFEST.in 4 | include requirements.txt 5 | include setup.py 6 | include timeboard/VERSION.txt 7 | 8 | graft timeboard 9 | 10 | graft doc/source 11 | prune doc/source/_build 12 | prune doc/source/.ipynb_checkpoints 13 | 14 | global-exclude *.py[co] 15 | global-exclude __pycache__ 16 | prune timeboard/**/.cache 17 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://timeboard.readthedocs.io/en/latest/_static/timeboard_logo.png 2 | :align: center 3 | :alt: timeboard logo 4 | 5 | .. image:: https://img.shields.io/travis/mmamaev/timeboard.svg 6 | :alt: Travis build status 7 | :target: https://travis-ci.org/mmamaev/timeboard 8 | 9 | .. image:: https://ci.appveyor.com/api/projects/status/github/mmamaev/timeboard?svg=true 10 | :alt: AppVeyor build status 11 | :target: https://ci.appveyor.com/project/mmamaev/timeboard 12 | 13 | .. image:: https://img.shields.io/readthedocs/timeboard.svg 14 | :alt: Readthedocs build status 15 | :target: https://timeboard.readthedocs.io/ 16 | 17 | .. image:: https://img.shields.io/pypi/v/timeboard.svg 18 | :alt: Version available on PyPI 19 | :target: https://pypi.python.org/pypi/timeboard 20 | 21 | .. image:: https://img.shields.io/github/release/mmamaev/timeboard.svg 22 | :alt: Release on Github 23 | :target: https://github.com/mmamaev/timeboard/releases 24 | 25 | ********************************************* 26 | timeboard - business calendar calculations 27 | ********************************************* 28 | 29 | `timeboard` creates schedules of work periods and performs calendar calculations over them. You can build standard business day calendars as well as a variety of other schedules, simple or complex. 30 | 31 | .. pypi-start 32 | 33 | Examples of problems solved by `timeboard`: 34 | 35 | - If we have 20 business days to complete the project when will be the deadline? 36 | 37 | - If a person was employed from November 15 to December 22 and salary is paid monthly, how many month's salaries has the employee earned? 38 | 39 | - The above-mentioned person was scheduled to work Mondays, Tuesdays, Saturdays, and Sundays on odd weeks, and Wednesdays, Thursdays, and Fridays on even weeks. The question is the same. 40 | 41 | - A 24x7 call center operates in shifts of varying length starting at 02:00, 08:00, and 18:00. An operator comes in on every fourth shift and is paid per shift. How many shifts has the operator sat in a specific month? 42 | 43 | - With employees entering and leaving a company throughout a year, what was the average annual headcount? 44 | 45 | Based on pandas timeseries library, `timeboard` gives more flexibility than pandas's built-in business calendars. The key features of `timeboard` are: 46 | 47 | - You can choose any time frequencies (days, hours, multiple-hour shifts, etc.) as work periods. 48 | 49 | - You can create sophisticated schedules which can combine periodical patterns, seasonal variations, stop-and-resume behavior, etc. 50 | 51 | - There are built-in standard business day calendars (in this version: for USA, UK, and Russia). 52 | 53 | 54 | Installation 55 | ============ 56 | 57 | :: 58 | 59 | pip install timeboard 60 | 61 | `timeboard` is tested with Python versions 2.7, 3.6 - 3.10. 62 | 63 | Dependencies: 64 | 65 | - pandas >= 0.22 66 | - numpy >= 1.13 67 | - dateutil >= 2.6.1 68 | - six >= 1.11 69 | 70 | The import statement to run all the examples: 71 | :: 72 | 73 | >>> import timeboard as tb 74 | 75 | 76 | Quick Start Guide 77 | ================= 78 | 79 | Set up a timeboard 80 | ------------------ 81 | 82 | To get started you need to build a timeboard (calendar). The simplest way to do so is to use a preconfigured calendar which is shipped with the package. Let's take a regular business day calendar for the United States. 83 | :: 84 | 85 | >>> import timeboard.calendars.US as US 86 | >>> clnd = US.Weekly8x5() 87 | 88 | 89 | .. note:: If you need to build a custom calendar, for example, a schedule of shifts for a 24x7 call center, `Making a Timeboard `_ section of the documentation explains this topic in details. 90 | 91 | Once you have got a timeboard, you may perform queries and calculations over it. 92 | 93 | Play with workshifts 94 | -------------------- 95 | 96 | Calling a timeboard instance `clnd` with a single point in time produces an object representing a unit of the calendar (in this case, a day) that contains this point in time. Object of this type is called *workshift*. 97 | 98 | **Is a certain date a business day?** 99 | :: 100 | 101 | >>> ws = clnd('27 May 2017') 102 | >>> ws.is_on_duty() 103 | False 104 | 105 | Indeed, it was a Saturday. 106 | 107 | 108 | **When was the next business day?** 109 | :: 110 | 111 | >>> ws.rollforward() 112 | Workshift(6359) of 'D' at 2017-05-30 113 | 114 | The returned calendar unit (workshift) has the sequence number of 6359 and represents the day of 30 May 2017, which, by the way, was the Tuesday after the Memorial Day holiday. 115 | 116 | 117 | **If we were to finish the project in 22 business days starting on 01 May 2017, when would be our deadline?** 118 | :: 119 | 120 | >>> clnd('01 May 2017') + 22 121 | Workshift(6361) of 'D' at 2017-06-01 122 | 123 | This is the same as: 124 | :: 125 | 126 | >>> clnd('01 May 2017').rollforward(22) 127 | Workshift(6361) of 'D' at 2017-06-01 128 | 129 | 130 | Play with intervals 131 | ------------------- 132 | 133 | Calling ``clnd()`` with a different set of parameters produces an object representing an *interval* on the calendar. The interval below contains all workshifts of the months of May 2017. 134 | 135 | **How many business days were there in a certain month?** 136 | :: 137 | 138 | >>> may2017 = clnd('May 2017', period='M') 139 | >>> may2017.count() 140 | 22 141 | 142 | 143 | **How many days off?** 144 | :: 145 | 146 | >>> may2017.count(duty='off') 147 | 9 148 | 149 | 150 | **How many working hours?** 151 | :: 152 | 153 | >>> may2017.worktime() 154 | 176.0 155 | 156 | 157 | An employee was on the staff from April 3, 2017 to May 15, 2017. **What portion of April's salary did the company owe them?** 158 | 159 | Calling ``clnd()`` with a tuple of two points in time produces an interval containing all workshifts between these points, inclusively. 160 | :: 161 | 162 | >>> time_in_company = clnd(('03 Apr 2017','15 May 2017')) 163 | >>> time_in_company.what_portion_of(clnd('Apr 2017', period='M')) 164 | 1.0 165 | 166 | Indeed, the 1st and the 2nd of April in 2017 fell on the weekend, therefore, having started on the 3rd, the employee checked out all the working days in the month. 167 | 168 | **And what portion of May's?** 169 | :: 170 | 171 | >>> time_in_company.what_portion_of(may2017) 172 | 0.5 173 | 174 | **How many days had the employee worked in May?** 175 | 176 | The multiplication operator returns the intersection of two intervals. 177 | :: 178 | 179 | >>> (time_in_company * may2017).count() 180 | 11 181 | 182 | **How many hours?** 183 | :: 184 | 185 | >>> (time_in_company * may2017).worktime() 186 | 88 187 | 188 | 189 | An employee was on the staff from 01 Jan 2016 to 15 Jul 2017. **How many years this person had worked for the company?** 190 | :: 191 | 192 | >>> clnd(('01 Jan 2016', '15 Jul 2017')).count_periods('A') 193 | 1.5421686746987953 194 | 195 | 196 | Links 197 | ===== 198 | 199 | **Documentation:** https://timeboard.readthedocs.io/ 200 | 201 | **GitHub:** https://github.com/mmamaev/timeboard 202 | 203 | **PyPI:** https://pypi.python.org/pypi/timeboard 204 | 205 | 206 | .. pypi-end 207 | 208 | License 209 | ======= 210 | 211 | `BSD 3 Clause `_ 212 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | build: false 2 | 3 | environment: 4 | matrix: 5 | - PYTHON_VERSION: 2.7 6 | MINICONDA: C:\Miniconda 7 | - PYTHON_VERSION: 2.7 8 | MINICONDA: C:\Miniconda-x64 9 | - PYTHON_VERSION: 3.8 10 | MINICONDA: C:\Miniconda3 11 | - PYTHON_VERSION: 3.8 12 | MINICONDA: C:\Miniconda3-x64 13 | - PYTHON_VERSION: 3.9 14 | MINICONDA: C:\Miniconda3 15 | - PYTHON_VERSION: 3.9 16 | MINICONDA: C:\Miniconda3-x64 17 | - PYTHON_VERSION: 3.10 18 | MINICONDA: C:\Miniconda3 19 | - PYTHON_VERSION: 3.10 20 | MINICONDA: C:\Miniconda3-x64 21 | 22 | init: 23 | - "ECHO %PYTHON_VERSION% %MINICONDA%" 24 | 25 | install: 26 | - "set PATH=%MINICONDA%;%MINICONDA%\\Scripts;%PATH%" 27 | - conda config --set always_yes yes --set changeps1 no 28 | - conda update -q conda 29 | - conda info -a 30 | - "conda create -q -n test-environment python=%PYTHON_VERSION% numpy pandas python-dateutil six pytest" 31 | - activate test-environment 32 | 33 | test_script: 34 | - py.test 35 | -------------------------------------------------------------------------------- /doc/source/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = timeboard 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /doc/source/_fake_links.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. fake links to force sphinx move these files to output dir 4 | 5 | :download:`use_cases notebook ` 6 | 7 | -------------------------------------------------------------------------------- /doc/source/about.rst: -------------------------------------------------------------------------------- 1 | *************** 2 | About timeboard 3 | *************** 4 | 5 | :py:mod:`timeboard` creates schedules of work periods and performs calendar calculations over them. You can build standard business day calendars as well as a variety of other schedules, simple or complex. 6 | 7 | Examples of problems solved by :py:mod:`timeboard`: 8 | 9 | - If we have 20 business days to complete the project, when will be the deadline? 10 | 11 | - If a person was employed from November 15 to December 22 and salary is paid monthly, how many month's salaries has the employee earned? 12 | 13 | - The above-mentioned person was scheduled to work Mondays, Tuesdays, Saturdays, and Sundays on odd weeks, and Wednesdays, Thursdays, and Fridays on even weeks. The question is the same. 14 | 15 | - A 24x7 call center operates in shifts of varying length starting at 02:00, 08:00, and 18:00. An operator comes in on every fourth shift and is paid per shift. How many shifts has the operator sat in a specific month? 16 | 17 | - With employees entering and leaving a company throughout a year, what was the average annual headcount? 18 | 19 | Based on pandas timeseries library, :py:mod:`timeboard` gives more flexibility than pandas's built-in business calendars. The key features of :py:mod:`timeboard` are: 20 | 21 | - You can choose any time frequencies (days, hours, multiple-hour shifts, etc.) as work periods. 22 | 23 | - You can create sophisticated schedules which can combine periodical patterns, seasonal variations, stop-and-resume behavior, etc. 24 | 25 | - There are built-in standard business day calendars (in this version: for USA, UK, and Russia). 26 | 27 | 28 | 29 | Contributing 30 | ------------- 31 | 32 | :py:mod:`timeboard` is authored and maintained by Maxim Mamaev. 33 | 34 | Please use Github issues for the feedback. 35 | 36 | License 37 | ------- 38 | 39 | :: 40 | 41 | 3-Clause BSD License 42 | 43 | Copyright (c) 2018, Maxim Mamaev 44 | All rights reserved. 45 | 46 | Redistribution and use in source and binary forms, with or without 47 | modification, are permitted provided that the following conditions are met: 48 | 49 | 1. Redistributions of source code must retain the above copyright notice, 50 | this list of conditions and the following disclaimer. 51 | 52 | 2. Redistributions in binary form must reproduce the above copyright 53 | notice, this list of conditions and the following disclaimer in the 54 | documentation and/or other materials provided with the distribution. 55 | 56 | 3. Neither the name of the copyright holder nor the names of its 57 | contributors may be used to endorse or promote products derived from this 58 | software without specific prior written permission. 59 | 60 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 61 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 62 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 63 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 64 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 65 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 66 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 67 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 68 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 69 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 70 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 71 | 72 | Attribution 73 | ----------- 74 | 75 | Logo design by Olga Mamaeva. 76 | 77 | Icon 'Worker' made by Freepik from www.flaticon.com is used as an element of the logo. 78 | -------------------------------------------------------------------------------- /doc/source/calendar_classes.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | *********************** 4 | Preconfigured calendars 5 | *********************** 6 | 7 | Russia 8 | ====== 9 | 10 | .. autoclass:: timeboard.calendars.RU.Weekly8x5 11 | 12 | 13 | United Kingdom 14 | ============== 15 | 16 | .. autoclass:: timeboard.calendars.UK.Weekly8x5 17 | 18 | 19 | United States 20 | ============= 21 | 22 | .. autoclass:: timeboard.calendars.US.Weekly8x5 23 | 24 | -------------------------------------------------------------------------------- /doc/source/compound_timeboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/doc/source/compound_timeboard.png -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # timeboard documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Jan 18 18:48:32 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.todo', 36 | #'sphinx.ext.napoleon', 37 | 'numpydoc', 38 | 'nbsphinx', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = 'timeboard' 55 | copyright = '2018, Maxim Mamaev' 56 | author = 'Maxim Mamaev' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '0.2' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '0.2' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This patterns also effect to html_static_path and html_extra_path 77 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = 'sphinx' 81 | 82 | # If true, `todo` and `todoList` produce output, else they produce nothing. 83 | todo_include_todos = True 84 | 85 | 86 | # -- Options for HTML output ---------------------------------------------- 87 | 88 | # The theme to use for HTML and HTML Help pages. See the documentation for 89 | # a list of builtin themes. 90 | # 91 | html_theme = 'nature' 92 | html_logo = 'timeboard_logo.png' 93 | # 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | #html_sidebars = { 112 | # '**': [ 113 | # 'about.html', 114 | # 'navigation.html', 115 | # 'relations.html', # needs 'show_related': True theme option to display 116 | # 'searchbox.html', 117 | # 'donate.html', 118 | # ] 119 | #} 120 | 121 | 122 | # -- Options for HTMLHelp output ------------------------------------------ 123 | 124 | # Output file base name for HTML help builder. 125 | htmlhelp_basename = 'timeboarddoc' 126 | 127 | 128 | # -- Options for LaTeX output --------------------------------------------- 129 | 130 | latex_elements = { 131 | # The paper size ('letterpaper' or 'a4paper'). 132 | # 133 | # 'papersize': 'letterpaper', 134 | 135 | # The font size ('10pt', '11pt' or '12pt'). 136 | # 137 | # 'pointsize': '10pt', 138 | 139 | # Additional stuff for the LaTeX preamble. 140 | # 141 | # 'preamble': '', 142 | 143 | # Latex figure (float) alignment 144 | # 145 | # 'figure_align': 'htbp', 146 | } 147 | 148 | # Grouping the document tree into LaTeX files. List of tuples 149 | # (source start file, target name, title, 150 | # author, documentclass [howto, manual, or own class]). 151 | latex_documents = [ 152 | (master_doc, 'timeboard.tex', 'timeboard Documentation', 153 | 'Maxim Mamaev', 'manual'), 154 | ] 155 | 156 | 157 | # -- Options for manual page output --------------------------------------- 158 | 159 | # One entry per manual page. List of tuples 160 | # (source start file, name, description, authors, manual section). 161 | man_pages = [ 162 | (master_doc, 'timeboard', 'timeboard Documentation', 163 | [author], 1) 164 | ] 165 | 166 | 167 | # -- Options for Texinfo output ------------------------------------------- 168 | 169 | # Grouping the document tree into Texinfo files. List of tuples 170 | # (source start file, target name, title, author, 171 | # dir menu entry, description, category) 172 | texinfo_documents = [ 173 | (master_doc, 'timeboard', 'timeboard Documentation', 174 | author, 'timeboard', 'One line description of project.', 175 | 'Miscellaneous'), 176 | ] 177 | 178 | # Napoleon settings 179 | # napoleon_google_docstring = True 180 | # napoleon_numpy_docstring = True 181 | # napoleon_include_init_with_doc = False 182 | # napoleon_include_private_with_doc = False 183 | # napoleon_include_special_with_doc = True 184 | # napoleon_use_admonition_for_examples = False 185 | # napoleon_use_admonition_for_notes = False 186 | # napoleon_use_admonition_for_references = False 187 | # napoleon_use_ivar = False 188 | # napoleon_use_param = True 189 | # napoleon_use_rtype = True 190 | 191 | # numpydoc settings 192 | numpydoc_show_class_members = False 193 | -------------------------------------------------------------------------------- /doc/source/data_model.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | Data Model 3 | ********** 4 | 5 | .. contents:: Table of Contents 6 | :depth: 2 7 | :local: 8 | :backlinks: none 9 | 10 | Timeboard 11 | ========= 12 | 13 | Timeboard is a representation of a custom business calendar. 14 | 15 | More precisely, **timeboard** is a collection of work *schedules* based on a specific *timeline* of *workshifts* built upon a reference *frame*. 16 | 17 | .. note:: 18 | 19 | The terms *workshift*, *frame*, *timeline*, and *schedule* have exact meanings that are explained below. On the other hand, word *calendar* is considered rather ambiguous. It is used occasionally as a loose synonym for *timeboard* when there is no risk of misunderstanding. 20 | 21 | Timeboard is the upper-level object of the data model. You use timeboard as the entry point for all calculations. 22 | 23 | .. image:: simple_timeboard.png 24 | 25 | 26 | Workshift 27 | ========= 28 | 29 | **Workshift** is a period of time during which a business agent is either active or not. 30 | 31 | No assumptions are made about what "business" is and who its "agents" may be. It could be regular office workers, or operators in a 24x7 call center, or trains calling at a station on specific days. 32 | 33 | The activity state of a workshift is called **duty**; therefore for a given business agent the workshift is either "on duty" or "off duty". It is not relevant for determining the duty whether the agent is continuously active through the workshift, or takes breaks, or works only for a part of the workshift. The duty is assigned to a workshift as a whole. 34 | 35 | It is up to the user to define and interpret periods of time as workshifts in a way which is suitable for user's application. For example, when making plans on the regular calendar of 8-hour business days, you do not care about exact working hours. Hence, it is sufficient to designate whole days as workshifts with business days being on-duty workshifts, and weekends and holidays being off-duty. On the other hand, to build working schedules for operators in a 24x7 call center who work in 8-hour shifts you have to designate each 8-hour period as a separate workshift. 36 | 37 | See also *Work time* section below which discusses counting the actual work time. 38 | 39 | Note that both on-duty and off-duty periods are called "workshifts" although *work*, whatever it might be, is not carried out by the business agent when the duty is off. Generally speaking, though not necessarily, some other business agent may operate a reversed schedule, doing *work* when the first agent is idle. For example, this is the case for a 24x7 call center. 40 | 41 | Frame and Base Units 42 | ==================== 43 | 44 | The span of time covered by the timeboard is represented as a reference frame. **Frame** is a monotonous sequence of uniform periods of time called **base units**. The base unit is atomic, meaning that everything on the timeboard consists of an integer number of base units. 45 | 46 | 47 | Timeline 48 | ======== 49 | 50 | **Timeline** is a continuous sequence of workshifts laid upon the frame. 51 | 52 | Each workshift consists of one or more base units of the frame. The number of base units constituting a workshift is called the **duration** of the workshift. Different workshifts do not necessarily have the same duration. 53 | 54 | Each base unit of the frame belongs to one and only one workshift. This means that workshifts do not overlap and there are no gaps between workshifts. 55 | 56 | Each workshift is given a **label**. The type and semantic of labels are application-specific. Several or all workshifts can be assigned the same label. 57 | 58 | Labels are essential for defining the duty of the workshifts. 59 | 60 | Interval 61 | ======== 62 | 63 | **Interval** is a continuous series of workshifts within the timeline. Number if workshifts in an interval is called the **length** of the interval. Interval contains at least one workshift. 64 | 65 | Schedule 66 | ======== 67 | 68 | Schedule defines duty status of each workshift on the timeline according to a rule called **selector**. Selector examines the label of a workshift and returns True if this workshift is considered on duty under this schedule, otherwise, it returns False. 69 | 70 | Timeboard contains at least one ("default") schedule and may contain many. Each schedule employs its specific selector. The default selector for the default schedule returns ``bool(label)``. 71 | 72 | The duty of a workshift may vary depending on the schedule. 73 | 74 | For example, let the timeline consist of calendar days where weekdays from Monday through Friday are labeled with 2, Saturdays are labeled with 1, and Sundays and holidays are labeled with 0. The schedule of regular business days is obtained by applying selector ``label>1``, and under this schedule, Saturdays are off duty. However, if children attend schools on Saturdays, the school schedule can be obtained from the same timeline with selector `label>0`. Under the latter schedule, Saturdays are on duty. 75 | 76 | .. _compound-workshifts-section: 77 | 78 | Compound Workshifts 79 | =================== 80 | 81 | It is important to emphasize that, when working with a timeboard, you reason about workshifts rather than base units. Duty is associated with a workshift, not with a base unit. All calendar calculations are performed either on workshifts or on intervals. 82 | 83 | In many cases, workshift coincides with a base unit. The only reason to be otherwise is when you need workshifts of varying duration. Let's illustrate this point with examples. 84 | 85 | When reasoning about business days, the time of day when working hours start or end is not relevant. For the purpose of the calendar, it suffices to label a whole weekday on-duty in spite of the fact that only 8 hours of 24 are actually taken by business activity. 86 | 87 | Moreover, if the number of working hours do vary from day to day (i.e. Friday's hours are shorter) and you need to track that, the task can be solved just with workshift labeling. A workshift still takes a whole day, and within week workshifts are labeled ``[8, 8, 8, 8, 7, 0, 0]`` reflecting the number of working hours. The default ``selector=bool(label)`` works fine with that. Therefore, while actual workshifts do have varying duration, you do not *need* to model this in the timeline. You can use a simpler timeboard where each workshift correspond to a base unit of one calendar day. 88 | 89 | Now consider the case of a 24x7 call center operating in 8-hour shifts. Clearly, a workshift is to be represented by an 8-hour period but this does not necessarily call for workshifts consisting of 8 base units, each base unit one hour long. When building the frame, you are not limited to use of base units equal to a single calendar period, i.e. one hour, one day, and so on. You can take a base unit which spans multiple consecutive calendar periods, for example, 8 hours. Therefore, in this case, there is still no need to create workshifts consisting of several base units, as 8-hour base units can be directly mapped to 8-hour workshifts. 90 | 91 | However, the things change if we assume that the call center operates shifts of varying durations, i.e. 08:00 to 18:00 (10 hours), 18:00 to 02:00 (8 hours), and 02:00 to 08:00 (6 hours). 92 | 93 | Now the base unit has to be a common divisor of all workshift durations which is one hour. (Technically, it also can be two hours, which does not make the case any simpler, so we will stick to the more natural one-hour clocking.) 94 | 95 | This case cannot be elegantly handled by workshifts bound to base units. This way we would end up, for any day, not with three workshifts of 10, 8 and 6 hours long but with a succession of 24 one-hour workshifts of which either 10, 8 or 6 consecutive ones will be labeled on-duty. Creating meaningful work schedules and performing calculations for such timeline would be a rather cumbersome challenge. Therefore we have to decouple workshifts from base units and create the timeline where individual workshifts have durations of 10, 8, and 6 base units in the repeating pattern. 96 | 97 | Having said that, while in many cases a workshift will coincide with a base unit, these entities have different purposes. 98 | 99 | A workshift comprising more than one base unit is called **compound workshift**. 100 | 101 | .. image:: compound_timeboard.png 102 | 103 | .. _work-time-section: 104 | 105 | Work time 106 | ========= 107 | 108 | Work time (also spelled 'worktime' in names of functions and parameters) is the amount of time within workshift which the agent spends actually doing work. In many use cases, you will want to find out the work time of a specific workshift or the total work time of an interval. 109 | 110 | Depending on the model of a timeboard, the duration of workshift may or may not represent the work time. Typically, in the models based on continuous succession of shifts, the work time takes the entire workshift. On the other hand, in calendars of business days, the actual work time takes only a part of a workshift (that is, of a day). 111 | 112 | In the latter case, you may use workshift labels to indicate the work time as it has been shown in the previous section. Obviously, such labels must be numbers. Their interpretation is up to the user. 113 | 114 | When creating your timeboard you will have to specify the source of information for counting the work time: either it is workshift's duration or workshift's label. Accordingly, the functions counting the work time will return either the number of base units in the workshift/interval or the sum of the labels. -------------------------------------------------------------------------------- /doc/source/exceptions.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | *********** 4 | Exceptions 5 | *********** 6 | 7 | 8 | .. autoexception:: timeboard.OutOfBoundsError 9 | 10 | .. autoexception:: timeboard.PartialOutOfBoundsError 11 | 12 | .. autoexception:: timeboard.VoidIntervalError 13 | 14 | 15 | .. autoexception:: timeboard.UnacceptablePeriodError -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. timeboard documentation master file, created by 2 | sphinx-quickstart on Thu Jan 18 18:48:32 2018. 3 | 4 | Welcome to timeboard's documentation! 5 | ====================================== 6 | 7 | :py:mod:`timeboard` performs calendar calculations over business schedules such as business days or work shifts. 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | :caption: Contents: 12 | 13 | about 14 | installation 15 | quick_start 16 | data_model 17 | making_a_timeboard 18 | using_preconfigured 19 | doing_calculations 20 | use_cases.ipynb 21 | release_notes 22 | 23 | * :ref:`genindex` 24 | 25 | .. * :ref:`modindex` 26 | .. * :ref:`search` 27 | 28 | Downloads: 29 | 30 | * :download:`jupyter notebook ` with common use cases 31 | 32 | Links: 33 | 34 | * Github: https://github.com/mmamaev/timeboard 35 | * PyPI: https://pypi.python.org/pypi/timeboard 36 | * Documentation (this page): https://timeboard.readthedocs.io/ 37 | -------------------------------------------------------------------------------- /doc/source/installation.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Installation 3 | ************ 4 | 5 | Python version support 6 | ---------------------- 7 | 8 | :py:mod:`timeboard` is tested with Python versions 2.7, 3.6, 3.7, and 3.8. 9 | 10 | 11 | Installation 12 | ------------ 13 | :: 14 | 15 | pip install timeboard 16 | 17 | The import statement to run all the examples is:: 18 | 19 | >>> import timeboard as tb 20 | 21 | 22 | Dependencies 23 | ------------ 24 | 25 | ====================================================== ================= 26 | Package versions tested 27 | ====================================================== ================= 28 | `pandas `_ 0.22 - 1.0 29 | `numpy `_ 1.13 - 1.18 30 | `python-dateutil `_ 2.6.1 - 2.8.1 31 | `six `_ 1.11 - 1.14 32 | ====================================================== ================= 33 | 34 | The code is tested by `pytest `_ . 35 | 36 | 37 | -------------------------------------------------------------------------------- /doc/source/interval_class.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ***************** 4 | `Interval` class 5 | ***************** 6 | 7 | 8 | .. autoclass:: timeboard.Interval 9 | :members: first, last, nth, total_duration, worktime, count, 10 | count_periods, what_portion_of, to_dataframe, overlap, 11 | workshifts, __mul__, __div__ 12 | -------------------------------------------------------------------------------- /doc/source/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=timeboard 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /doc/source/organizing_classes.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ***************** 4 | `Organizer` class 5 | ***************** 6 | 7 | .. autoclass:: timeboard.Organizer 8 | 9 | 10 | ***************** 11 | `Marker` class 12 | ***************** 13 | 14 | .. autoclass:: timeboard.Marker 15 | 16 | 17 | ************************** 18 | `RememberingPattern` class 19 | ************************** 20 | 21 | .. autoclass:: timeboard.RememberingPattern 22 | -------------------------------------------------------------------------------- /doc/source/quick_start.rst: -------------------------------------------------------------------------------- 1 | ***************** 2 | Quick Start Guide 3 | ***************** 4 | 5 | Set up a timeboard 6 | ------------------ 7 | 8 | To get started you need to build a timeboard (calendar). The simplest way to do so is to use a preconfigured calendar which is shipped with the package. Let's take a regular business day calendar for the United States. 9 | :: 10 | 11 | >>> import timeboard.calendars.US as US 12 | >>> clnd = US.Weekly8x5() 13 | 14 | 15 | .. note:: If you need to build a custom calendar, for example, a schedule of shifts for a 24x7 call center, :doc:`Making a Timeboard ` section of the documentation explains this topic in details. 16 | 17 | Once you have got a timeboard, you may perform queries and calculations over it. 18 | 19 | 20 | Play with workshifts 21 | -------------------- 22 | 23 | Calling a timeboard instance `clnd` with a single point in time produces an object representing a unit of the calendar (in this case, a day) that contains this point in time. Object of this type is called *workshift*. 24 | 25 | **Is a certain date a business day?** 26 | :: 27 | 28 | >>> ws = clnd('27 May 2017') 29 | >>> ws.is_on_duty() 30 | False 31 | 32 | Indeed, it was a Saturday. 33 | 34 | 35 | **When was the next business day?** 36 | :: 37 | 38 | >>> ws.rollforward() 39 | Workshift(6359) of 'D' at 2017-05-30 40 | 41 | The returned calendar unit (workshift) has the sequence number of 6359 and represents the day of 30 May 2017, which, by the way, was the Tuesday after the Memorial Day holiday. 42 | 43 | 44 | **If we were to finish the project in 22 business days starting on 01 May 2017, when would be our deadline?** 45 | :: 46 | 47 | >>> clnd('01 May 2017') + 22 48 | Workshift(6361) of 'D' at 2017-06-01 49 | 50 | This is the same as: 51 | :: 52 | 53 | >>> clnd('01 May 2017').rollforward(22) 54 | Workshift(6361) of 'D' at 2017-06-01 55 | 56 | 57 | Play with intervals 58 | ------------------- 59 | 60 | Calling ``clnd()`` with a different set of parameters produces an object representing an *interval* on the calendar. The interval below contains all workshifts of the months of May 2017. 61 | 62 | **How many business days were there in a certain month?** 63 | :: 64 | 65 | >>> may2017 = clnd('May 2017', period='M') 66 | >>> may2017.count() 67 | 22 68 | 69 | 70 | **How many days off?** 71 | :: 72 | 73 | >>> may2017.count(duty='off') 74 | 9 75 | 76 | 77 | **How many working hours?** 78 | :: 79 | 80 | >>> may2017.worktime() 81 | 176.0 82 | 83 | 84 | An employee was on the staff from April 3, 2017 to May 15, 2017. **What portion of April's salary did the company owe them?** 85 | 86 | Calling ``clnd()`` with a tuple of two points in time produces an interval containing all workshifts between these points, inclusively. 87 | :: 88 | 89 | >>> time_in_company = clnd(('03 Apr 2017','15 May 2017')) 90 | >>> time_in_company.what_portion_of(clnd('Apr 2017', period='M')) 91 | 1.0 92 | 93 | Indeed, the 1st and the 2nd of April in 2017 fell on the weekend, therefore, having started on the 3rd, the employee checked out all the working days in the month. 94 | 95 | **And what portion of May's?** 96 | :: 97 | 98 | >>> time_in_company.what_portion_of(may2017) 99 | 0.5 100 | 101 | **How many days had the employee worked in May?** 102 | 103 | The multiplication operator returns the intersection of two intervals. 104 | :: 105 | 106 | >>> (time_in_company * may2017).count() 107 | 11 108 | 109 | **How many hours?** 110 | :: 111 | 112 | >>> (time_in_company * may2017).worktime() 113 | 88 114 | 115 | 116 | An employee was on the staff from 01 Jan 2016 to 15 Jul 2017. **How many years this person had worked for the company?** 117 | :: 118 | 119 | >>> clnd(('01 Jan 2016', '15 Jul 2017')).count_periods('A') 120 | 1.5421686746987953 121 | 122 | 123 | -------------------------------------------------------------------------------- /doc/source/release_notes.rst: -------------------------------------------------------------------------------- 1 | *************** 2 | Release Notes 3 | *************** 4 | 5 | timeboard 0.2.4 6 | =============== 7 | 8 | **Release date:** June 25, 2022 9 | 10 | Resolved issues 11 | --------------- 12 | 13 | * Fixed changed in import path for Iterables. 14 | * Tested compatibility with Python 3.9, 3.10. 15 | 16 | 17 | timeboard 0.2.3 18 | =============== 19 | 20 | **Release date:** May 01, 2020 21 | 22 | Resolved issues 23 | --------------- 24 | 25 | * Incompatibility with the breaking API changes introduced in pandas 1.0 26 | 27 | Miscellaneous 28 | ------------- 29 | 30 | * Russian business day calendar has been updated for 2020. 31 | 32 | 33 | timeboard 0.2.2 34 | =============== 35 | 36 | **Release date:** May 01, 2019 37 | 38 | Resolved issues 39 | --------------- 40 | 41 | Breaking changes were introduced in pandas versions 0.23 and 0.24 42 | 43 | * Pandas 0.23 moved `is_subperiod` function to another module 44 | * Workaround for pandas issue #26258 (Adding offset to DatetimeIndex is broken) 45 | 46 | 47 | timeboard 0.2.1 48 | =============== 49 | 50 | **Release date:** January 15, 2019 51 | 52 | Miscellaneous 53 | ------------- 54 | 55 | * Business day calendars for RU, UK, and US have been updated 56 | 57 | 58 | timeboard 0.2 59 | ============= 60 | 61 | **Release date:** March 01, 2018 62 | 63 | New features 64 | ------------ 65 | 66 | * :py:meth:`.Interval.overlap` (also ``*``) - return the interval that is the intersection of two intervals. 67 | 68 | * :py:meth:`.Interval.what_portion_of` (also ``/``) - calculate what portion of the other interval this interval takes up. 69 | 70 | * :py:meth:`.Interval.workshifts` - return a generator that yields workshifts with the specified duty from the interval. 71 | 72 | * Work time calculation: :py:meth:`.Workshift.worktime`, :py:meth:`.Interval.worktime` 73 | 74 | Miscellaneous 75 | ------------- 76 | 77 | * Performance: building any practical timeboard should take a fraction of a second. 78 | 79 | * Documentation: added :doc:`Common Use Cases ` section. It is also available as a :download:`jupyter notebook `. 80 | 81 | 82 | timeboard 0.1 83 | ============= 84 | 85 | **Release date:** February 01, 2018 86 | 87 | This is the first release. 88 | -------------------------------------------------------------------------------- /doc/source/requirements.txt: -------------------------------------------------------------------------------- 1 | numpydoc 2 | ipykernel 3 | nbsphinx 4 | -------------------------------------------------------------------------------- /doc/source/simple_timeboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/doc/source/simple_timeboard.png -------------------------------------------------------------------------------- /doc/source/timeboard_class.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ***************** 4 | `Timeboard` class 5 | ***************** 6 | 7 | .. autoclass:: timeboard.Timeboard 8 | :members: 9 | 10 | 11 | .. index:: 12 | single: _Schedule (class in timeboard.core) 13 | 14 | .. class:: timeboard.core_Schedule 15 | :noindex: 16 | 17 | Duty schedule of workshifts. 18 | 19 | :py:meth:`._Schedule` constructor is not supposed to be called directly. 20 | 21 | Default schedule for a timeboard is generated automatically. To add another schedule call :py:meth:`.Timeboard.add_schedule`. To remove a schedule from the timeboard call :py:meth:`.Timeboard.drop_schedule`. 22 | 23 | Users identify schedules by name. To find out the name of a schedule inspect attribute :py:attr:`._Schedule.name`. To obtain a schedule by name look it up in :py:attr:`Timeboard.schedules` dictionary. -------------------------------------------------------------------------------- /doc/source/timeboard_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/doc/source/timeboard_logo.png -------------------------------------------------------------------------------- /doc/source/using_preconfigured.rst: -------------------------------------------------------------------------------- 1 | ***************************** 2 | Using Preconfigured Calendars 3 | ***************************** 4 | 5 | There are a few preconfigured Timeboards that come with the package. They implement common business day calendars of different countries. 6 | 7 | To access calendars of a country you have to import the country module from :py:mod:`timeboard.calendars`, for example:: 8 | 9 | >>> import timeboard.calendars.US as US 10 | 11 | Then, to obtain a Timeboard implementing a required calendar, call the class for this calendar from the chosen module. Usually, the class takes some country-specific parameters that allow tuning the calendar. For example:: 12 | 13 | >>> clnd = US.Weekly8x5(do_not_observe = {'black_friday'}) 14 | 15 | :py:meth:`parameters` class method returns the dictionary of the parameters used to instantiate the Timeboard. Of these, the most usable are probably parameters `start` and `end` which limit the maximum supported span of the calendar:: 16 | 17 | >>> params = US.Weekly8x5.parameters() 18 | >>> params['start'] 19 | Timestamp('2000-01-01 00:00:00') 20 | >>> params['end'] 21 | Timestamp('2020-12-31 23:59:59') 22 | 23 | The currently available calendars are listed below. Consult the reference page of the calendar class to review its parameters and examples. 24 | 25 | =============== ====== =========== =========================================== 26 | Country Module Calendar Description 27 | =============== ====== =========== =========================================== 28 | Russia RU |RU-W8x5| Official calendar for 5 days x 8 hours 29 | working week with holiday observations 30 | 31 | United Kingdom UK |UK-W8x5| Business calendar for 5 days x 8 hours 32 | working week with bank holidays 33 | 34 | United States US |US-W8x5| Business calendar for 5 days x 8 hours 35 | working week with federal holidays 36 | =============== ====== =========== =========================================== 37 | 38 | .. |RU-W8x5| replace:: :py:class:`~timeboard.calendars.RU.Weekly8x5` 39 | 40 | .. |UK-W8x5| replace:: :py:class:`~timeboard.calendars.UK.Weekly8x5` 41 | 42 | .. |US-W8x5| replace:: :py:class:`~timeboard.calendars.US.Weekly8x5` 43 | -------------------------------------------------------------------------------- /doc/source/workshift_class.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ***************** 4 | `Workshift` class 5 | ***************** 6 | 7 | 8 | .. autoclass:: timeboard.Workshift 9 | :members: to_timestamp, is_on_duty, is_off_duty, worktime, 10 | rollforward, rollback, __add__, __sub__ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas >=0.22,<=0.24; python_full_version < '3.6.1' 2 | pandas >=0.22; python_full_version >= '3.6.1' 3 | numpy >=1.13,<=1.16; python_version < '3.6' 4 | numpy >=1.16; python_version >= '3.6' 5 | python-dateutil>=2.6.1 6 | six 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.test import test as TestCommand 3 | import sys 4 | 5 | def read_file(path): 6 | with open(path) as f: 7 | return f.read() 8 | 9 | readme = read_file('README.rst') 10 | _, rest = readme.split('.. pypi-start') 11 | text, _ = rest.split('.. pypi-end') 12 | long_description = text.replace('`', '').replace('**', '').replace('::', '') 13 | 14 | class Unsupported(TestCommand): 15 | def run(self): 16 | print("Running 'test' with setup.py is not supported. " 17 | "Use 'pytest' or 'tox' to run the tests.") 18 | 19 | PACKAGES = find_packages(where='.', exclude=['timeboard.tests']) 20 | 21 | setup(name='timeboard', 22 | version=read_file('timeboard/VERSION.txt').strip(), 23 | description='Calendar calculations over business days and work shifts', 24 | long_description=long_description, 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'License :: OSI Approved :: BSD License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python :: 2.7', 30 | 'Programming Language :: Python :: 3.6', 31 | 'Programming Language :: Python :: 3.7', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Topic :: Office/Business :: Scheduling', 36 | ], 37 | keywords='business day calendar schedule calculation shift', 38 | url='http://timeboard.readthedocs.io', 39 | author='Maxim Mamaev', 40 | author_email='mmamaev2@gmail.com', 41 | license='BSD 3-Clause', 42 | packages=PACKAGES, 43 | include_package_data=True, 44 | install_requires=read_file('requirements.txt').strip().split('\n'), 45 | test_suite='timeboard.tests', 46 | cmdclass={ 47 | "test": Unsupported 48 | }, 49 | zip_safe=False) 50 | -------------------------------------------------------------------------------- /timeboard/VERSION.txt: -------------------------------------------------------------------------------- 1 | 0.2.4 2 | -------------------------------------------------------------------------------- /timeboard/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | timeboard performs calendar calculations over business schedules 3 | such as business days or work shifts. 4 | 5 | See http://timeboard.readthedocs.io 6 | """ 7 | from .timeboard import Timeboard 8 | from .core import Organizer, Marker, RememberingPattern 9 | from .interval import Interval 10 | from .workshift import Workshift 11 | from .exceptions import (OutOfBoundsError, 12 | PartialOutOfBoundsError, 13 | VoidIntervalError, 14 | UnacceptablePeriodError) 15 | 16 | import os 17 | 18 | PKG_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 19 | 20 | def read_from(path): 21 | with open(os.path.join(PKG_ROOT_DIR, path)) as f: 22 | return f.read().strip() 23 | 24 | __version__ = read_from('VERSION.txt') -------------------------------------------------------------------------------- /timeboard/calendars/RU.py: -------------------------------------------------------------------------------- 1 | from .calendarbase import CalendarBase 2 | from ..core import get_timestamp, get_period 3 | from ..timeboard import Organizer 4 | from itertools import product 5 | 6 | 7 | def holidays(start_year, end_year, work_on_dec31): 8 | dates = ['01 Jan', '02 Jan', '03 Jan', '04 Jan', '05 Jan', 9 | '06 Jan', '07 Jan', '23 Feb', '08 Mar', '01 May', 10 | '09 May', '12 Jun', '04 Nov'] 11 | if not work_on_dec31: 12 | dates.append('31 Dec') 13 | years = range(start_year, end_year + 1) 14 | return {get_timestamp("{} {}".format(day, year)): 0 15 | for day, year in product(dates, years)} 16 | 17 | 18 | def changes(eve_hours): 19 | x = eve_hours 20 | dates = { 21 | '10 Jan 2005': 0, '22 Feb 2005': x, '05 Mar 2005': x, '07 Mar 2005': 0, 22 | '02 May 2005': 0, '10 May 2005': 0, '14 May 2005': 8, '13 Jun 2005': 0, 23 | '03 Nov 2005': x, 24 | '09 Jan 2006': 0, '22 Feb 2006': x, '24 Feb 2006': 0, '26 Feb 2006': 8, 25 | '07 Mar 2006': x, '06 May 2006': x, '08 May 2006': 0, '03 Nov 2006': x, 26 | '06 Nov 2006': 0, 27 | '08 Jan 2007': 0, '22 Feb 2007': x, '07 Mar 2007': x, '28 Apr 2007': x, 28 | '30 Apr 2007': 0, '08 May 2007': x, '09 Jun 2007': x, '11 Jun 2007': 0, 29 | '05 Nov 2007': 0, '29 Dec 2007': x, '31 Dec 2007': 0, 30 | '08 Jan 2008': 0, '22 Feb 2008': x, '25 Feb 2008': 0, '07 Mar 2008': x, 31 | '10 Mar 2008': 0, '30 Apr 2008': x, '02 May 2008': 0, '04 May 2008': 8, 32 | '08 May 2008': x, '07 Jun 2008': 8, '11 Jun 2008': x, '13 Jun 2008': 0, 33 | '01 Nov 2008': x, '03 Nov 2008': 0, '31 Dec 2008': x, 34 | '08 Jan 2009': 0, '09 Jan 2009': 0, '11 Jan 2009': 8, '09 Mar 2009': 0, 35 | '30 Apr 2009': x, '08 May 2009': x, '11 May 2009': 0, '11 Jun 2009': x, 36 | '03 Nov 2009': x, '31 Dec 2009': x, 37 | '08 Jan 2010': 0, '22 Feb 2010': 0, '27 Feb 2010': x, '30 Apr 2010': x, 38 | '03 May 2010': 0, '10 May 2010': 0, '11 Jun 2010': x, '14 Jun 2010': 0, 39 | '03 Nov 2010': x, '05 Nov 2010': 0, '13 Nov 2010': 8, '31 Dec 2010': x, 40 | '10 Jan 2011': 0, '22 Feb 2011': x, '05 Mar 2011': x, '07 Mar 2011': 0, 41 | '02 May 2011': 0, '13 Jun 2011': 0, '03 Nov 2011': x, 42 | '09 Jan 2012': 0, '22 Feb 2012': x, '07 Mar 2012': x, '09 Mar 2012': 0, 43 | '11 Mar 2012': 8, '28 Apr 2012': x, '30 Apr 2012': 0, '05 May 2012': 8, 44 | '07 May 2012': 0, '08 May 2012': 0, '12 May 2012': x, '09 Jun 2012': x, 45 | '11 Jun 2012': 0, '05 Nov 2012': 0, '29 Dec 2012': x, '31 Dec 2012': 0, 46 | '08 Jan 2013': 0, '22 Feb 2013': x, '07 Mar 2013': x, '30 Apr 2013': x, 47 | '02 May 2013': 0, '03 May 2013': 0, '08 May 2013': x, '10 May 2013': 0, 48 | '11 Jun 2013': x, '31 Dec 2013': x, 49 | '08 Jan 2014': 0, '24 Feb 2014': x, '07 Mar 2014': x, '10 Mar 2014': 0, 50 | '30 Apr 2014': x, '02 May 2014': 0, '08 May 2014': x, '11 Jun 2014': x, 51 | '13 Jun 2014': 0, '03 Nov 2014': 0, '31 Dec 2014': x, 52 | '08 Jan 2015': 0, '09 Jan 2015': 0, '09 Mar 2015': 0, '30 Apr 2015': x, 53 | '04 May 2015': 0, '08 May 2015': x, '11 May 2015': 0, '11 Jun 2015': x, 54 | '03 Nov 2015': x, '31 Dec 2015': x, 55 | '08 Jan 2016': 0, '20 Feb 2016': x, '22 Feb 2016': 0, '07 Mar 2016': 0, 56 | '02 May 2016': 0, '03 May 2016': 0, '13 Jun 2016': 0, '03 Nov 2016': x, 57 | '22 Feb 2017': x, '24 Feb 2017': 0, '07 Mar 2017': x, '08 May 2017': 0, 58 | '03 Nov 2017': x, '06 Nov 2017': 0, 59 | '08 Jan 2018': 0, '22 Feb 2018': x, '07 Mar 2018': x, '09 Mar 2018': 0, 60 | '28 Apr 2018': x, '30 Apr 2018': 0, '02 May 2018': 0, '08 May 2018': x, 61 | '09 Jun 2018': x, '11 Jun 2018': 0, '05 Nov 2018': 0, '29 Dec 2018': x, 62 | '31 Dec 2018': 0, 63 | '08 Jan 2019': 0, '22 Feb 2019': x, '07 Mar 2019': x, '30 Apr 2019': x, 64 | '02 May 2019': 0, '03 May 2019': 0, '08 May 2019': x, '10 May 2019': 0, 65 | '11 Jun 2019': x, '31 Dec 2019': x, 66 | '08 Jan 2020': 0, '24 Feb 2020': 0, '09 Mar 2020': 0, '30 Apr 2020': x, 67 | '04 May 2020': 0, '05 May 2020': 0, '08 May 2020': x, '11 May 2020': 0, 68 | '11 Jun 2020': x, '03 Nov 2020': x, '31 Dec 2020': x, 69 | '08 Jan 2021': 0, '20 Feb 2021': x, '22 Feb 2021': 0, '30 Apr 2021': x, 70 | '03 May 2021': 0, '10 May 2021': 0, '11 Jun 2021': x, '14 Jun 2021': 0, 71 | '03 Nov 2021': x, '05 Nov 2021': 0, '31 Dec 2021': 0, 72 | } 73 | 74 | return {get_timestamp(k): v for k, v in dates.items()} 75 | 76 | 77 | class Weekly8x5(CalendarBase): 78 | """Russian official calendar for 5 days x 8 hours working week. 79 | 80 | Workshifts are calendar days. Workshift labels are the number of working 81 | hours per day: 0 for days off, 8 for regular business days, 7 for some 82 | pre- or post-holiday business days (see also `short_eves` parameter). 83 | 84 | Parameters 85 | ---------- 86 | custom_start : `Timestamp`-like, optional 87 | Change the first date of the calendar. This date must be within the 88 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 89 | By default, the calendar starts on the date defined by 'start' 90 | element of :py:meth:`Weekly8x5.parameters()`. 91 | custom_end : `Timestamp`-like, optional 92 | Change the last date of the calendar. This date must be within the 93 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 94 | By default, the calendar ends on the date defined by 'end' 95 | element of :py:meth:`Weekly8x5.parameters()`. 96 | do_not_amend : bool, optional (default False) 97 | If set to True, the calendar is created without any amendments, 98 | meaning that effects of holiday observations are not accounted for. 99 | only_custom_amendments : bool, optional (default False) 100 | If set to True, only amendments from `custom_amendments` are applied 101 | to the calendar. 102 | custom_amendments : dict-like 103 | The alternative amendments if `only_custom_amendments` is true. 104 | Otherwise, `custom_amendments` are used to update pre-configured 105 | amendments (add missing or override existing amendments). 106 | work_on_dec31 : bool, optional (default True) 107 | If False, the December 31 is always considered a holiday. Otherwise 108 | use the official status of each December 31, which is the default 109 | behavior. 110 | short_eves : bool, optional (default True) 111 | If False, consider all business days having 8 working hours. 112 | Otherwise assume the official reduction of the working day to 7 113 | hours on some pre- or post-holiday business days, which is the 114 | default behavior. 115 | 116 | Raises 117 | ------ 118 | OutOfBoundsError 119 | If `custom_start` or `custom_end` fall outside the calendar range 120 | returned by :py:meth:`Weekly8x5.parameters()` 121 | 122 | Returns 123 | ------- 124 | :py:class:`.Timeboard` 125 | 126 | Methods 127 | ------- 128 | parameters() : dict 129 | This class method returns a dictionary of :py:class:`.Timeboard` 130 | parameters used for building the calendar. 131 | 132 | Examples 133 | -------- 134 | >>> import timeboard.calendars.RU as RU 135 | 136 | Create an official business calendar for the available range of dates: 137 | 138 | >>> clnd = RU.Weekly8x5() 139 | 140 | Create a business calendar for years 2010-2017, ignoring short eves 141 | and making December 31 always a day off: 142 | 143 | >>> clnd = RU.Weekly8x5(custom_start='01 Jan 2010', 144 | ... custom_end='31 Dec 2017', 145 | ... work_on_dec31 = False, 146 | ... short_eves = False) 147 | 148 | Inspect the default calendar range: 149 | 150 | >>> params = RU.Weekly8x5.parameters() 151 | >>> params['start'] 152 | Timestamp('2005-01-01 00:00:00') 153 | >>> params['end'] 154 | Timestamp('2018-12-31 23:59:59') 155 | 156 | """ 157 | 158 | @classmethod 159 | def parameters(cls): 160 | return { 161 | 'base_unit_freq': 'D', 162 | 'start': get_timestamp('01 Jan 2005'), 163 | 'end': get_timestamp('31 Dec 2021 23:59:59'), 164 | 'layout': Organizer(marker='W', structure=[[8, 8, 8, 8, 8, 0, 0]]), 165 | 'worktime_source': 'labels', 166 | } 167 | 168 | @classmethod 169 | def amendments(cls, custom_start=None, custom_end=None, 170 | custom_amendments=None, 171 | work_on_dec31=True, short_eves=True): 172 | 173 | start, end = cls._get_bounds(custom_start, custom_end) 174 | 175 | if short_eves: 176 | eve_hours = 7 177 | else: 178 | eve_hours = 8 179 | result = changes(eve_hours) 180 | result.update(holidays(start.year, end.year, work_on_dec31)) 181 | if custom_amendments is not None: 182 | freq = cls.parameters()['base_unit_freq'] 183 | result.update( 184 | {get_period(k, freq=freq).start_time: v 185 | for k, v in custom_amendments.items()} 186 | ) 187 | 188 | return result 189 | -------------------------------------------------------------------------------- /timeboard/calendars/UK.py: -------------------------------------------------------------------------------- 1 | from .calendarbase import ( 2 | CalendarBase, nth_weekday_of_month, extend_weekends, from_easter) 3 | from ..core import get_timestamp, get_period 4 | from ..timeboard import Organizer 5 | from itertools import product 6 | 7 | 8 | def bank_holidays(start_year, end_year, country='england', do_not_observe=None, 9 | long_weekends=True, label=0): 10 | 11 | bank_holidays_fixed = {'new_year': '01 Jan', 12 | 'new_year2': '02 Jan', 13 | 'st_patricks': '17 Mar', 14 | 'orangemens': '12 Jul', 15 | 'st_andrews': '30 Nov', 16 | 'christmas': '25 Dec', 17 | 'boxing': '26 Dec'} 18 | 19 | bank_holidays_floating = { 20 | 'early_may': (5, 1, 1), 21 | 'spring': (5, 1, -1), 22 | 'summer': (8, 1, -1), 23 | } 24 | 25 | bank_holidays_easter = { 26 | 'good_friday': -2, 27 | 'easter_monday': 1 28 | } 29 | 30 | if do_not_observe is None: 31 | do_not_observe = set() 32 | else: 33 | do_not_observe = set(do_not_observe) 34 | if country == 'england': 35 | do_not_observe |= {'new_year2', 'st_patricks', 'orangemens', 36 | 'st_andrews'} 37 | if country == 'scotland': 38 | bank_holidays_floating['summer'] = (8, 1, 1) 39 | do_not_observe |= {'st_patricks', 'orangemens', 'easter_monday'} 40 | if country == 'northern_ireland': 41 | do_not_observe |= {'new_year2', 'st_andrews'} 42 | 43 | years = range(start_year, end_year + 1) 44 | days = [day for holiday, day in bank_holidays_fixed.items() 45 | if holiday not in do_not_observe] 46 | 47 | amendments = {"{} {}".format(day, year): label 48 | for day, year in product(days, years)} 49 | if long_weekends: 50 | amendments = extend_weekends(amendments, how='next') 51 | 52 | floating_dates_to_seek = [date_tuple for holiday, date_tuple 53 | in bank_holidays_floating.items() 54 | if holiday not in do_not_observe] 55 | easter_dates_to_seek = [shift for holiday, shift 56 | in bank_holidays_easter.items() 57 | if holiday not in do_not_observe] 58 | for year in years: 59 | amendments.update( 60 | nth_weekday_of_month(year, floating_dates_to_seek, label)) 61 | amendments.update( 62 | from_easter(year, easter_dates_to_seek, 'western', label)) 63 | 64 | if 2002 in years: 65 | try: 66 | del amendments[get_timestamp('27 May 2002')] 67 | except KeyError: 68 | pass 69 | if 'royal' not in do_not_observe: 70 | amendments[get_timestamp('03 Jun 2002')] = label 71 | amendments[get_timestamp('04 Jun 2002')] = label 72 | if 2011 in years and 'royal' not in do_not_observe: 73 | amendments[get_timestamp('29 Apr 2011')] = label 74 | if 2012 in years: 75 | try: 76 | del amendments[get_timestamp('28 May 2012')] 77 | except KeyError: 78 | pass 79 | if 'royal' not in do_not_observe: 80 | amendments[get_timestamp('04 Jun 2012')] = label 81 | amendments[get_timestamp('05 Jun 2012')] = label 82 | 83 | return amendments 84 | 85 | 86 | class Weekly8x5(CalendarBase): 87 | """British business calendar for 5 days x 8 hours working week. 88 | 89 | The calendar takes into account the bank holidays 90 | (https://www.gov.uk/bank-holidays) with regard to the country within 91 | the UK. Selected holidays can be ignored by adding them to `exclusions`. 92 | 93 | Workshifts are calendar days. Workshift labels are the number of working 94 | hours per day: 0 for days off, 8 for business days. 95 | 96 | Parameters 97 | ---------- 98 | custom_start : `Timestamp`-like, optional 99 | Change the first date of the calendar. This date must be within the 100 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 101 | By default, the calendar starts on the date defined by 'start' 102 | element of :py:meth:`Weekly8x5.parameters()`. 103 | custom_end : `Timestamp`-like, optional 104 | Change the last date of the calendar. This date must be within the 105 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 106 | By default, the calendar ends on the date defined by 'end' 107 | element of :py:meth:`Weekly8x5.parameters()`. 108 | do_not_amend : bool, optional (default False) 109 | If set to True, the calendar is created without any amendments, 110 | meaning that effects of holiday observations are not accounted for. 111 | only_custom_amendments : bool, optional (default False) 112 | If set to True, only amendments from `custom_amendments` are applied 113 | to the calendar. 114 | only_custom_amendments : bool, optional (default False) 115 | If set to True, only amendments from `custom_amendments` are applied 116 | to the calendar. 117 | custom_amendments : dict-like 118 | The alternative amendments if `only_custom_amendments` is true. 119 | Otherwise, `custom_amendments` are used to update pre-configured 120 | amendments (add missing or override existing amendments). 121 | country : {``'england'``, ``'northern_ireland'``, ``'scotland'``}, optional 122 | The default is ``'england'`` for England and Wales. 123 | do_not_observe : set, optional 124 | Holidays to be ignored. The following values are accepted into 125 | the set: ``'new_year'``, ``'new_year2'`` (for the 2nd of January), 126 | ``'st_patricks'``, ``'good_friday'``, ``'easter_monday'``, 127 | ``'early_may'``, ``'spring'``, ``'orangemens'`` 128 | (for Battle of the Boyne Day on July 12), 129 | ``'st_andrews'``, ``'christmas'``, ``'boxing'``, ``'royal'`` 130 | (for one-off celebrations in the royal family). 131 | long_weekends : bool, optional (default True) 132 | If false, do not extend weekends if a holiday falls on Saturday or 133 | Sunday. 134 | 135 | Raises 136 | ------ 137 | OutOfBoundsError 138 | If `custom_start` or `custom_end` fall outside the calendar range 139 | returned by :py:meth:`Weekly8x5.parameters()` 140 | 141 | Returns 142 | ------- 143 | :py:class:`.Timeboard` 144 | 145 | Methods 146 | ------- 147 | parameters() : dict 148 | This class method returns a dictionary of :py:class:`.Timeboard` 149 | parameters used for building the calendar. 150 | 151 | Examples 152 | -------- 153 | >>> import timeboard.calendars.UK as UK 154 | 155 | Create an official business calendar for the available range of dates: 156 | 157 | >>> clnd = UK.Weekly8x5() 158 | 159 | Create a 2010-2017 business calendar for Scotland but don't observe 160 | St Andrew's Day: 161 | 162 | >>> clnd = UK.Weekly8x5(custom_start='01 Jan 2010', 163 | ... custom_end='31 Dec 2017', 164 | ... country = 'scotland', 165 | ... do_not_observe = {'st_andrews'}) 166 | 167 | Inspect the default calendar range: 168 | 169 | >>> params = UK.Weekly8x5.parameters() 170 | >>> params['start'] 171 | Timestamp('2000-01-01 00:00:00') 172 | >>> params['end'] 173 | Timestamp('2019-12-31 23:59:59') 174 | 175 | """ 176 | 177 | @classmethod 178 | def parameters(cls): 179 | return { 180 | 'base_unit_freq': 'D', 181 | 'start': get_timestamp('01 Jan 2000'), 182 | 'end': get_timestamp('31 Dec 2020 23:59:59'), 183 | 'layout': Organizer(marker='W', structure=[[8, 8, 8, 8, 8, 0, 0]]), 184 | 'worktime_source': 'labels', 185 | } 186 | 187 | @classmethod 188 | def amendments(cls, custom_start=None, custom_end=None, 189 | custom_amendments=None, country='england', 190 | do_not_observe=None, long_weekends=True): 191 | start, end = cls._get_bounds(custom_start, custom_end) 192 | 193 | result = bank_holidays(start.year, end.year, country, do_not_observe, 194 | long_weekends) 195 | if custom_amendments is not None: 196 | freq = cls.parameters()['base_unit_freq'] 197 | result.update( 198 | {get_period(k, freq=freq).start_time: v 199 | for k, v in custom_amendments.items()} 200 | ) 201 | 202 | return result 203 | -------------------------------------------------------------------------------- /timeboard/calendars/US.py: -------------------------------------------------------------------------------- 1 | from .calendarbase import CalendarBase, nth_weekday_of_month, extend_weekends 2 | from ..core import get_timestamp, get_period 3 | from ..timeboard import Organizer 4 | from itertools import product 5 | 6 | 7 | def fed_holidays(start_year, end_year, do_not_observe=None, long_weekends=True, 8 | label=0): 9 | 10 | fed_holidays_fixed = {'new_year': '01 Jan', 11 | 'independence': '04 Jul', 12 | 'veterans': '11 Nov', 13 | 'christmas': '25 Dec'} 14 | 15 | fed_holidays_floating = { 16 | 'mlk': (1, 1, 3), 17 | 'presidents': (2, 1, 3), 18 | 'memorial': (5, 1, -1), 19 | 'labor': (9, 1, 1), 20 | 'columbus': (10, 1, 2), 21 | 'thanksgiving': (11, 4, 4), 22 | 'black_friday': (11, 4, 4, 1) 23 | } 24 | 25 | if do_not_observe is None: 26 | do_not_observe = set() 27 | else: 28 | do_not_observe = set(do_not_observe) 29 | years = range(start_year, end_year + 1) 30 | days = [day for holiday, day in fed_holidays_fixed.items() 31 | if holiday not in do_not_observe] 32 | 33 | amendments = {"{} {}".format(day, year): label 34 | for day, year in product(days, years)} 35 | if long_weekends: 36 | amendments = extend_weekends(amendments, how='nearest') 37 | 38 | floating_dates_to_seek = [date_tuple for holiday, date_tuple 39 | in fed_holidays_floating.items() 40 | if holiday not in do_not_observe] 41 | for year in years: 42 | amendments.update( 43 | nth_weekday_of_month(year, floating_dates_to_seek, label)) 44 | 45 | if 2014 in years and 'xmas_additional_day' not in do_not_observe: 46 | amendments[get_timestamp('26 Dec 2018')] = label 47 | if 2018 in years and 'one_off' not in do_not_observe: 48 | amendments[get_timestamp('05 Dec 2018')] = label 49 | if 2018 in years and 'xmas_additional_day' not in do_not_observe: 50 | amendments[get_timestamp('24 Dec 2018')] = label 51 | 52 | return amendments 53 | 54 | 55 | class Weekly8x5(CalendarBase): 56 | """US business calendar for 5 days x 8 hours working week. 57 | 58 | The calendar takes into account the federal holidays. The Black 59 | Friday is also considered a holiday. Selected holidays can 60 | be ignored by adding them to `exclusions`. 61 | 62 | Workshifts are calendar days. Workshift labels are the number of working 63 | hours per day: 0 for days off, 8 for business days. 64 | 65 | Parameters 66 | ---------- 67 | custom_start : `Timestamp`-like, optional 68 | Change the first date of the calendar. This date must be within the 69 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 70 | By default, the calendar starts on the date defined by 'start' 71 | element of :py:meth:`Weekly8x5.parameters()`. 72 | custom_end : `Timestamp`-like, optional 73 | Change the last date of the calendar. This date must be within the 74 | default calendar range returned by :py:meth:`Weekly8x5.parameters()`. 75 | By default, the calendar ends on the date defined by 'end' 76 | element of :py:meth:`Weekly8x5.parameters()`. 77 | do_not_amend : bool, optional (default False) 78 | If set to True, the calendar is created without any amendments, 79 | meaning that effects of holiday observations are not accounted for. 80 | only_custom_amendments : bool, optional (default False) 81 | If set to True, only amendments from `custom_amendments` are applied 82 | to the calendar. 83 | custom_amendments : dict-like 84 | The alternative amendments if `only_custom_amendments` is true. 85 | Otherwise, `custom_amendments` are used to update pre-configured 86 | amendments (add missing or override existing amendments). 87 | do_not_observe : set, optional 88 | Holidays to be ignored. The following values are accepted into 89 | the set: ``'new_year'``, ``'mlk'`` for Martin Luther King Jr. Day, 90 | ``'presidents'``, ``'memorial'``, ``'independence'``, ``'labor'``, 91 | ``'columbus'``, ``'veterans'``, 92 | ``'thanksgiving'``, ``'black_friday'``, ``'christmas'``, 93 | ``'xmas_additional_day'`` for Christmas Eve or the day after Christmas 94 | in certain years only, ``'one_off'`` for one-off events such as State 95 | funeral of George W. Bush senior in 05 Dec 2018. 96 | long_weekends : bool, optional (default True) 97 | If false, do not extend weekends if a holiday falls on Saturday or 98 | Sunday. 99 | 100 | Raises 101 | ------ 102 | OutOfBoundsError 103 | If `custom_start` or `custom_end` fall outside the calendar range 104 | returned by :py:meth:`Weekly8x5.parameters()` 105 | 106 | Returns 107 | ------- 108 | :py:class:`.Timeboard` 109 | 110 | Methods 111 | ------- 112 | parameters() : dict 113 | This class method returns a dictionary of :py:class:`.Timeboard` 114 | parameters used for building the calendar. 115 | 116 | Examples 117 | -------- 118 | >>> import timeboard.calendars.US as US 119 | 120 | Create an official business calendar for the available range of dates: 121 | 122 | >>> clnd = US.Weekly8x5() 123 | 124 | Create a 2010-2017 business calendar with Black Friday a working day: 125 | 126 | >>> clnd = US.Weekly8x5(custom_start='01 Jan 2010', 127 | ... custom_end='31 Dec 2017', 128 | ... do_not_observe = {'black_friday'}) 129 | 130 | 131 | Inspect the default calendar range: 132 | 133 | >>> params = US.Weekly8x5.parameters() 134 | >>> params['start'] 135 | Timestamp('2000-01-01 00:00:00') 136 | >>> params['end'] 137 | Timestamp('2020-12-31 00:00:00') 138 | 139 | """ 140 | 141 | @classmethod 142 | def parameters(cls): 143 | return { 144 | 'base_unit_freq': 'D', 145 | 'start': get_timestamp('01 Jan 2000'), 146 | 'end': get_timestamp('31 Dec 2020 23:59:59'), 147 | 'layout': Organizer(marker='W', structure=[[8, 8, 8, 8, 8, 0, 0]]), 148 | 'worktime_source': 'labels', 149 | } 150 | 151 | @classmethod 152 | def amendments(cls, custom_start=None, custom_end=None, 153 | custom_amendments=None, do_not_observe=None, 154 | long_weekends=True): 155 | 156 | start, end = cls._get_bounds(custom_start, custom_end) 157 | 158 | result = fed_holidays(start.year, end.year, do_not_observe, 159 | long_weekends) 160 | if custom_amendments is not None: 161 | freq = cls.parameters()['base_unit_freq'] 162 | result.update( 163 | {get_period(k, freq=freq).start_time: v 164 | for k, v in custom_amendments.items()} 165 | ) 166 | 167 | return result 168 | -------------------------------------------------------------------------------- /timeboard/calendars/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/timeboard/calendars/__init__.py -------------------------------------------------------------------------------- /timeboard/calendars/calendarbase.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from ..exceptions import OutOfBoundsError 3 | from ..core import get_timestamp 4 | from ..timeboard import Timeboard 5 | from pandas import period_range 6 | import datetime 7 | from dateutil.easter import easter 8 | 9 | 10 | def nth_weekday_of_month(year, dates_to_seek, label=0, errors='ignore'): 11 | """Calculate holiday dates anchored to n-th weekday of a month. 12 | 13 | This function is to be used to build a (part of) `amendments` dictionary 14 | for a day-based calendar. 15 | 16 | Parameters 17 | ---------- 18 | year : int 19 | dates_to_seek : iterable of tuples (month, weekday, n, [shift]) 20 | month : int 1..12 (January is 1) 21 | weekday : int 1..7 (Monday is 1) 22 | n : int -5..5 (n=1 the for first weekday in the month, n=-1 for the 23 | last. n=0 is not allowed) 24 | shift: int, optional - number of days to add to the found weekday, 25 | may be negative (default 0). 26 | label : optional (default 0) 27 | errors : {'ignore', 'raise'}, optional (default 'ignore') 28 | What to do if the requested date does not exist (i.e. there are less 29 | than n such weekdays in the month). 30 | 31 | Raises 32 | ------ 33 | OutOfBoundsError 34 | If the requested date does not exist and `errors='raise'` 35 | 36 | Returns 37 | ------- 38 | dict 39 | Dictionary suitable for `amendments` parameter of Timeboard with 40 | `base_unit_freq='D'`. The keys are timestamps. The values of the 41 | dictionary are set to the value of `label`. 42 | 43 | Example 44 | ------- 45 | Create amendments for US Memorial Day and Labor Day in 2017 46 | 47 | >>> nth_weekday_of_month(2017, [(5, 1, -1), (9, 1, 1)]) 48 | {Timestamp('2017-05-29 00:00:00'): 0, Timestamp('2017-09-04 00:00:00'): 0} 49 | """ 50 | months = period_range(start=datetime.date(year, 1, 1), 51 | end=datetime.date(year, 12, 31), freq='M') 52 | weekday_freq = {1: 'W-SUN', 2: 'W-MON', 3: 'W-TUE', 4: 'W-WED', 53 | 5: 'W-THU', 6: 'W-FRI', 7: 'W-SAT'} 54 | amendments = {} 55 | for date_tuple in dates_to_seek: 56 | month, weekday, n = date_tuple[0:3] 57 | assert month in range(1, 13) 58 | assert weekday in range(1, 8) 59 | assert n in range(1, 6) or n in range(-5, 0) 60 | try: 61 | shift = date_tuple[3] 62 | except IndexError: 63 | shift = 0 64 | if n > 0: 65 | n -= 1 66 | weeks = period_range(start=months[month - 1].start_time, 67 | end=months[month - 1].end_time, 68 | freq=weekday_freq[weekday]) 69 | if weeks[0].start_time < months[month - 1].start_time: 70 | weeks = weeks[1:] 71 | try: 72 | week_starting_on_our_day = weeks[n] 73 | except IndexError: 74 | if errors == 'raise': 75 | raise OutOfBoundsError("There is no valid date for " 76 | "year={}, month-{}, weekday={}, n={}.". 77 | format(year, month, weekday, n)) 78 | else: 79 | amendments[week_starting_on_our_day.start_time + 80 | datetime.timedelta(days=shift)] = label 81 | 82 | return amendments 83 | 84 | 85 | def extend_weekends(amendments, how='nearest', label=None, weekend=None): 86 | """Make a weekday a day off if a holiday falls on the weekend. 87 | 88 | This function is to be used to update a (part of) `amendments` dictionary 89 | for a day-based calendar. 90 | 91 | Parameters 92 | ---------- 93 | amendments: dict 94 | how : {'previous', 'next', 'nearest'}, optional (default 'nearest') 95 | Which weekday to make a day off: a weekday preceding the weekend, 96 | a weekday following the weekend, or a weekday nearest to the holiday. 97 | If there is a tie with 'nearest', it works the same way as 'next'. 98 | label : optional (default None) 99 | weekend: list, optional 100 | Weekdays constituting weekend, Monday is 0, Sunday is 6. Days must be 101 | consecutive: i.e. if weekend is on Sunday and Monday, weekend=[6,0], 102 | NOT weekend=[0,6]. By default weekend is Saturday and Sunday. 103 | 104 | Returns 105 | ------- 106 | dict 107 | Updated `amendments` dictionary with new days off, if any. The keys 108 | are timestamps (both for the old and the new elements.) The values 109 | of the newly added days are set to the value of `label` if given; 110 | otherwise, the label of the corresponding holiday is used. 111 | 112 | Notes 113 | ----- 114 | If a weekday has been already declared a day off because of another holiday 115 | taking place on the same weekend or it is a holiday by itself, the next 116 | weekday in the direction specified by `how` parameter will be made 117 | a day off. 118 | """ 119 | assert how in ['previous', 'next', 'nearest'] 120 | if weekend is None: 121 | weekend = [5, 6] # Saturday and Sunday 122 | amendments = {get_timestamp(k): v for k, v in amendments.items()} 123 | for holiday in sorted(amendments.keys()): 124 | day_of_week = holiday.weekday() 125 | try: 126 | loc_in_wend = weekend.index(day_of_week) 127 | except ValueError: 128 | continue 129 | if label is None: 130 | _label = amendments[holiday] 131 | else: 132 | _label = label 133 | if how == 'previous': 134 | first_step = -(loc_in_wend+1) 135 | step = -1 136 | elif how == 'next': 137 | first_step = len(weekend) - loc_in_wend 138 | step = 1 139 | elif how == 'nearest': 140 | if loc_in_wend < len(weekend) // 2: 141 | first_step = -(loc_in_wend + 1) 142 | step = -1 143 | else: 144 | first_step = len(weekend) - loc_in_wend 145 | step = 1 146 | new_day = holiday + datetime.timedelta(days=first_step) 147 | while new_day in amendments: 148 | new_day += datetime.timedelta(days=step) 149 | amendments[new_day] = _label 150 | 151 | return amendments 152 | 153 | 154 | def from_easter(year, shifts, easter_type='western', label=0): 155 | """Calculate holiday dates anchored to Easter. 156 | 157 | This function is to be used to build a (part of) `amendments` dictionary 158 | for a day-based calendar. 159 | 160 | Parameters 161 | ---------- 162 | year : int 163 | shifts : iterable of int 164 | List of holidays whose dates are to be found. The holidays are 165 | denominated in their distance from Easter. For example, 166 | -2 is Good Friday, 1 is Monday after Easter, 0 is Easter itself. 167 | easter_type: {'western', 'orthodox'}, optional (default 'western') 168 | label : optional (default 0) 169 | 170 | Returns 171 | ------- 172 | dict 173 | Dictionary suitable for `amendments` parameter of Timeboard with 174 | `base_unit_freq='D'`. The keys are timestamps. The values of the 175 | dictionary are set to the value of `label`. 176 | 177 | Example 178 | ------- 179 | Create amendments for Good Friday and Easter Monday in 2017: 180 | 181 | >>> from_easter(2017, [-2, 1]) 182 | {Timestamp('2017-04-14 00:00:00'): 0, Timestamp('2017-04-17 00:00:00'): 0} 183 | """ 184 | assert easter_type in ['western', 'orthodox'] 185 | if easter_type == 'western': 186 | _easter_type = 3 187 | elif easter_type == 'orthodox': 188 | _easter_type = 2 189 | easter_date = easter(year, _easter_type) 190 | amendments = { 191 | get_timestamp(easter_date + datetime.timedelta(days=shift)): label 192 | for shift in shifts} 193 | return amendments 194 | 195 | 196 | class CalendarBase(object): 197 | """Template for pre-configured calendars. 198 | 199 | To prepare a ready-to-use customized calendar subclass CalendarBase and 200 | override `parameters` and `amendments` class methods. 201 | 202 | `parameters` class method must return a dictionary of parameters to 203 | instantiate a Timeboard except `amendments`. 204 | 205 | `amendments` class method must return a dictionary suitable for 206 | `amendments` parameter of a Timeboard. 207 | 208 | Parameters 209 | ---------- 210 | custom_start : `Timestamp`-like, optional 211 | Change the first date of the calendar. This date must be within 212 | the default calendar range returned by `parameters()` class method. 213 | By default, the calendar starts on the date defined by 'start' 214 | element of `parameters()`. 215 | custom_end : `Timestamp`-like, optional 216 | Change the last date of the calendar. This date must be within 217 | the default calendar range returned by `parameters()` class method. 218 | By default, the calendar ends on the date defined by 'end' 219 | element of `parameters()`. 220 | do_not_amend : bool, optional (default False) 221 | If set to True, the calendar is created without any amendments, 222 | meaning that effects of holiday observations are not accounted for. 223 | only_custom_amendments : bool, optional (default False) 224 | If set to True, only amendments from `custom_amendments` are applied 225 | to the calendar. 226 | custom_amendments : dict-like 227 | Additional or alternative amendments (depends on 228 | `only_custom_amendments` flag). 229 | **additional_kwargs : optional 230 | Additional parameters which are passed to your `amendments` 231 | method. 232 | 233 | Notes 234 | ----- 235 | If both `do_not_amend` and `only_custom_amendments` are false (the 236 | default setting), `amendments` class method is called to receive 237 | amendments for the timeboard. The `custom_start`, `custom_end`, 238 | `custom_amendments` parameters are passed to `amendments` method along 239 | with `**additional_kwargs`. The use of this parameters is up to your 240 | implementation. 241 | 242 | The `custom_start` and `custom_end`, if specified, are used as 243 | timeboard `start` and `end` parameters instead of the namesake 244 | elements of `parameters`. 245 | 246 | Raises 247 | ------ 248 | OutOfBoundsError 249 | If `custom_start` or `custom_end` fall outside the calendar span 250 | set by `parameters()` class method. 251 | 252 | Returns 253 | ------- 254 | :py:class:`.Timeboard` 255 | 256 | Examples 257 | -------- 258 | :: 259 | class MyCalendar(CalendarBase): 260 | @classmethod 261 | def parameters(cls): 262 | return { 263 | 'base_unit_freq': ? , 264 | 'start': ? , 265 | 'end': ? , 266 | 'layout': ? 267 | ... 268 | } 269 | 270 | @classmethod 271 | def amendments(cls, custom_start=None, custom_end=None, 272 | custom_amendments=None, your_parameter=?): 273 | calculate_amendments 274 | return dict 275 | 276 | Inspect calendar parameters: 277 | 278 | >>> parameters_dict = MyCalendar.parameters() 279 | 280 | Inspect calendar amendments 281 | 282 | >>> amendments_dict = MyCalendar.amendments(**kwargs) 283 | 284 | Create a timeboard with your calendar: 285 | 286 | >>> clnd = MyCalendar(**kwargs) 287 | """ 288 | @classmethod 289 | def parameters(cls): 290 | return { 291 | 'base_unit_freq': 'D', 292 | 'start': get_timestamp('01 Jan 2001'), 293 | 'end': get_timestamp('31 Dec 2030'), 294 | 'layout': [1] 295 | } 296 | 297 | @classmethod 298 | def amendments(cls, custom_start=None, custom_end=None, 299 | custom_amendments=None, **additional_kwargs): 300 | if custom_amendments is None: 301 | custom_amendments = {} 302 | return custom_amendments 303 | 304 | @classmethod 305 | def _check_time(cls, t): 306 | if not cls.parameters()['start'] <= t <= cls.parameters()['end']: 307 | raise OutOfBoundsError("Point in time '{}' is outside calendar {}" 308 | .format(t, cls)) 309 | 310 | @classmethod 311 | def _get_bounds(cls, custom_start=None, custom_end=None): 312 | if custom_start is None: 313 | start = get_timestamp(cls.parameters()['start']) 314 | else: 315 | start = get_timestamp(custom_start) 316 | cls._check_time(start) 317 | if custom_end is None: 318 | end = get_timestamp(cls.parameters()['end']) 319 | else: 320 | end = get_timestamp(custom_end) 321 | cls._check_time(end) 322 | return start, end 323 | 324 | def __new__(cls, custom_start=None, custom_end=None, 325 | do_not_amend=False, only_custom_amendments=False, 326 | custom_amendments=None, **additional_kwargs): 327 | parameters = cls.parameters() 328 | parameters['start'], parameters['end'] = cls._get_bounds( 329 | custom_start, custom_end) 330 | if do_not_amend: 331 | amendments = {} 332 | elif only_custom_amendments: 333 | amendments = custom_amendments 334 | else: 335 | amendments = cls.amendments(custom_start=parameters['start'], 336 | custom_end=parameters['end'], 337 | custom_amendments=custom_amendments, 338 | **additional_kwargs) 339 | 340 | return Timeboard(amendments=amendments, **parameters) 341 | -------------------------------------------------------------------------------- /timeboard/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfBoundsError(LookupError): 2 | """Raise on an attempt to create or access an object which is outside 3 | the bounds of the timeboard or the interval.""" 4 | pass 5 | 6 | 7 | class PartialOutOfBoundsError(ValueError): 8 | """Raise on an attempt to construct an object which partially lays within 9 | the timeboard but extends beyond the timeboard's bounds.""" 10 | pass 11 | 12 | 13 | class VoidIntervalError(ValueError): 14 | """Raise on an attempt to create an empty interval. This includes the case 15 | creating an interval from a calendar period that is too short to contain 16 | a workshift.""" 17 | pass 18 | 19 | 20 | class UnacceptablePeriodError(ValueError): 21 | """Raise on an attempt to pass an unsupported or unacceptable calendar 22 | frequency.""" 23 | pass 24 | 25 | -------------------------------------------------------------------------------- /timeboard/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/timeboard/tests/__init__.py -------------------------------------------------------------------------------- /timeboard/tests/calendars/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmamaev/timeboard/2b042381c51f8857167d7a52a14e2a8410b8278c/timeboard/tests/calendars/__init__.py -------------------------------------------------------------------------------- /timeboard/tests/calendars/test_calendars_generic.py: -------------------------------------------------------------------------------- 1 | from timeboard.calendars.calendarbase import ( 2 | CalendarBase, nth_weekday_of_month, extend_weekends, from_easter) 3 | from timeboard.exceptions import OutOfBoundsError 4 | from timeboard.core import get_period, get_timestamp 5 | from pandas import Timedelta 6 | import pytest 7 | 8 | 9 | class TestCalendarUtils(object): 10 | 11 | def test_nth_weekday_positive(self): 12 | date_tuples_2017={ 13 | (12, 5, 1): get_timestamp('01 Dec 2017'), 14 | (12, 7, 1): get_timestamp('03 Dec 2017'), 15 | (12, 3, 1): get_timestamp('06 Dec 2017'), 16 | (12, 3, 4): get_timestamp('27 Dec 2017'), 17 | (12, 3, 5): None, 18 | (12, 5, 5): get_timestamp('29 Dec 2017'), 19 | (12, 7, 5): get_timestamp('31 Dec 2017'), 20 | (5, 1, 1): get_timestamp('01 May 2017'), 21 | (5, 1, 5): get_timestamp('29 May 2017'), 22 | } 23 | result = nth_weekday_of_month(2017, date_tuples_2017.keys(), label=5) 24 | valid_dates = [x for x in date_tuples_2017.values() if x is not None] 25 | assert sorted(result.keys()) == sorted(valid_dates) 26 | assert list(result.values()).count(5) == len(result) 27 | 28 | def test_nth_weekday_negative(self): 29 | 30 | date_tuples_2017 = { 31 | (12, 3, -1): get_timestamp('27 Dec 2017'), 32 | (12, 3, -4): get_timestamp('06 Dec 2017'), 33 | (12, 3, -5): None, 34 | (12, 5, -1): get_timestamp('29 Dec 2017'), 35 | (12, 7, -1): get_timestamp('31 Dec 2017'), 36 | (5, 1, -1): get_timestamp('29 May 2017'), 37 | (5, 1, -5): get_timestamp('01 May 2017'), 38 | } 39 | result = nth_weekday_of_month(2017, date_tuples_2017.keys(), label=5) 40 | valid_dates = [x for x in date_tuples_2017.values() if x is not None] 41 | assert sorted(result.keys()) == sorted(valid_dates) 42 | assert list(result.values()).count(5) == len(result) 43 | 44 | def test_nth_weekday_shift(self): 45 | date_tuples_2017={ 46 | (12, 5, 1, 0): get_timestamp('01 Dec 2017'), 47 | (12, 5, 1, 2): get_timestamp('03 Dec 2017'), 48 | (12, 5, 1, -2): get_timestamp('29 Nov 2017') 49 | } 50 | result = nth_weekday_of_month(2017, date_tuples_2017.keys()) 51 | valid_dates = [x for x in date_tuples_2017.values() if x is not None] 52 | assert sorted(result.keys()) == sorted(valid_dates) 53 | assert list(result.values()).count(0) == len(result) 54 | 55 | def test_nth_weekday_bad_n(self): 56 | with pytest.raises(OutOfBoundsError): 57 | nth_weekday_of_month(2017, [(12, 3, 5)], errors='raise') 58 | with pytest.raises(OutOfBoundsError): 59 | nth_weekday_of_month(2017, [(12, 3, -5)], errors='raise') 60 | with pytest.raises(AssertionError): 61 | nth_weekday_of_month(2017, [(5, 1, -6)]) 62 | with pytest.raises(AssertionError): 63 | nth_weekday_of_month(2017, [(5, 1, 6)]) 64 | with pytest.raises(AssertionError): 65 | nth_weekday_of_month(2017, [(5, 1, 0)]) 66 | 67 | def test_extend_weekend_saturday(self): 68 | amds = {'16 Dec 2017': 5} 69 | result = extend_weekends(amds, how='nearest') 70 | assert result == {get_timestamp('16 Dec 2017'): 5, 71 | get_timestamp('15 Dec 2017'): 5} 72 | result = extend_weekends(amds, how='previous') 73 | assert result == {get_timestamp('16 Dec 2017'): 5, 74 | get_timestamp('15 Dec 2017'): 5} 75 | result = extend_weekends(amds, how='next') 76 | assert result == {get_timestamp('16 Dec 2017'): 5, 77 | get_timestamp('18 Dec 2017'): 5} 78 | 79 | def test_extend_weekend_sunday(self): 80 | amds = {'17 Dec 2017': 5} 81 | result = extend_weekends(amds, how='nearest') 82 | assert result == {get_timestamp('17 Dec 2017'): 5, 83 | get_timestamp('18 Dec 2017'): 5} 84 | result = extend_weekends(amds, how='previous') 85 | assert result == {get_timestamp('17 Dec 2017'): 5, 86 | get_timestamp('15 Dec 2017'): 5} 87 | result = extend_weekends(amds, how='next') 88 | assert result == {get_timestamp('17 Dec 2017'): 5, 89 | get_timestamp('18 Dec 2017'): 5} 90 | 91 | def test_extend_weekend_new_label(self): 92 | amds = {'17 Dec 2017': 5} 93 | result = extend_weekends(amds, how='nearest', label='a') 94 | assert result == {get_timestamp('17 Dec 2017'): 5, 95 | get_timestamp('18 Dec 2017'): 'a'} 96 | 97 | def test_extend_weekend_weekday(self): 98 | amds = {'15 Dec 2017': 5} 99 | result = extend_weekends(amds, how='nearest') 100 | assert result == {get_timestamp('15 Dec 2017'): 5} 101 | 102 | def test_extend_weekend_weekday_already_off1(self): 103 | amds = {'17 Dec 2017': 5, '18 Dec 2017': 5} 104 | result = extend_weekends(amds, how='nearest') 105 | assert result == {get_timestamp('17 Dec 2017'): 5, 106 | get_timestamp('18 Dec 2017'): 5, 107 | get_timestamp('19 Dec 2017'): 5 108 | } 109 | 110 | def test_extend_weekend_weekday_already_off2(self): 111 | amds = {'17 Dec 2017': 5, '15 Dec 2017': 5} 112 | result = extend_weekends(amds, how='previous') 113 | assert result == {get_timestamp('14 Dec 2017'): 5, 114 | get_timestamp('15 Dec 2017'): 5, 115 | get_timestamp('17 Dec 2017'): 5 116 | } 117 | 118 | def test_extend_weekend_two_holidays_same_labels(self): 119 | amds = {'16 Dec 2017': 5, '17 Dec 2017': 5} 120 | result = extend_weekends(amds, how='nearest') 121 | assert result == {get_timestamp('15 Dec 2017'): 5, 122 | get_timestamp('16 Dec 2017'): 5, 123 | get_timestamp('17 Dec 2017'): 5, 124 | get_timestamp('18 Dec 2017'): 5 125 | } 126 | result = extend_weekends(amds, how='previous') 127 | assert result == {get_timestamp('14 Dec 2017'): 5, 128 | get_timestamp('15 Dec 2017'): 5, 129 | get_timestamp('16 Dec 2017'): 5, 130 | get_timestamp('17 Dec 2017'): 5, 131 | } 132 | result = extend_weekends(amds, how='next') 133 | assert result == {get_timestamp('16 Dec 2017'): 5, 134 | get_timestamp('17 Dec 2017'): 5, 135 | get_timestamp('18 Dec 2017'): 5, 136 | get_timestamp('19 Dec 2017'): 5 137 | } 138 | 139 | def test_extend_weekend_two_holidays_new_label(self): 140 | amds = {'16 Dec 2017': 5, '17 Dec 2017': 5} 141 | result = extend_weekends(amds, how='nearest', label=0) 142 | assert result == {get_timestamp('15 Dec 2017'): 0, 143 | get_timestamp('16 Dec 2017'): 5, 144 | get_timestamp('17 Dec 2017'): 5, 145 | get_timestamp('18 Dec 2017'): 0} 146 | result = extend_weekends(amds, how='previous', label=0) 147 | assert result == {get_timestamp('14 Dec 2017'): 0, 148 | get_timestamp('15 Dec 2017'): 0, 149 | get_timestamp('16 Dec 2017'): 5, 150 | get_timestamp('17 Dec 2017'): 5, 151 | } 152 | result = extend_weekends(amds, how='next', label=0) 153 | assert result == {get_timestamp('16 Dec 2017'): 5, 154 | get_timestamp('17 Dec 2017'): 5, 155 | get_timestamp('18 Dec 2017'): 0, 156 | get_timestamp('19 Dec 2017'): 0} 157 | 158 | def test_extend_weekend_two_holidays_diff_labels(self): 159 | amds = {'16 Dec 2017': 3, '17 Dec 2017': 5} 160 | result = extend_weekends(amds, how='nearest') 161 | assert result == {get_timestamp('15 Dec 2017'): 3, 162 | get_timestamp('16 Dec 2017'): 3, 163 | get_timestamp('17 Dec 2017'): 5, 164 | get_timestamp('18 Dec 2017'): 5} 165 | result = extend_weekends(amds, how='previous') 166 | assert result == {get_timestamp('14 Dec 2017'): 5, 167 | get_timestamp('15 Dec 2017'): 3, 168 | get_timestamp('16 Dec 2017'): 3, 169 | get_timestamp('17 Dec 2017'): 5, 170 | } 171 | result = extend_weekends(amds, how='next') 172 | assert result == {get_timestamp('16 Dec 2017'): 3, 173 | get_timestamp('17 Dec 2017'): 5, 174 | get_timestamp('18 Dec 2017'): 3, 175 | get_timestamp('19 Dec 2017'): 5} 176 | 177 | def test_extend_weekend_strange_weekend(self): 178 | amds = {'16 Dec 2017': 5} 179 | result = extend_weekends(amds, how='nearest', weekend=[5]) 180 | # if there is a tie, 'nearest' == 'next' 181 | assert result == {get_timestamp('16 Dec 2017'): 5, 182 | get_timestamp('17 Dec 2017'): 5} 183 | result = extend_weekends(amds, how='next', weekend=[5, 6, 0]) 184 | assert result == {get_timestamp('16 Dec 2017'): 5, 185 | get_timestamp('19 Dec 2017'): 5} 186 | result = extend_weekends(amds, how='previous', weekend=[3, 4, 5]) 187 | assert result == {get_timestamp('13 Dec 2017'): 5, 188 | get_timestamp('16 Dec 2017'): 5} 189 | result = extend_weekends(amds, weekend=[]) 190 | assert result == {get_timestamp('16 Dec 2017'): 5} 191 | 192 | def test_from_easter(self): 193 | assert from_easter(2017, [0, 1, -2]) == { 194 | get_timestamp('16 Apr 2017'): 0, 195 | get_timestamp('17 Apr 2017'): 0, 196 | get_timestamp('14 Apr 2017'): 0} 197 | 198 | assert from_easter(2017, [1], easter_type='orthodox', label=5) == { 199 | get_timestamp('17 Apr 2017'): 5} 200 | assert from_easter(2000, [0]) == {get_timestamp('23 Apr 2000'): 0} 201 | assert from_easter(2000, [0], 202 | easter_type='orthodox') == {get_timestamp('30 Apr 2000'): 0} 203 | assert from_easter(2000, []) == {} 204 | 205 | 206 | class TestCalendarBase(object): 207 | def test_calendar_base(self): 208 | assert CalendarBase.amendments() == {} 209 | start = CalendarBase.parameters()['start'] 210 | end = CalendarBase.parameters()['end'] 211 | freq = CalendarBase.parameters()['base_unit_freq'] 212 | clnd = CalendarBase() 213 | assert clnd.start_time == get_period(start, freq=freq).start_time 214 | assert clnd.end_time == get_period(end, freq=freq).end_time 215 | delta = end - start 216 | assert clnd.get_interval().count() == delta.components.days + 1 217 | 218 | def test_calendar_base_custom_limits(self): 219 | clnd = CalendarBase(custom_start='01 Jan 2017', 220 | custom_end='31 Dec 2018') 221 | assert clnd.get_interval().count() == 365*2 222 | 223 | def test_calendar_base_custom_amds(self): 224 | clnd = CalendarBase(custom_start='01 Jan 2017', 225 | custom_end='31 Dec 2018', 226 | custom_amendments={ 227 | '01 Mar 2017': 0, 228 | '01 Mar 2019': 0} 229 | ) 230 | assert clnd.get_interval().count() == 365 * 2 - 1 231 | 232 | def test_calendat_base_OOB(self): 233 | start = CalendarBase.parameters()['start'] 234 | end = CalendarBase.parameters()['end'] 235 | with pytest.raises(OutOfBoundsError): 236 | CalendarBase(start-Timedelta(days=1)) 237 | with pytest.raises(OutOfBoundsError): 238 | CalendarBase(custom_end=end+Timedelta(days=1)) 239 | -------------------------------------------------------------------------------- /timeboard/tests/calendars/test_calendars_ru_us_uk.py: -------------------------------------------------------------------------------- 1 | from timeboard.exceptions import OutOfBoundsError 2 | from timeboard.core import get_period 3 | import timeboard.calendars.RU as RU 4 | import timeboard.calendars.US as US 5 | import timeboard.calendars.UK as UK 6 | 7 | import datetime 8 | import pytest 9 | 10 | class TestCalendarsRU(object): 11 | 12 | def test_calendar_RU_week8x5(self): 13 | 14 | start = RU.Weekly8x5.parameters()['start'] 15 | end = RU.Weekly8x5.parameters()['end'] 16 | freq = RU.Weekly8x5.parameters()['base_unit_freq'] 17 | clnd = RU.Weekly8x5() 18 | assert clnd.start_time == get_period(start, freq=freq).start_time 19 | assert clnd.end_time == get_period(end, freq=freq).end_time 20 | 21 | bdays_in_year = { 22 | 2005: 248, 2006: 248, 2007: 249, 23 | 2008: 250, 2009: 249, 2010: 249, 2011: 248, 2012: 249, 2013: 247, 24 | 2014: 247, 2015: 247, 2016: 247, 2017: 247, 2018: 247, 2019: 247, 25 | } 26 | for year, bdays in bdays_in_year.items(): 27 | assert clnd.get_interval('01 Jan {}'.format(year), 28 | period='A').count() == bdays 29 | 30 | bhours_in_year = { 31 | 2015: 1971, 2016: 1974, 2017: 1973, 2018: 1970, 2019: 1970, 32 | } 33 | for year, bhours in bhours_in_year.items(): 34 | assert clnd.get_interval('01 Jan {}'.format(year), 35 | period='A').worktime() == bhours 36 | 37 | # a regular week 38 | for day in range(20, 25): 39 | ws = clnd("{} Nov 2017".format(day)) 40 | assert ws.is_on_duty() 41 | assert ws.label == 8 42 | for day in range(25, 27): 43 | ws = clnd("{} Nov 2017".format(day)) 44 | assert ws.is_off_duty() 45 | assert ws.label == 0 46 | assert clnd(('20 Nov 2017', '26 Nov 2017')).worktime() == 40 47 | 48 | # standard holidays 49 | assert clnd('01 Jan 2008').is_off_duty() 50 | assert clnd('23 Feb 2017').is_off_duty() 51 | # week day made a day off 52 | assert clnd('24 Feb 2017').is_off_duty() 53 | # weekend made a working day 54 | assert clnd('05 May 2012').is_on_duty() 55 | # by default 31 Dec is a working day if not a weekend 56 | assert clnd('31 Dec 2015').is_on_duty() 57 | # by default a holiday eve has shorter hours 58 | assert clnd('22 Feb 2017').label == 7 59 | 60 | def test_calendar_RU_week8x5_31dec_off(self): 61 | clnd = RU.Weekly8x5(work_on_dec31=False) 62 | for y in range(2008,2020): 63 | assert clnd("31 Dec {}".format(y)).is_off_duty() 64 | 65 | def test_calendar_RU_week8x5_no_short_eves(self): 66 | clnd = RU.Weekly8x5(short_eves=False) 67 | assert clnd('22 Feb 2017').label == 8 68 | 69 | def test_calendar_RU_week8x5_custom_amds(self): 70 | clnd = RU.Weekly8x5(custom_amendments={'22 Feb 2017 00:00': 0, 71 | '23.02.2017': 8}) 72 | assert clnd('21 Feb 2017').is_on_duty() 73 | assert clnd('22 Feb 2017').is_off_duty() 74 | assert clnd('23 Feb 2017').is_on_duty() 75 | assert clnd('24 Feb 2017').is_off_duty() 76 | assert clnd('01 May 2017').is_off_duty() 77 | 78 | def test_calendar_RU_week8x5_custom_amds_ambiguous(self): 79 | # We already have timestamps of 22.02.17 00:00:00 and 23.02.17 00:00:00 80 | # in amendments dict which is to be update by custom amds. 81 | # Applying the custom amds below would result in ambiguous keys (two 82 | # keys referring to the same day) but we preprocess custom_amedndments 83 | # to align them at the start_time of days in the same manner as 84 | # in amendments dict 85 | clnd = RU.Weekly8x5(custom_amendments={'22 Feb 2017 13:00': 0, 86 | '23 Feb 2017 15:00': 8}) 87 | assert clnd('21 Feb 2017').is_on_duty() 88 | assert clnd('22 Feb 2017').is_off_duty() 89 | assert clnd('23 Feb 2017').is_on_duty() 90 | assert clnd('24 Feb 2017').is_off_duty() 91 | assert clnd('01 May 2017').is_off_duty() 92 | 93 | 94 | def test_calendar_RU_week8x5_only_custom_amds(self): 95 | clnd = RU.Weekly8x5(only_custom_amendments=True, 96 | custom_amendments={'22 Feb 2017': 0, 97 | '23 Feb 2017': 8}) 98 | assert clnd('21 Feb 2017').is_on_duty() 99 | assert clnd('22 Feb 2017').is_off_duty() 100 | assert clnd('23 Feb 2017').is_on_duty() 101 | assert clnd('24 Feb 2017').is_on_duty() 102 | assert clnd('01 May 2017').is_on_duty() 103 | 104 | 105 | def test_calendar_RU_week8x5_no_amds(self): 106 | clnd = RU.Weekly8x5(do_not_amend=True, 107 | only_custom_amendments=True, 108 | custom_amendments={'22 Feb 2017': 0, 109 | '23 Feb 2017': 8} 110 | ) 111 | assert clnd('21 Feb 2017').is_on_duty() 112 | assert clnd('22 Feb 2017').is_on_duty() 113 | assert clnd('23 Feb 2017').is_on_duty() 114 | assert clnd('24 Feb 2017').is_on_duty() 115 | assert clnd('01 May 2017').is_on_duty() 116 | 117 | def test_calendar_RU_week8x5_select_years(self): 118 | 119 | clnd = RU.Weekly8x5('01 Jan 2010', '31 Dec 2015') 120 | assert clnd.start_time == datetime.datetime(2010, 1, 1, 0, 0, 0) 121 | assert clnd.end_time > datetime.datetime(2015, 12, 31, 23, 59, 59) 122 | assert clnd.end_time < datetime.datetime(2016, 1, 1, 0, 0, 0) 123 | # if only year is given, the 1st of Jan is implied at the both ends 124 | clnd = RU.Weekly8x5('2010', '2015') 125 | assert clnd.start_time == datetime.datetime(2010, 1, 1, 0, 0, 0) 126 | assert clnd.end_time > datetime.datetime(2015, 1, 1, 23, 59, 59) 127 | assert clnd.end_time < datetime.datetime(2015, 1, 2, 0, 0, 0) 128 | 129 | def test_calendar_RU_week8x5_OOB(self): 130 | with pytest.raises(OutOfBoundsError): 131 | RU.Weekly8x5('1990') 132 | with pytest.raises(OutOfBoundsError): 133 | RU.Weekly8x5('2008', '31 Dec 2050') 134 | 135 | class TestCalendarsUS(object): 136 | 137 | def test_calendar_US_week8x5(self): 138 | holidays_2011 = [ 139 | '31 Dec 2010', '17 Jan 2011', '21 Feb 2011', '30 May 2011', 140 | '04 Jul 2011', '05 Sep 2011', '10 Oct 2011', '11 Nov 2011', 141 | '24 Nov 2011', '25 Nov 2011', '26 Dec 2011' 142 | # Christmas observance moved forward to Monday 143 | ] 144 | clnd0 = US.Weekly8x5(do_not_amend=True) 145 | assert all([clnd0(d).is_on_duty() for d in holidays_2011]) 146 | 147 | clnd1 = US.Weekly8x5() 148 | #regular week 149 | assert clnd1(('04 Dec 2017', '10 Dec 2017')).worktime() == 40 150 | 151 | assert all([clnd1(d).is_off_duty() for d in holidays_2011]) 152 | assert clnd0.get_interval('2011', period='A').count() == ( 153 | clnd1.get_interval('2011', period='A').count() + 154 | len(holidays_2011) - 1) 155 | 156 | # Observance moved backward to Friday: 157 | assert clnd1('31 Dec 2010').is_off_duty() 158 | 159 | # one-off or irregular holidays 160 | assert clnd1('05 Dec 2018').is_off_duty() 161 | assert clnd1('24 Dec 2018').is_off_duty() 162 | assert clnd1('26 Dec 2018').is_off_duty() 163 | 164 | def test_calendar_US_week8x5_exclusions(self): 165 | holidays_2011 = [ 166 | '31 Dec 2010', '17 Jan 2011', '21 Feb 2011', '30 May 2011', 167 | '04 Jul 2011', '05 Sep 2011', '10 Oct 2011', '11 Nov 2011', 168 | '24 Nov 2011', '25 Nov 2011', '26 Dec 2011' 169 | ] 170 | clnd = US.Weekly8x5(do_not_observe=[ 171 | 'independence', 'christmas', 'black_friday', 172 | 'one_off', 'xmas_additional_day' 173 | ]) 174 | assert clnd('04 Jul 2011').is_on_duty() 175 | assert clnd('25 Nov 2011').is_on_duty() 176 | assert clnd('25 Dec 2011').is_off_duty() 177 | assert clnd('26 Dec 2011').is_on_duty() 178 | assert clnd('24 Dec 2018').is_on_duty() 179 | assert clnd('26 Dec 2018').is_on_duty() 180 | 181 | def test_calendar_US_week8x5_short_weekends(self): 182 | holidays_2011 = [ 183 | '31 Dec 2010', '17 Jan 2011', '21 Feb 2011', '30 May 2011', 184 | '04 Jul 2011', '05 Sep 2011', '10 Oct 2011', '11 Nov 2011', 185 | '24 Nov 2011', '25 Nov 2011', '26 Dec 2011' 186 | ] 187 | clnd = US.Weekly8x5(long_weekends=False) 188 | assert clnd('31 Dec 2010').is_on_duty() 189 | assert clnd('04 Jul 2011').is_off_duty() 190 | assert clnd('25 Dec 2011').is_off_duty() 191 | assert clnd('26 Dec 2011').is_on_duty() 192 | 193 | def test_calendar_US_week8x5_shortened(self): 194 | holidays_2011_shortened = [ 195 | '17 Jan 2011', '21 Feb 2011', '30 May 2011', 196 | '04 Jul 2011', '05 Sep 2011', '10 Oct 2011', '11 Nov 2011', 197 | '24 Nov 2011', '25 Nov 2011' 198 | ] 199 | clnd0 = US.Weekly8x5('01 Jan 2011', '30 Nov 2011', do_not_amend=True) 200 | assert all([clnd0(d).is_on_duty() for d in holidays_2011_shortened]) 201 | clnd1 = US.Weekly8x5('01 Jan 2011', '30 Nov 2011') 202 | assert all([clnd1(d).is_off_duty() for d in holidays_2011_shortened]) 203 | assert clnd0.get_interval().count() == ( 204 | clnd1.get_interval().count() + len(holidays_2011_shortened)) 205 | 206 | 207 | def test_calendar_US_week8x5_custom_amds(self): 208 | holidays_2011 = [ 209 | '31 Dec 2010', '17 Jan 2011', '21 Feb 2011', '30 May 2011', 210 | '04 Jul 2011', '05 Sep 2011', '10 Oct 2011', '11 Nov 2011', 211 | '24 Nov 2011', '25 Nov 2011', '26 Dec 2011' 212 | ] 213 | clnd = US.Weekly8x5(custom_amendments={'18 Jan 2011': 0, 214 | '26 Dec 2011': 8}) 215 | 216 | assert clnd('18 Jan 2011').is_off_duty() 217 | assert clnd('04 Jul 2011').is_off_duty() 218 | assert clnd('25 Dec 2011').is_off_duty() 219 | assert clnd('26 Dec 2011').is_on_duty() 220 | 221 | 222 | class TestCalendarsUK(object): 223 | 224 | @pytest.mark.skip("Is this for a new version of calendar?") 225 | def test_calendar_UK_week8x5_processing(self): 226 | assert UK.Weekly8x5.amendments() is None 227 | clnd = UK.Weekly8x5(do_not_amend=True) 228 | assert len(UK.Weekly8x5.amendments()) == 0 229 | clnd = UK.Weekly8x5(country='northern_ireland') 230 | assert len(UK.Weekly8x5.amendments()) > 0 231 | print(" ") 232 | for d in [d for d in UK.Weekly8x5.amendments() if d.year==2016]: 233 | print(d) 234 | 235 | def test_calendar_UK_week8x5_nirl(self): 236 | holidays_nirl_2016 = [ 237 | '01 Jan 2016', '17 Mar 2016', '25 Mar 2016', '28 Mar 2016', 238 | '02 May 2016', '30 May 2016', '12 Jul 2016', '29 Aug 2016', 239 | '26 Dec 2016', '27 Dec 2016' 240 | ] 241 | clnd0 = UK.Weekly8x5(country='northern_ireland', do_not_amend=True) 242 | assert all([clnd0(d).is_on_duty() for d in holidays_nirl_2016]) 243 | 244 | clnd1 = UK.Weekly8x5(country='northern_ireland') 245 | #regular week 246 | assert clnd1(('04 Dec 2017', '10 Dec 2017')).worktime() == 40 247 | 248 | assert all([clnd1(d).is_off_duty() for d in holidays_nirl_2016]) 249 | assert clnd0.get_interval('2016', period='A').count() == ( 250 | clnd1.get_interval('2016', period='A').count() + 251 | len(holidays_nirl_2016)) 252 | 253 | def test_calendar_UK_week8x5_scot(self): 254 | holidays_scot_2012 = [ 255 | '02 Jan 2012', '03 Jan 2012', '06 Apr 2012', 256 | '07 May 2012', '04 Jun 2012', '05 Jun 2012', '06 Aug 2012', 257 | '30 Nov 2012', '25 Dec 2012', '26 Dec 2012' 258 | ] 259 | clnd0 = UK.Weekly8x5(country='scotland', do_not_amend=True) 260 | assert all([clnd0(d).is_on_duty() for d in holidays_scot_2012]) 261 | clnd1 = UK.Weekly8x5(country='scotland') 262 | assert all([clnd1(d).is_off_duty() for d in holidays_scot_2012]) 263 | assert clnd0.get_interval('2012', period='A').count() == ( 264 | clnd1.get_interval('2012', period='A').count() + 265 | len(holidays_scot_2012)) 266 | 267 | def test_calendar_UK_week8x5_exclusions(self): 268 | holidays_scot_2012 = [ 269 | '02 Jan 2012', '03 Jan 2012', '06 Apr 2012', 270 | '07 May 2012', '04 Jun 2012', '05 Jun 2012', '06 Aug 2012', 271 | '30 Nov 2012', '25 Dec 2012', '26 Dec 2012' 272 | ] 273 | clnd = UK.Weekly8x5(country='scotland', 274 | do_not_observe=['new_year', 'good_friday', 275 | 'royal', 'summer']) 276 | assert clnd('01 Jan 2012').is_off_duty() 277 | assert clnd('03 Jan 2012').is_on_duty() 278 | assert clnd('06 Apr 2012').is_on_duty() 279 | assert clnd('07 May 2012').is_off_duty() 280 | assert clnd('28 May 2012').is_on_duty() 281 | assert clnd('04 Jun 2012').is_on_duty() 282 | assert clnd('05 Jun 2012').is_on_duty() 283 | assert clnd('06 Aug 2012').is_on_duty() 284 | assert clnd('30 Nov 2012').is_off_duty() 285 | 286 | def test_calendar_UK_week8x5_short_weekends(self): 287 | clnd = UK.Weekly8x5(country='northern_ireland', long_weekends=False) 288 | assert clnd('28 Dec 2015').is_on_duty() 289 | assert clnd('13 Jul 2015').is_on_duty() 290 | assert clnd('27 Dec 2016').is_on_duty() 291 | clnd = UK.Weekly8x5(country='scotland', long_weekends=False) 292 | assert clnd('03 Jan 2012').is_on_duty() 293 | 294 | def test_calendar_UK_week8x5_shortened(self): 295 | 296 | holidays_nirl_2016_shortened = [ 297 | '01 Jan 2016', '17 Mar 2016', '25 Mar 2016', '28 Mar 2016', 298 | '02 May 2016', '30 May 2016', '12 Jul 2016', '29 Aug 2016', 299 | ] 300 | clnd0 = UK.Weekly8x5('01 Jan 2016', '30 Nov 2016', 301 | country='northern_ireland', do_not_amend=True) 302 | assert all([clnd0(d).is_on_duty() for d in holidays_nirl_2016_shortened]) 303 | clnd1 = UK.Weekly8x5('01 Jan 2016', '30 Nov 2016', 304 | country='northern_ireland') 305 | assert all([clnd1(d).is_off_duty() for d in holidays_nirl_2016_shortened]) 306 | assert clnd0.get_interval().count() == ( 307 | clnd1.get_interval().count() + 308 | len(holidays_nirl_2016_shortened)) 309 | 310 | def test_calendar_UK_week8x5_custom_amds(self): 311 | holidays_scot_2012 = [ 312 | '02 Jan 2012', '03 Jan 2012', '06 Apr 2012', 313 | '07 May 2012', '04 Jun 2012', '05 Jun 2012', '06 Aug 2012', 314 | '30 Nov 2012', '25 Dec 2012', '26 Dec 2012' 315 | ] 316 | clnd = UK.Weekly8x5(country='scotland', 317 | custom_amendments={'18 Jan 2012': 0, 318 | '26 Dec 2012': 8}) 319 | 320 | assert clnd('18 Jan 2012').is_off_duty() 321 | assert clnd('04 Jun 2012').is_off_duty() 322 | assert clnd('25 Dec 2012').is_off_duty() 323 | assert clnd('26 Dec 2012').is_on_duty() 324 | -------------------------------------------------------------------------------- /timeboard/tests/test_frames.py: -------------------------------------------------------------------------------- 1 | #from timeboard.timeboard import Timeboard 2 | from timeboard.core import _Frame, _Span 3 | from timeboard.exceptions import (OutOfBoundsError, 4 | VoidIntervalError, 5 | UnacceptablePeriodError) 6 | import pandas as pd 7 | import datetime 8 | import pytest 9 | 10 | 11 | class TestFrameConstructor(object) : 12 | 13 | def test_frame_constructor_days(self): 14 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='01 Mar 2017') 15 | assert len(f)==60 16 | assert f[0] == pd.Period('01 Jan 2017', freq='D') 17 | assert f[59] == pd.Period('01 Mar 2017', freq='D') 18 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 19 | assert f.end_time >= pd.Timestamp('01 Mar 2017 23:59:59.999999999') 20 | assert f.end_time <= pd.Timestamp('02 Mar 2017 00:00:00') 21 | assert f.is_monotonic 22 | 23 | def test_frame_constructor_end_same_as_start(self): 24 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='01 Jan 2017') 25 | assert len(f)==1 26 | assert f[0] == pd.Period('01 Jan 2017', freq='D') 27 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 28 | assert f.end_time >= pd.Timestamp('01 Jan 2017 23:59:59.999999999') 29 | assert f.end_time <= pd.Timestamp('02 Jan 2017 00:00:00') 30 | assert f.is_monotonic 31 | 32 | def test_frame_constructor_same_base_unit(self): 33 | f = _Frame(base_unit_freq='D', start='01 Jan 2017 08:00', end='01 Jan 2017 09:00') 34 | assert len(f)==1 35 | assert f[0] == pd.Period('01 Jan 2017', freq='D') 36 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 37 | assert f.end_time >= pd.Timestamp('01 Jan 2017 23:59:59.999999999') 38 | assert f.end_time <= pd.Timestamp('02 Jan 2017 00:00:00') 39 | assert f.is_monotonic 40 | 41 | def test_frame_constructor_multiple_days(self): 42 | f = _Frame(base_unit_freq='4D', start='01 Jan 2017', end='08 Jan 2017') 43 | assert len(f) == 2 44 | assert f[0] == pd.Period('01 Jan 2017', freq='4D') 45 | assert f[1] == pd.Period('05 Jan 2017', freq='4D') 46 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 47 | assert f.end_time >= pd.Timestamp('08 Jan 2017 23:59:59.999999999') 48 | assert f.end_time <= pd.Timestamp('09 Jan 2017 00:00:00') 49 | assert f.is_monotonic 50 | 51 | def test_frame_constructor_multiple_days_partial(self): 52 | f = _Frame(base_unit_freq='4D', start='01 Jan 2017', end='10 Jan 2017') 53 | assert len(f) == 3 54 | assert f[0] == pd.Period('01 Jan 2017', freq='4D') 55 | assert f[2] == pd.Period('09 Jan 2017', freq='4D') 56 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 57 | assert f.end_time >= pd.Timestamp('12 Jan 2017 23:59:59.999999999') 58 | assert f.end_time <= pd.Timestamp('13 Jan 2017 00:00:00') 59 | assert f.is_monotonic 60 | 61 | def test_frame_constructor_weeks(self): 62 | f = _Frame(base_unit_freq='W', start='01 Jan 2017', end='01 Mar 2017') 63 | assert len(f)==10 64 | assert f[0] == pd.Period('26 Dec 2016', freq='W-SUN') 65 | assert f[9] == pd.Period('27 Feb 2017', freq='W-SUN') 66 | assert f.start_time == pd.Timestamp('26 Dec 2016 00:00:00') 67 | assert f.end_time >= pd.Timestamp('05 Mar 2017 23:59:59.999999999') 68 | assert f.end_time <= pd.Timestamp('06 Mar 2017 00:00:00') 69 | assert f.is_monotonic 70 | 71 | def test_frame_constructor_weeks_shifted(self): 72 | f = _Frame(base_unit_freq='W-WED', start='01 Jan 2017', end='01 Mar 2017') 73 | assert len(f)==9 74 | assert f[0] == pd.Period('29 Dec 2016', freq='W-WED') 75 | assert f[8] == pd.Period('23 Feb 2017', freq='W-WED') 76 | assert f.start_time == pd.Timestamp('29 Dec 2016 00:00:00') 77 | assert f.end_time >= pd.Timestamp('01 Mar 2017 23:59:59.999999999') 78 | assert f.end_time <= pd.Timestamp('02 Mar 2017 00:00:00') 79 | assert f.is_monotonic 80 | 81 | def test_frame_constructor_months(self): 82 | f = _Frame(base_unit_freq='M', start='01 Jan 2017', end='01 Mar 2017') 83 | assert len(f) == 3 84 | assert f[0] == pd.Period('Jan 2017', freq='M') 85 | assert f[2] == pd.Period('Mar 2017', freq='M') 86 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 87 | assert f.end_time >= pd.Timestamp('31 Mar 2017 23:59:59.999999999') 88 | assert f.end_time <= pd.Timestamp('01 Apr 2017 00:00:00') 89 | assert f.is_monotonic 90 | 91 | def test_frame_constructor_with_timestamps(self): 92 | f = _Frame(base_unit_freq='M', 93 | start=pd.Timestamp('01 Jan 2017 08:00:01'), 94 | end=pd.Timestamp('01 Mar 2017 12:15:03')) 95 | assert len(f) == 3 96 | assert f[0] == pd.Period('Jan 2017', freq='M') 97 | assert f[2] == pd.Period('Mar 2017', freq='M') 98 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 99 | assert f.end_time >= pd.Timestamp('31 Mar 2017 23:59:59.999999999') 100 | assert f.end_time <= pd.Timestamp('01 Apr 2017 00:00:00') 101 | assert f.is_monotonic 102 | 103 | def test_frame_constructor_with_datetime_redundant(self): 104 | f = _Frame(base_unit_freq='M', 105 | start=datetime.datetime(year=2017, month=1, day=1, 106 | hour=8, minute=0, second=1), 107 | end=datetime.datetime(year=2017, month=3, day=1, 108 | hour=12, minute=15, second=3)) 109 | assert len(f) == 3 110 | assert f[0] == pd.Period('Jan 2017', freq='M') 111 | assert f[2] == pd.Period('Mar 2017', freq='M') 112 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 113 | assert f.end_time >= pd.Timestamp('31 Mar 2017 23:59:59.999999999') 114 | assert f.end_time <= pd.Timestamp('01 Apr 2017 00:00:00') 115 | assert f.is_monotonic 116 | 117 | def test_frame_constructor_with_datetime_deficient(self): 118 | f = _Frame(base_unit_freq='H', 119 | start=datetime.date(year=2017,month=1,day=1), 120 | end=datetime.date(year=2017,month=1,day=2)) 121 | assert len(f) == 25 122 | assert f[0] == pd.Period('01 Jan 2017 00:00:00', freq='H') 123 | assert f[24] == pd.Period('02 Jan 2017 00:00:00', freq='H') 124 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 125 | assert f.end_time >= pd.Timestamp('02 Jan 2017 00:59:59.999999999') 126 | assert f.end_time <= pd.Timestamp('02 Jan 2017 01:00:00') 127 | assert f.is_monotonic 128 | 129 | def test_frame_constructor_days_from_periods(self): 130 | f = _Frame(base_unit_freq='D', 131 | start=pd.Period('01 Jan 2017', freq='D'), 132 | end=pd.Period('01 Mar 2017', freq='D')) 133 | assert len(f)==60 134 | assert f[0] == pd.Period('01 Jan 2017', freq='D') 135 | assert f[59] == pd.Period('01 Mar 2017', freq='D') 136 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 137 | assert f.end_time >= pd.Timestamp('01 Mar 2017 23:59:59.999999999') 138 | assert f.end_time <= pd.Timestamp('02 Mar 2017 00:00:00') 139 | assert f.is_monotonic 140 | 141 | def test_frame_constructor_days_from_subperiods(self): 142 | f = _Frame(base_unit_freq='D', 143 | start=pd.Period('01 Jan 2017 12:00', freq='H'), 144 | end=pd.Period('01 Mar 2017 15:00', freq='H')) 145 | assert len(f)==60 146 | assert f[0] == pd.Period('01 Jan 2017', freq='D') 147 | assert f[59] == pd.Period('01 Mar 2017', freq='D') 148 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 149 | assert f.end_time >= pd.Timestamp('01 Mar 2017 23:59:59.999999999') 150 | assert f.end_time <= pd.Timestamp('02 Mar 2017 00:00:00') 151 | assert f.is_monotonic 152 | 153 | def test_frame_constructor_days_from_superperiods(self): 154 | f = _Frame(base_unit_freq='D', 155 | start=pd.Period('01 Jan 2017', freq='M'), 156 | end=pd.Period('01 Mar 2017', freq='M')) 157 | # it takes the END of a period given as argument 158 | # to define start or end of the frame 159 | # Therefore of January 2017 only the last day made it into the frame 160 | assert len(f) == 1 + 28 + 31 161 | assert f[0] == pd.Period('31 Jan 2017', freq='D') 162 | assert f[59] == pd.Period('31 Mar 2017', freq='D') 163 | assert f.start_time == pd.Timestamp('31 Jan 2017 00:00:00') 164 | assert f.end_time >= pd.Timestamp('31 Mar 2017 23:59:59.999999999') 165 | assert f.end_time <= pd.Timestamp('01 Apr 2017 00:00:00') 166 | assert f.is_monotonic 167 | 168 | def test_frame_constructor_days_from_nonsubperiods(self): 169 | f = _Frame(base_unit_freq='M', 170 | start=pd.Period('31 Dec 2016', freq='W'), 171 | end=pd.Period('01 Mar 2017', freq='W')) 172 | # it takes the END of a period given as argument 173 | # to define start or end of the frame 174 | # therefore December is not in the frame, because the week of 175 | # 31 Dec 2016 ends on 01 Jan 2017. 176 | assert len(f) == 3 177 | assert f[0] == pd.Period('Jan 2017', freq='M') 178 | assert f[2] == pd.Period('Mar 2017', freq='M') 179 | assert f.start_time == pd.Timestamp('01 Jan 2017 00:00:00') 180 | assert f.end_time >= pd.Timestamp('31 Mar 2017 23:59:59.999999999') 181 | assert f.end_time <= pd.Timestamp('01 Apr 2017 00:00:00') 182 | assert f.is_monotonic 183 | 184 | def test_frame_constructor_end_before_start(self): 185 | with pytest.raises(VoidIntervalError): 186 | _Frame(base_unit_freq='D', start='01 Mar 2017', end='01 Jan 2017') 187 | 188 | def test_frame_constructor_bad_timestamp(self): 189 | with pytest.raises(ValueError): 190 | _Frame(base_unit_freq='D', start='01 Mar 2017', end='bad timestamp') 191 | 192 | def test_frame_constructor_too_big_range(self): 193 | 194 | try: 195 | excpt = (RuntimeError, pd._libs.tslibs.np_datetime.OutOfBoundsDatetime) 196 | except AttributeError: 197 | excpt = RuntimeError 198 | with pytest.raises(excpt): 199 | _Frame(start='21 Sep 1677', end='2017', base_unit_freq='D') 200 | # '22 Sep 1677' is the earliest possible day, 201 | # '21 Sep 1677 00:12:44' is the earliest possible time 202 | 203 | 204 | def frame_60d(): 205 | return _Frame(base_unit_freq='D', start='01 Jan 2017', end='01 Mar 2017') 206 | 207 | 208 | def split_frame_60d(): 209 | return [(0,8), (9,39), (40,49), (50,59)] 210 | 211 | 212 | def split_frame_60d_int(): 213 | return [(5,8), (9,39), (40,49), (50,55)] 214 | 215 | 216 | class TestFrameLocSubframesWhole(object): 217 | 218 | def test_frame_locsubf_basic(self): 219 | split_points=[pd.Timestamp('10 Jan 2017'), 220 | pd.Timestamp('10 Feb 2017 12:12:12'), 221 | pd.Timestamp('20 Feb 2017 11:05')] 222 | f = frame_60d() 223 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 224 | assert len(result)==4 225 | for i in range(len(result)) : 226 | assert (result[i] == split_frame_60d()[i]) 227 | 228 | def test_frame_locsubf_multiplied_freq(self): 229 | f = _Frame(start='31 Dec 2016', end='09 Jan 2017', base_unit_freq='2D') 230 | split_points=[pd.Timestamp('02 Jan 2017'), 231 | pd.Timestamp('02 Jan 2017 12:12:12'), 232 | pd.Timestamp('07 Jan 2017'), 233 | pd.Timestamp('07 Jan 2017 11:05')] 234 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 235 | assert len(result) == 3 236 | assert result[0] == (0, 0) 237 | assert result[1] == (1, 2) 238 | assert result[2] == (3, 4) 239 | 240 | def test_frame_locsubf_basic_from_dti(self): 241 | split_points=[pd.Timestamp('10 Jan 2017'), 242 | pd.Timestamp('10 Feb 2017 12:12:12'), 243 | pd.Timestamp('20 Feb 2017 11:05')] 244 | dti = pd.DatetimeIndex(split_points) 245 | f = frame_60d() 246 | result = list(f._locate_subspans(_Span(0, len(f) - 1), dti)) 247 | assert len(result)==4 248 | for i in range(len(result)) : 249 | assert (result[i] == split_frame_60d()[i]) 250 | 251 | def test_frame_locsubf_unordered(self): 252 | split_points = [pd.Timestamp('20 Feb 2017 11:05'), 253 | pd.Timestamp('10 Feb 2017 12:12:12'), 254 | pd.Timestamp('10 Jan 2017')] 255 | f = frame_60d() 256 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 257 | assert len(result) == 4 258 | for i in range(len(result)): 259 | assert (result[i] == split_frame_60d()[i]) 260 | 261 | def test_frame_locsubf_multiple_points_per_bu(self): 262 | split_points=[pd.Timestamp('10 Jan 2017'), 263 | pd.Timestamp('10 Feb 2017 12:12:12'), 264 | pd.Timestamp('10 Feb 2017 20:20:20'), 265 | pd.Timestamp('20 Feb 2017 11:05')] 266 | f = frame_60d() 267 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 268 | assert len(result)==4 269 | for i in range(len(result)) : 270 | assert (result[i] == split_frame_60d()[i]) 271 | 272 | def test_frame_locsubf_with_points_outside(self): 273 | split_points=[pd.Timestamp('30 Dec 2016'), 274 | pd.Timestamp('10 Jan 2017'), 275 | pd.Timestamp('10 Feb 2017 12:12:12'), 276 | pd.Timestamp('20 Feb 2017 11:05'), 277 | pd.Timestamp('20 Feb 2018')] 278 | f = frame_60d() 279 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 280 | assert len(result)==4 281 | for i in range(len(result)) : 282 | assert (result[i] == split_frame_60d()[i]) 283 | 284 | def test_frame_locsubf_with_point_outside_only(self): 285 | split_points=[pd.Timestamp('30 Dec 2016'), 286 | pd.Timestamp('20 Feb 2018')] 287 | f = frame_60d() 288 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 289 | assert len(result)==1 290 | assert (result[0] == (0,59)) 291 | 292 | def test_frame_locsubf_with_first_bu(self): 293 | split_points=[pd.Timestamp('01 Jan 2017'), 294 | pd.Timestamp('10 Jan 2017'), 295 | pd.Timestamp('10 Feb 2017 12:12:12'), 296 | pd.Timestamp('20 Feb 2017 11:05')] 297 | f = frame_60d() 298 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 299 | assert len(result)==4 300 | for i in range(len(result)) : 301 | assert (result[i] == split_frame_60d()[i]) 302 | 303 | def test_frame_locsubf_at_first_bu_only(self): 304 | split_points=[pd.Timestamp('01 Jan 2017')] 305 | f = frame_60d() 306 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 307 | assert len(result)==1 308 | assert (result[0] == (0,59)) 309 | 310 | def test_frame_locsubf_at_last_bu(self): 311 | split_points = [pd.Timestamp('01 Mar 2017')] 312 | f = frame_60d() 313 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 314 | assert len(result) == 2 315 | assert result[0] == (0,58) 316 | assert result[1] == (59,59) 317 | 318 | def test_frame_split_empty(self): 319 | split_points=[] 320 | f = frame_60d() 321 | result = list(f._locate_subspans(_Span(0, len(f) - 1), split_points)) 322 | assert len(result)==1 323 | assert result[0] == (0,59) 324 | 325 | 326 | class TestFrameLocSubframesSpan(object): 327 | 328 | def test_frame_locsubf_int_basic(self): 329 | split_points=[pd.Timestamp('10 Jan 2017'), 330 | pd.Timestamp('10 Feb 2017 12:12:12'), 331 | pd.Timestamp('20 Feb 2017 11:05')] 332 | f = frame_60d() 333 | result = list(f._locate_subspans(_Span(5, 55), split_points)) 334 | assert len(result)==4 335 | for i in range(len(result)) : 336 | assert (result[i] == split_frame_60d_int()[i]) 337 | 338 | def test_frame_locsubf_with_points_outside(self): 339 | split_points=[pd.Timestamp('30 Dec 2016'), 340 | pd.Timestamp('10 Jan 2017'), 341 | pd.Timestamp('10 Feb 2017 12:12:12'), 342 | pd.Timestamp('20 Feb 2017 11:05'), 343 | pd.Timestamp('20 Feb 2018')] 344 | f = frame_60d() 345 | result = list(f._locate_subspans(_Span(5, 55), split_points)) 346 | assert len(result)==4 347 | for i in range(len(result)) : 348 | assert (result[i] == split_frame_60d_int()[i]) 349 | 350 | def test_frame_locsubf_with_point_outside_only(self): 351 | split_points=[pd.Timestamp('30 Dec 2016'), 352 | pd.Timestamp('20 Feb 2018')] 353 | f = frame_60d() 354 | result = list(f._locate_subspans(_Span(5, 55), split_points)) 355 | assert len(result)==1 356 | assert (result[0] == (5,55)) 357 | 358 | def test_frame_locsubf_with_first_bu(self): 359 | split_points=[pd.Timestamp('06 Jan 2017'), 360 | pd.Timestamp('10 Jan 2017'), 361 | pd.Timestamp('10 Feb 2017 12:12:12'), 362 | pd.Timestamp('20 Feb 2017 11:05')] 363 | f = frame_60d() 364 | result = list(f._locate_subspans(_Span(5, 55), split_points)) 365 | assert len(result)==4 366 | for i in range(len(result)) : 367 | assert (result[i] == split_frame_60d_int()[i]) 368 | 369 | def test_frame_locsubf_at_first_bu_only(self): 370 | split_points=[pd.Timestamp('06 Jan 2017')] 371 | f = frame_60d() 372 | result = list(f._locate_subspans(_Span(5, 55), split_points)) 373 | assert len(result)==1 374 | assert (result[0] == (5,55)) 375 | 376 | def test_frame_locsubf_span_single(self): 377 | split_points=[pd.Timestamp('06 Jan 2017')] 378 | f = frame_60d() 379 | result = list(f._locate_subspans(_Span(5, 5), split_points)) 380 | assert len(result)==1 381 | assert (result[0] == (5,5)) 382 | 383 | def test_frame_locsubf_span_single_points_outside(self): 384 | split_points=[pd.Timestamp('07 Jan 2017')] 385 | f = frame_60d() 386 | result = list(f._locate_subspans(_Span(5, 5), split_points)) 387 | assert len(result)==1 388 | assert (result[0] == (5,5)) 389 | 390 | def test_frame_locsubf_span_single_no_points(self): 391 | split_points = [] 392 | f = frame_60d() 393 | result = list(f._locate_subspans(_Span(5, 5), split_points)) 394 | assert len(result) == 1 395 | assert (result[0] == (5, 5)) 396 | 397 | def test_frame_locsubf_span_single_at_zero(self): 398 | split_points = [] 399 | f = frame_60d() 400 | result = list(f._locate_subspans(_Span(0, 0), split_points)) 401 | assert len(result) == 1 402 | assert (result[0] == (0, 0)) 403 | 404 | def test_frame_locsubf_span_negative1(self): 405 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 406 | f = frame_60d() 407 | with pytest.raises(OutOfBoundsError): 408 | f._locate_subspans(_Span(5 - 60, 55), split_points) 409 | 410 | def test_frame_locsubf_span_negative2(self): 411 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 412 | f = frame_60d() 413 | with pytest.raises(OutOfBoundsError): 414 | f._locate_subspans(_Span(5, 55 - 60), split_points) 415 | 416 | def test_frame_locsubf_span_outside(self): 417 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 418 | f = frame_60d() 419 | with pytest.raises(OutOfBoundsError): 420 | f._locate_subspans(_Span(5, 70), split_points) 421 | 422 | def test_frame_locsubf_span_reverse_(self): 423 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 424 | f = frame_60d() 425 | with pytest.raises(VoidIntervalError): 426 | f._locate_subspans(_Span(55, 5), split_points) 427 | 428 | def test_frame_locsubf_span_ts_injection1(self): 429 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 430 | f = frame_60d() 431 | with pytest.raises(Exception): 432 | f._locate_subspans(_Span(5, '20 Feb 2017'), split_points) 433 | 434 | def test_frame_locsubf_span_ts_injection2(self): 435 | split_points = [pd.Timestamp('10 Feb 2017 12:12:12')] 436 | f = frame_60d() 437 | with pytest.raises(Exception): 438 | f._locate_subspans(_Span(5, pd.Timestamp('20 Feb 2017')), 439 | split_points) -------------------------------------------------------------------------------- /timeboard/tests/test_intervals_compound.py: -------------------------------------------------------------------------------- 1 | import timeboard as tb 2 | from timeboard.interval import Interval 3 | from timeboard.exceptions import OutOfBoundsError, PartialOutOfBoundsError 4 | 5 | import datetime 6 | import pandas as pd 7 | import pytest 8 | 9 | def tb_10_8_6_hours(workshift_ref='start', worktime_source='duration'): 10 | shifts = tb.Marker(each='D', at=[{'hours': 2}, {'hours': 8}, {'hours': 18}]) 11 | daily = tb.Organizer(marker=shifts, structure=[1, 0]) 12 | return tb.Timeboard(base_unit_freq='H', 13 | start='01 Oct 2017', end='06 Oct 2017', 14 | layout=daily, 15 | workshift_ref=workshift_ref, 16 | worktime_source=worktime_source) 17 | 18 | # workshift day dur end label on_duty 19 | # loc 20 | # 0 2017-10-01 00:00:00 1 1 2 2017-10-01 01:59:59 1.0 True 21 | # 1 2017-10-01 02:00:00 1 1 6 2017-10-01 07:59:59 0.0 False 22 | # 2 2017-10-01 08:00:00 1 1 10 2017-10-01 17:59:59 1.0 True 23 | # 3 2017-10-01 18:00:00 1x2 8 2017-10-02 01:59:59 0.0 False 24 | # 4 2017-10-02 02:00:00 2 2 6 2017-10-02 07:59:59 1.0 True 25 | # 5 2017-10-02 08:00:00 2 2 10 2017-10-02 17:59:59 0.0 False 26 | # 6 2017-10-02 18:00:00 2x3 8 2017-10-03 01:59:59 1.0 True 27 | # 7 2017-10-03 02:00:00 3 3 6 2017-10-03 07:59:59 0.0 False 28 | # 8 2017-10-03 08:00:00 3 3 10 2017-10-03 17:59:59 1.0 True 29 | # 9 2017-10-03 18:00:00 3x4 8 2017-10-04 01:59:59 0.0 False 30 | # 10 2017-10-04 02:00:00 4 4 6 2017-10-04 07:59:59 1.0 True 31 | # 11 2017-10-04 08:00:00 4 4 10 2017-10-04 17:59:59 0.0 False 32 | # 12 2017-10-04 18:00:00 4x5 8 2017-10-05 01:59:59 1.0 True 33 | # 13 2017-10-05 02:00:00 5 5 6 2017-10-05 07:59:59 0.0 False 34 | # 14 2017-10-05 08:00:00 5 5 10 2017-10-05 17:59:59 1.0 True 35 | # 15 2017-10-05 18:00:00 5x6 7 2017-10-06 00:59:59 0.0 False 36 | 37 | 38 | class TestIntervalCompoundConstructor: 39 | 40 | def test_interval_constructor_compound_with_two_ts(self): 41 | clnd = tb_10_8_6_hours() 42 | ivl = clnd.get_interval(('01 Oct 2017 10:00', '02 Oct 2017 23:00')) 43 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 8, 0, 0) 44 | assert ivl.end_time > datetime.datetime(2017, 10, 3, 1, 59, 59) 45 | assert ivl.end_time < datetime.datetime(2017, 10, 3, 2, 0, 0) 46 | assert ivl._loc == (2,6) 47 | assert len(ivl) == 5 48 | 49 | ivlx = clnd(('01 Oct 2017 10:00', '02 Oct 2017 23:00')) 50 | assert ivlx._loc == ivl._loc 51 | 52 | def test_interval_constructor_compound_with_two_ts_open_ended(self): 53 | clnd = tb_10_8_6_hours() 54 | ivl = clnd.get_interval(('01 Oct 2017 10:00', '02 Oct 2017 23:00'), 55 | closed='00') 56 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 18, 0, 0) 57 | assert ivl.end_time > datetime.datetime(2017, 10, 2, 17, 59, 59) 58 | assert ivl.end_time < datetime.datetime(2017, 10, 2, 18, 0, 0) 59 | assert ivl._loc == (3, 5) 60 | assert len(ivl) == 3 61 | 62 | def test_interval_constructor_compound_with_two_ts_same_ws(self): 63 | clnd = tb_10_8_6_hours() 64 | ivl = clnd.get_interval(('02 Oct 2017 19:15', '03 Oct 2017 01:10')) 65 | assert ivl.start_time == datetime.datetime(2017, 10, 2, 18, 0, 0) 66 | assert ivl.end_time > datetime.datetime(2017, 10, 3, 1, 59, 59) 67 | assert ivl.end_time < datetime.datetime(2017, 10, 3, 2, 0, 0) 68 | assert ivl._loc == (6,6) 69 | assert len(ivl) == 1 70 | 71 | ivlx = clnd(('02 Oct 2017 19:15', '03 Oct 2017 01:10')) 72 | assert ivlx._loc == ivl._loc 73 | 74 | def test_interval_constructor_compound_with_length(self): 75 | clnd = tb_10_8_6_hours() 76 | ivl = clnd.get_interval('02 Oct 2017 15:00', length=7) 77 | assert ivl.start_time == datetime.datetime(2017, 10, 2, 8, 0, 0) 78 | assert ivl.end_time > datetime.datetime(2017, 10, 4, 17, 59, 59) 79 | assert ivl.end_time < datetime.datetime(2017, 10, 4, 18, 0, 0) 80 | assert ivl._loc == (5,11) 81 | assert len(ivl) == 7 82 | 83 | ivlx = clnd('02 Oct 2017 15:00', length=7) 84 | assert ivlx._loc == ivl._loc 85 | 86 | def test_interval_constructor_compound_with_period(self): 87 | clnd = tb_10_8_6_hours() 88 | ivl = clnd.get_interval('01 Oct 2017 19:00', period='D') 89 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 0, 0, 0) 90 | assert ivl.end_time > datetime.datetime(2017, 10, 2, 1, 59, 59) 91 | assert ivl.end_time < datetime.datetime(2017, 10, 2, 2, 0, 0) 92 | assert ivl._loc == (0, 3) 93 | assert len(ivl) == 4 94 | 95 | ivlx = clnd('01 Oct 2017 03:00', period='D') 96 | assert ivlx._loc == ivl._loc 97 | 98 | ivl = clnd.get_interval(pd.Period('01 Oct 2017 19:00', freq='D')) 99 | assert ivl._loc == (0, 3) 100 | 101 | 102 | def test_interval_constructor_compound_with_period2(self): 103 | clnd = tb_10_8_6_hours() 104 | ivl = clnd.get_interval('02 Oct 2017 01:00', period='D') 105 | # The ws of '02 Oct 2017 01:00' begins on the previous day. 106 | # With workshift_ref='start', it does not belong to ivl, hence 107 | # the day is not fully covered by the interval, the ivl reference point 108 | # is not in the interval 109 | assert ivl.start_time == datetime.datetime(2017, 10, 2, 2, 0, 0) 110 | assert ivl.end_time > datetime.datetime(2017, 10, 3, 1, 59, 59) 111 | assert ivl.end_time < datetime.datetime(2017, 10, 3, 2, 0, 0) 112 | assert ivl._loc == (4, 6) 113 | assert len(ivl) == 3 114 | 115 | ivlx = clnd('02 Oct 2017 01:00', period='D') 116 | assert ivlx._loc == ivl._loc 117 | 118 | def test_interval_constructor_compound_with_period3(self): 119 | clnd = tb_10_8_6_hours(workshift_ref='end') 120 | ivl = clnd.get_interval('01 Oct 2017 19:00', period='D') 121 | # the ws starting at 18:00 ends next day. 122 | # With workshift_ref='end' is does not belong to the ivl , hence 123 | # the day is not fully covered by the interval, the ivl reference point 124 | # is not in the interval 125 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 0, 0, 0) 126 | assert ivl.end_time > datetime.datetime(2017, 10, 1, 17, 59, 59) 127 | assert ivl.end_time < datetime.datetime(2017, 10, 1, 18, 0, 0) 128 | assert ivl._loc == (0, 2) 129 | assert len(ivl) == 3 130 | 131 | ivlx = clnd('01 Oct 2017 19:00', period='D') 132 | assert ivlx._loc == ivl._loc 133 | 134 | def test_interval_constructor_compound_with_period4(self): 135 | clnd = tb_10_8_6_hours(workshift_ref='end') 136 | ivl = clnd.get_interval('02 Oct 2017 01:00', period='D') 137 | # the ws starting at 18:00 ends next day. 138 | # With workshift_ref='end' is does not belong to the ivl , hence 139 | # the day is not fully covered by the interval 140 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 18, 0, 0) 141 | assert ivl.end_time > datetime.datetime(2017, 10, 2, 17, 59, 59) 142 | assert ivl.end_time < datetime.datetime(2017, 10, 2, 18, 0, 0) 143 | assert ivl._loc == (3, 5) 144 | assert len(ivl) == 3 145 | 146 | ivlx = clnd('02 Oct 2017 01:00', period='D') 147 | assert ivlx._loc == ivl._loc 148 | 149 | def test_interval_constructor_compound_with_period_partial(self): 150 | clnd = tb_10_8_6_hours() 151 | # this period is completely outside the tb because the last workshift's 152 | # ref time is in the previous day Oct 5 153 | with pytest.raises(OutOfBoundsError): 154 | clnd.get_interval('06 Oct 2017 00:15', period='D') 155 | 156 | 157 | def test_interval_constructor_compound_with_period_partial2(self): 158 | clnd = tb_10_8_6_hours(workshift_ref='end') 159 | ivl = clnd.get_interval('06 Oct 2017 00:15', period='D') 160 | assert ivl.start_time == datetime.datetime(2017, 10, 5, 18, 0, 0) 161 | assert ivl.end_time > datetime.datetime(2017, 10, 6, 0, 59, 59) 162 | assert ivl.end_time < datetime.datetime(2017, 10, 6, 1, 0, 0) 163 | assert ivl._loc == (15, 15) 164 | assert len(ivl) == 1 165 | 166 | ivl = clnd.get_interval('06 Oct 2017 20:15', period='D') 167 | assert ivl._loc == (15, 15) 168 | with pytest.raises(PartialOutOfBoundsError): 169 | ivl = clnd.get_interval('06 Oct 2017 00:15', period='D', 170 | clip_period=False) 171 | 172 | def test_interval_constructor_compound_with_period_partial3(self): 173 | clnd = tb_10_8_6_hours() 174 | ivl = clnd.get_interval('01 Oct 2017 00:15', period='W') 175 | # 01 Oct 2017 is Sunday, the last day of the week 176 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 0, 0, 0) 177 | assert ivl.end_time > datetime.datetime(2017, 10, 2, 1, 59, 59) 178 | assert ivl.end_time < datetime.datetime(2017, 10, 2, 2, 0, 0) 179 | assert ivl._loc == (0, 3) 180 | assert len(ivl) == 4 181 | 182 | ivl = clnd.get_interval('30 Sep 2017 12:00', period='W') 183 | assert ivl._loc == (0, 3) 184 | with pytest.raises(PartialOutOfBoundsError): 185 | ivl = clnd.get_interval('01 Oct 2017 00:15', period='W', 186 | clip_period=False) 187 | 188 | def test_interval_constructor_compound_with_period_partial4(self): 189 | clnd = tb_10_8_6_hours(workshift_ref='end') 190 | ivl = clnd.get_interval('01 Oct 2017 00:15', period='W') 191 | # 01 Oct 2017 is Sunday, the last day of the week 192 | assert ivl.start_time == datetime.datetime(2017, 10, 1, 0, 0, 0) 193 | assert ivl.end_time > datetime.datetime(2017, 10, 1, 17, 59, 59) 194 | assert ivl.end_time < datetime.datetime(2017, 10, 1, 18, 0, 0) 195 | assert ivl._loc == (0, 2) 196 | assert len(ivl) == 3 197 | 198 | ivl = clnd.get_interval('30 Sep 2017 12:00', period='W') 199 | assert ivl._loc == (0, 2) 200 | with pytest.raises(PartialOutOfBoundsError): 201 | ivl = clnd.get_interval('01 Oct 2017 00:15', period='W', 202 | clip_period=False) 203 | 204 | 205 | class TestIntervalCompoundCountPeriodsWithLocsNotStraddling(object): 206 | 207 | def test_ivl_compound_count_periods_one_float_right_duty_on(self): 208 | clnd = tb_10_8_6_hours() 209 | ivl = Interval(clnd, (4, 5)) 210 | assert ivl.count_periods('D') == 1.0 / 2.0 211 | clnd = tb_10_8_6_hours(workshift_ref='end') 212 | assert clnd._timeline._workshift_ref == 'end' 213 | ivl = Interval(clnd, (4, 5)) 214 | assert ivl.count_periods('D') == 1.0 215 | 216 | def test_ivl_compound_count_periods_one_float_right_duty_off(self): 217 | clnd = tb_10_8_6_hours() 218 | ivl = Interval(clnd, (7, 8)) 219 | assert ivl.count_periods('D', duty='off') == 1.0 / 2.0 220 | clnd = tb_10_8_6_hours(workshift_ref='end') 221 | ivl = Interval(clnd, (7, 8)) 222 | assert ivl.count_periods('D', duty='off') == 2.0 / 2.0 223 | 224 | def test_ivl_compound_count_periods_one_float_right_duty_any(self): 225 | clnd = tb_10_8_6_hours() 226 | ivl = Interval(clnd, (1, 2)) 227 | assert ivl.count_periods('D', duty='any') == 2.0 / 4.0 228 | clnd = tb_10_8_6_hours(workshift_ref='end') 229 | ivl = Interval(clnd, (1, 2)) 230 | assert ivl.count_periods('D', duty='any') == 2.0 / 3.0 231 | 232 | def test_ivl_compound_count_periods_one_float_left_duty_on(self): 233 | clnd = tb_10_8_6_hours() 234 | ivl = Interval(clnd, (7, 8)) 235 | assert ivl.count_periods('D') == 1.0 / 1.0 236 | clnd = tb_10_8_6_hours(workshift_ref='end') 237 | ivl = Interval(clnd, (7, 8)) 238 | assert ivl.count_periods('D') == 1.0 / 2.0 239 | 240 | def test_ivl_compound_count_periods_one_float_left_duty_off(self): 241 | clnd = tb_10_8_6_hours() 242 | ivl = Interval(clnd, (4, 5)) 243 | assert ivl.count_periods('D', duty='off') == 1.0 / 1.0 244 | clnd = tb_10_8_6_hours(workshift_ref='end') 245 | ivl = Interval(clnd, (4, 5)) 246 | assert ivl.count_periods('D', duty='off') == 1.0 / 2.0 247 | 248 | def test_ivl_compound_count_periods_many_float_right_duty_on(self): 249 | clnd = tb_10_8_6_hours() 250 | ivl = Interval(clnd, (4, 11)) 251 | assert ivl.count_periods('D') == 2.5 252 | clnd = tb_10_8_6_hours(workshift_ref='end') 253 | ivl = Interval(clnd, (4, 11)) 254 | assert ivl.count_periods('D') == 3.0 255 | 256 | def test_ivl_compound_count_periods_many_float_left_duty_off(self): 257 | clnd = tb_10_8_6_hours() 258 | ivl = Interval(clnd, (4, 11)) 259 | assert ivl.count_periods('D', duty='off') == 3.0 260 | clnd = tb_10_8_6_hours(workshift_ref='end') 261 | ivl = Interval(clnd, (4, 11)) 262 | assert ivl.count_periods('D', duty='off') == 2.5 263 | 264 | def test_ivl_compound_count_periods_many_float_left_duty_on(self): 265 | clnd = tb_10_8_6_hours() 266 | ivl = Interval(clnd, (7, 14)) 267 | assert ivl.count_periods('D') == 3.0 268 | clnd = tb_10_8_6_hours(workshift_ref='end') 269 | ivl = Interval(clnd, (7, 14)) 270 | assert ivl.count_periods('D') == 2.5 271 | 272 | def test_ivl_compound_count_periods_many_float_right_duty_off(self): 273 | clnd = tb_10_8_6_hours() 274 | ivl = Interval(clnd, (7, 14)) 275 | assert ivl.count_periods('D', duty='off') == 2.5 276 | clnd = tb_10_8_6_hours(workshift_ref='end') 277 | ivl = Interval(clnd, (7, 14)) 278 | assert ivl.count_periods('D', duty='off') == 3.0 279 | 280 | def test_ivl_compound_count_periods_many_float_both_duty_any(self): 281 | clnd = tb_10_8_6_hours() 282 | ivl = Interval(clnd, (7, 14)) 283 | assert ivl.count_periods('D', duty='any') == 2.0 + 2.0/3.0 284 | clnd = tb_10_8_6_hours(workshift_ref='end') 285 | ivl = Interval(clnd, (7, 14)) 286 | assert ivl.count_periods('D', duty='any') == 2.0 + 2.0/3.0 287 | 288 | 289 | class TestIntervalCompoundCountPeriodsWithLocsStraddling(object): 290 | 291 | def test_ivl_compound_count_periods_straddle_float_left_duty_on(self): 292 | clnd = tb_10_8_6_hours() 293 | ivl = Interval(clnd, (6, 8)) 294 | assert ivl.count_periods('D') == 1.0 / 2.0 + 1.0 295 | clnd = tb_10_8_6_hours(workshift_ref='end') 296 | ivl = Interval(clnd, (6, 8)) 297 | assert ivl.count_periods('D') == 1.0 298 | 299 | def test_ivl_compound_count_periods_straddle_float_left_duty_off(self): 300 | clnd = tb_10_8_6_hours() 301 | ivl = Interval(clnd, (9, 11)) 302 | assert ivl.count_periods('D', duty='off') == 1.0 / 2.0 + 1.0 303 | clnd = tb_10_8_6_hours(workshift_ref='end') 304 | ivl = Interval(clnd, (9, 11)) 305 | assert ivl.count_periods('D', duty='off') == 1.0 306 | 307 | def test_ivl_compound_count_periods_straddle_float_right_duty_on(self): 308 | clnd = tb_10_8_6_hours() 309 | ivl = Interval(clnd, (4, 6)) 310 | assert ivl.count_periods('D') == 1.0 311 | clnd = tb_10_8_6_hours(workshift_ref='end') 312 | ivl = Interval(clnd, (4, 6)) 313 | assert ivl.count_periods('D') == 1.0 + 1.0 / 2.0 314 | 315 | def test_ivl_compound_count_periods_straddle_float_right_duty_off(self): 316 | clnd = tb_10_8_6_hours() 317 | ivl = Interval(clnd, (7, 9)) 318 | assert ivl.count_periods('D', duty='off') == 1.0 319 | clnd = tb_10_8_6_hours(workshift_ref='end') 320 | ivl = Interval(clnd, (7, 9)) 321 | assert ivl.count_periods('D', duty='off') == 1.0 + 1.0 / 2.0 322 | 323 | def test_ivl_compound_count_periods_many_straddle_both_ends_duty_on(self): 324 | clnd = tb_10_8_6_hours() 325 | ivl = Interval(clnd, (3, 12)) 326 | assert ivl.count_periods('D') == 3.0 327 | clnd = tb_10_8_6_hours(workshift_ref='end') 328 | ivl = Interval(clnd, (3, 12)) 329 | assert ivl.count_periods('D') == 3.0 + 1.0 / 2.0 330 | 331 | def test_ivl_compound_count_periods_many_straddle_both_ends_duty_off(self): 332 | clnd = tb_10_8_6_hours() 333 | ivl = Interval(clnd, (3, 12)) 334 | assert ivl.count_periods('D', duty='off') == 1.0 / 2.0 + 3.0 335 | clnd = tb_10_8_6_hours(workshift_ref='end') 336 | ivl = Interval(clnd, (3, 12)) 337 | assert ivl.count_periods('D', duty='off') == 3.0 338 | 339 | def test_ivl_compound_count_periods_many_straddle_both_ends_duty_any(self): 340 | clnd = tb_10_8_6_hours() 341 | ivl = Interval(clnd, (3, 12)) 342 | assert ivl.count_periods('D', duty='any') == 1.0 / 4.0 + 3.0 343 | clnd = tb_10_8_6_hours(workshift_ref='end') 344 | ivl = Interval(clnd, (3, 12)) 345 | assert ivl.count_periods('D', duty='any') > 3.0 + 0.3332 346 | assert ivl.count_periods('D', duty='any') < 3.0 + 0.3334 347 | 348 | 349 | class TestIntervalCompoundCountPeriodsOOB(object): 350 | 351 | def test_ivl_compound_count_periods_OOB(self): 352 | clnd = tb_10_8_6_hours() 353 | ivl = Interval(clnd, (0, 2)) 354 | with pytest.raises(PartialOutOfBoundsError): 355 | ivl.count_periods('W') 356 | 357 | def test_ivl_compound_count_periods_OOB_floating(self): 358 | clnd = tb_10_8_6_hours() 359 | ivl = Interval(clnd, (14, 15)) 360 | assert ivl.count_periods('D', duty='off') == 1.0 / 2.0 361 | clnd = tb_10_8_6_hours(workshift_ref='end') 362 | ivl = Interval(clnd, (14, 15)) 363 | with pytest.raises(PartialOutOfBoundsError): 364 | ivl.count_periods('D', duty='off') 365 | 366 | 367 | class TestIntervalCompoundTotalDuration(object): 368 | 369 | def test_ivl_compound_total_dur(self): 370 | clnd = tb_10_8_6_hours() 371 | ivl = Interval(clnd, (2, 8)) 372 | assert ivl.total_duration() == 34 373 | assert ivl.total_duration(duty='off') == 24 374 | assert ivl.total_duration(duty ='any') == 58 375 | 376 | def test_ivl_compound_total_dur_other_schedule(self): 377 | clnd = tb_10_8_6_hours() 378 | other_sdl = clnd.add_schedule('other', lambda x: x > 1) 379 | ivl = Interval(clnd, (2, 8)) 380 | assert ivl.total_duration(schedule=other_sdl) == 0 381 | assert ivl.total_duration(duty='off', schedule=other_sdl) == 58 382 | assert ivl.total_duration(duty ='any', schedule=other_sdl) == 58 383 | 384 | def test_ivl_compound_total_dur_other_schedule2(self): 385 | clnd = tb_10_8_6_hours() 386 | other_sdl = clnd.add_schedule('other', lambda x: x > -1) 387 | ivl = Interval(clnd, (2, 8), schedule=other_sdl) 388 | assert ivl.total_duration() == 58 389 | assert ivl.total_duration(duty='off') == 0 390 | assert ivl.total_duration(duty ='any') == 58 391 | 392 | 393 | class TestIntervalCompoundWorkTime(object): 394 | 395 | def test_ivl_compound_worktime_default(self): 396 | clnd = tb_10_8_6_hours() 397 | ivl = Interval(clnd, (2, 8)) 398 | assert ivl.worktime() == 34 399 | assert ivl.worktime(duty='off') == 24 400 | assert ivl.worktime(duty ='any') == 58 401 | 402 | def test_ivl_compound_worktime_in_labels(self): 403 | clnd = tb_10_8_6_hours(worktime_source='labels') 404 | ivl = Interval(clnd, (2, 8)) 405 | assert ivl.worktime() == 4 406 | assert ivl.worktime(duty='off') == 0 407 | assert ivl.worktime(duty ='any') == 4 408 | 409 | 410 | def test_ivl_compound_worktime_in_labels_other_schedule(self): 411 | clnd = tb_10_8_6_hours(worktime_source='labels') 412 | other_sdl = clnd.add_schedule('other', lambda x: x > 1) 413 | ivl = Interval(clnd, (2, 8)) 414 | assert ivl.worktime(schedule=other_sdl) == 0 415 | assert ivl.worktime(duty='off', schedule=other_sdl) == 4 416 | assert ivl.worktime(duty ='any', schedule=other_sdl) == 4 417 | 418 | 419 | -------------------------------------------------------------------------------- /timeboard/tests/test_organizers.py: -------------------------------------------------------------------------------- 1 | from timeboard.core import _Timeline, _Frame, Organizer, RememberingPattern 2 | from timeboard.exceptions import OutOfBoundsError 3 | from itertools import cycle 4 | import numpy as np 5 | import pandas as pd 6 | import pytest 7 | 8 | class TestOrganizerConstructor(object): 9 | 10 | 11 | def test_organizer_constructor_marker(self): 12 | org = Organizer(marker='W', structure=[1, 2, 3]) 13 | assert org.marker.each == 'W' 14 | assert org.marks is None 15 | assert org.structure == [1, 2, 3] 16 | 17 | def test_organizer_constructor_marks(self): 18 | org = Organizer(marks=['01 Jan 2017'], structure=[1, 2, 3]) 19 | assert org.marker is None 20 | assert org.marks == ['01 Jan 2017'] 21 | assert org.structure == [1, 2, 3] 22 | 23 | def test_organizer_constructor_structures_as_str(self): 24 | with pytest.raises(TypeError): 25 | Organizer(marks='01 Jan 2017', structure='123') 26 | 27 | def test_organizer_constructor_no_marker_or_marks(self): 28 | with pytest.raises(ValueError): 29 | Organizer(structure=[1, 2, 3]) 30 | 31 | def test_organizer_constructor_both_marker_and_marks(self): 32 | with pytest.raises(ValueError): 33 | Organizer(marker='W', marks='01 Jan 2017', structure=[1, 2, 3]) 34 | 35 | def test_organizer_constructor_no_structures(self): 36 | with pytest.raises(TypeError): 37 | Organizer(marker='W') 38 | 39 | def test_organizer_constructor_bad_structures(self): 40 | with pytest.raises(TypeError): 41 | Organizer(marker='W', structure=1) 42 | 43 | 44 | class TestOrganizeSimple(object): 45 | 46 | def test_organize_trivial(self): 47 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 48 | org = Organizer(marks=[], structure=[[1, 2, 3]]) 49 | t = _Timeline(frame=f, organizer=org) 50 | assert t.labels.eq([1, 2, 3, 1, 2, 3, 1, 2, 3, 1]).all() 51 | 52 | def test_organize_trivial_string_are_labels(self): 53 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 54 | org = Organizer(marks=[], structure=[['ab', 'cd', 'ef']]) 55 | t = _Timeline(frame=f, organizer=org) 56 | assert t.labels.eq(['ab', 'cd', 'ef', 'ab', 'cd', 'ef', 'ab', 'cd', 57 | 'ef', 'ab']).all() 58 | 59 | def test_organize_one_mark(self): 60 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 61 | org = Organizer(marks=['05 Jan 2017'], 62 | structure=[[1, 2, 3], [11, 12]]) 63 | t = _Timeline(frame=f, organizer=org) 64 | assert t.labels.eq([1, 2, 3, 1, 11, 12, 11, 12, 11, 12]).all() 65 | 66 | def test_organize_one_mark_as_single_value(self): 67 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 68 | org = Organizer(marks='05 Jan 2017', 69 | structure=[[1, 2, 3], [11, 12]]) 70 | t = _Timeline(frame=f, organizer=org) 71 | assert t.labels.eq([1, 2, 3, 1, 11, 12, 11, 12, 11, 12]).all() 72 | 73 | def test_organize_one_mark_cycle_structures(self): 74 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 75 | org = Organizer(marks=['05 Jan 2017'], 76 | structure=[[1, 2, 3]]) 77 | t = _Timeline(frame=f, organizer=org) 78 | assert t.labels.eq([1, 2, 3, 1, 1, 2, 3, 1, 2, 3]).all() 79 | 80 | def test_organize_simple_marker(self): 81 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 82 | org = Organizer(marker='W', structure=[[1, 2, 3], [11, 12]]) 83 | t = _Timeline(frame=f, organizer=org) 84 | assert t.labels.eq([1, 11, 12, 11, 12, 11, 12, 11, 1, 2]).all() 85 | 86 | def test_organize_simple_marker_cycle_structures(self): 87 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 88 | org = Organizer(marker='W', structure=[[1, 2, 3]]) 89 | t = _Timeline(frame=f, organizer=org) 90 | assert t.labels.eq([1, 1, 2, 3, 1, 2, 3, 1, 1, 2]).all() 91 | 92 | def test_organize_marks_outside(self): 93 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 94 | org = Organizer(marks=['20 Jan 2017'], structure=[[1, 2, 3]]) 95 | t = _Timeline(frame=f, organizer=org) 96 | assert t.labels.eq([1, 2, 3, 1, 2, 3, 1, 2, 3, 1]).all() 97 | 98 | def test_organize_marker_outside(self): 99 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 100 | org = Organizer(marker='M', structure=[[1, 2, 3]]) 101 | t = _Timeline(frame=f, organizer=org) 102 | assert t.labels.eq([1, 2, 3, 1, 2, 3, 1, 2, 3, 1]).all() 103 | 104 | def test_organize_pattern_with_memory(self): 105 | f = _Frame(base_unit_freq='D', start='02 Jan 2017', 106 | end='22 Jan 2017') 107 | p = RememberingPattern([1, 2, 3]) 108 | org = Organizer(marker='W', structure=[p, [9], p]) 109 | t = _Timeline(frame=f, organizer=org) 110 | assert t.labels.eq([1, 2, 3, 1, 2, 3, 1, 111 | 9, 9, 9, 9, 9, 9, 9, 112 | 2, 3, 1, 2, 3, 1, 2]).all() 113 | 114 | 115 | class TestOrganizeRecursive(object): 116 | 117 | def test_organize_recursive_mixed(self): 118 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 119 | org_int = Organizer(marker='W', structure=[[1, 2, 3]]) 120 | org_ext = Organizer(marks=['06 Jan 2017'], 121 | structure=[org_int, [11, 12]]) 122 | t = _Timeline(frame=f, organizer=org_ext) 123 | assert t.labels.eq([1, 1, 2, 3, 1, 11, 12, 11, 12, 11]).all() 124 | 125 | def test_organize_recursive_2org(self): 126 | f = _Frame(base_unit_freq='D', start='27 Dec 2016', end='05 Jan 2017') 127 | org_int1 = Organizer(marks=['30 Dec 2016'], 128 | structure=[['a', 'b'], 129 | ['x']]) 130 | org_int2 = Organizer(marker='W', structure=[[1, 2, 3]]) 131 | org_ext = Organizer(marker='M', structure=[org_int1, org_int2]) 132 | t = _Timeline(frame=f, organizer=org_ext) 133 | assert t.labels.eq(['a', 'b', 'a', 'x', 'x', 1, 1, 2, 3, 1]).all() 134 | 135 | def test_organize_recursive_cycled_org(self): 136 | f = _Frame(base_unit_freq='D', start='27 Dec 2016', end='05 Jan 2017') 137 | org_int = Organizer(marker='W', structure=[[1, 2, 3]]) 138 | org_ext = Organizer(marker='M', structure=[org_int]) 139 | t = _Timeline(frame=f, organizer=org_ext) 140 | assert t.labels.eq([2, 3, 1, 2, 3, 1, 1, 2, 3, 1]).all() 141 | 142 | def test_organize_recursive_with_memory(self): 143 | f = _Frame(base_unit_freq='D', start='29 Nov 2017', end='06 Dec 2017') 144 | p = RememberingPattern(['a', 'b', 'c', 'd']) 145 | org_int = Organizer(marker='W', structure=[[1, 2], p]) 146 | org_ext = Organizer(marker='M', structure=[p, org_int]) 147 | t = _Timeline(frame=f, organizer=org_ext) 148 | assert t.labels.eq(['a', 'b', 1, 2, 1, 'c', 'd', 'a']).all() 149 | 150 | def test_organize_recursive_complex(self): 151 | f = _Frame(base_unit_freq='D', start='27 Dec 2016', end='01 Feb 2017') 152 | # org0 : 27.12.16 - 31.12.16 <-org4 ; 01.01.17 - 01.02.17 <- org1 153 | # org4 : 27.12.16 - 31.12.16 <-'dec' 154 | # If we put 'dec' directly into org0.structure it will be anchored 155 | # at the start of the year (01.01.16) because marker='A'. 156 | # With org4 we anchor 'dec' at the start of the subframe (27.12.16) 157 | # 158 | # org1 : 01.01.17 - 31.01.17 <-org2 ; 01.02.17 <- org3 159 | # org2 : 01.01.17 - 05.01.17 <-org3 ; 06.01.17 - 31.01.17 <- 'z' 160 | # org3(1) : 01.01.17(Sun) - 05.01.17(Thu) <-[1,2,3] anchored at W-SUN 161 | # org3(2) : 01.02.17(Wed) <-[1,2,3] anchored at W-SUN 162 | org3 = Organizer(marker='W', structure=[[1, 2, 3]]) 163 | org2 = Organizer(marks='06 Jan 2017', 164 | structure=[org3, ['z']]) 165 | org1 = Organizer(marker='M', structure=[org2, org3]) 166 | org4 = Organizer(marks=[], structure=[['d', 'e', 'c']]) 167 | org0 = Organizer(marker='A', structure=[org4, org1]) 168 | t = _Timeline(frame=f, organizer=org0) 169 | #result: Dec 27-31 Jan 1-5 rest of Jan Feb 1 170 | result = ['d','e','c','d','e'] + [1,1,2,3,1] + ['z']*(31-6+1) + [3] 171 | assert t.labels.eq(result).all() 172 | 173 | 174 | class TestOrganizeVariousTypesOfStructure(object): 175 | 176 | def test_organize_with_empty_structure(self): 177 | f = _Frame(base_unit_freq='D', 178 | start='02 Oct 2017', end='04 Oct 2017 23:59') 179 | org = Organizer(marker='W', structure=[]) 180 | t = _Timeline(frame=f, organizer=org, data=0) 181 | assert t.labels.eq([0, 0, 0]).all() 182 | 183 | def test_organize_with_empty_pattern_in_structure(self): 184 | f = _Frame(base_unit_freq='D', 185 | start='01 Oct 2017', end='10 Oct 2017') 186 | org = Organizer(marker='W', structure=[[1,2],[]]) 187 | t = _Timeline(frame=f, organizer=org, data=0) 188 | assert t.labels.eq([1] + [0]*7 + [1,2]).all() 189 | 190 | def test_organize_structure_as_rememberingpattern(self): 191 | f = _Frame(base_unit_freq='H', 192 | start='02 Oct 2017', end='04 Oct 2017 23:59') 193 | org = Organizer(marker='D', structure=RememberingPattern([1,2])) 194 | t = _Timeline(frame=f, organizer=org, data=0) 195 | assert t.labels.eq([1,2,1]).all() 196 | 197 | def test_organize_structure_as_empty_rememberingpattern(self): 198 | f = _Frame(base_unit_freq='H', 199 | start='02 Oct 2017', end='04 Oct 2017 23:59') 200 | org = Organizer(marker='D', structure=RememberingPattern([])) 201 | t = _Timeline(frame=f, organizer=org, data=0) 202 | assert t.labels.eq([0]*72).all() 203 | 204 | def test_organize_structure_element_as_rememberingpattern(self): 205 | f = _Frame(base_unit_freq='D', 206 | start='02 Oct 2017', end='04 Oct 2017 23:59') 207 | org = Organizer(marker='W', structure=[RememberingPattern([1, 2])]) 208 | t = _Timeline(frame=f, organizer=org, data=0) 209 | assert t.labels.eq([1, 2, 1]).all() 210 | 211 | def test_organize_structure_element_as_empty_rememberingpattern(self): 212 | f = _Frame(base_unit_freq='D', 213 | start='02 Oct 2017', end='04 Oct 2017 23:59') 214 | org = Organizer(marker='W', structure=[RememberingPattern([])]) 215 | t = _Timeline(frame=f, organizer=org, data=0) 216 | assert t.labels.eq([0, 0, 0]).all() 217 | 218 | def test_organize_structure_as_generator(self): 219 | f = _Frame(base_unit_freq='H', 220 | start='02 Oct 2017', end='04 Oct 2017 23:59') 221 | org = Organizer(marker='D', structure=cycle([1,2])) 222 | t = _Timeline(frame=f, organizer=org, data=0) 223 | assert t.labels.eq([1,2,1]).all() 224 | 225 | def test_organize_structure_as_empty_generator(self): 226 | f = _Frame(base_unit_freq='H', 227 | start='02 Oct 2017', end='04 Oct 2017 23:59') 228 | org = Organizer(marker='D', structure=cycle([])) 229 | t = _Timeline(frame=f, organizer=org, data=0) 230 | assert t.labels.eq([0]*72).all() 231 | 232 | def test_organize_structure_element_as_generator(self): 233 | f = _Frame(base_unit_freq='D', 234 | start='02 Oct 2017', end='04 Oct 2017 23:59') 235 | org = Organizer(marker='W', structure=[cycle([1, 2])]) 236 | t = _Timeline(frame=f, organizer=org, data=0) 237 | assert t.labels.eq([1, 2, 1]).all() 238 | 239 | def test_organize_structure_element_as_empty_generator(self): 240 | f = _Frame(base_unit_freq='D', 241 | start='02 Oct 2017', end='04 Oct 2017 23:59') 242 | org = Organizer(marker='W', structure=[cycle([])]) 243 | t = _Timeline(frame=f, organizer=org, data=0) 244 | assert t.labels.eq([0, 0, 0]).all() 245 | 246 | def test_organize_structure_as_numpy_array(self): 247 | f = _Frame(base_unit_freq='H', 248 | start='02 Oct 2017', end='04 Oct 2017 23:59') 249 | org = Organizer(marker='D', structure=np.array([1,2])) 250 | t = _Timeline(frame=f, organizer=org, data=0) 251 | assert t.labels.eq([1,2,1]).all() 252 | 253 | def test_organize_structure_as_empty_numpy_array(self): 254 | f = _Frame(base_unit_freq='H', 255 | start='02 Oct 2017', end='04 Oct 2017 23:59') 256 | org = Organizer(marker='D', structure=np.array([])) 257 | t = _Timeline(frame=f, organizer=org, data=0) 258 | assert t.labels.eq([0]*72).all() 259 | 260 | def test_organize_structure_element_as_numpy_array(self): 261 | f = _Frame(base_unit_freq='D', 262 | start='02 Oct 2017', end='04 Oct 2017 23:59') 263 | org = Organizer(marker='W', structure=[np.array([1,2])]) 264 | t = _Timeline(frame=f, organizer=org, data=0) 265 | assert t.labels.eq([1,2,1]).all() 266 | 267 | def test_organize_structure_element_as_empty_numpy_array(self): 268 | f = _Frame(base_unit_freq='D', 269 | start='02 Oct 2017', end='04 Oct 2017 23:59') 270 | org = Organizer(marker='W', structure=[np.array([])]) 271 | t = _Timeline(frame=f, organizer=org, data=0) 272 | assert t.labels.eq([0,0,0]).all() 273 | 274 | 275 | class TestApplyAmendments(object): 276 | 277 | def test_amendments_basic(self): 278 | t = _Timeline(frame=_Frame(base_unit_freq='D', 279 | start='01 Jan 2017', end='10 Jan 2017'), 280 | data=0) 281 | amendments = {'02 Jan 2017' : 1, '09 Jan 2017' : 2} 282 | t.amend(amendments) 283 | assert t.labels.eq([0, 1, 0, 0, 0, 0, 0, 0, 2, 0]).all() 284 | 285 | def test_amendments_timestamps(self): 286 | t = _Timeline(frame=_Frame(base_unit_freq='D', 287 | start='01 Jan 2017', end='10 Jan 2017'), 288 | data=0) 289 | amendments = {pd.Timestamp('02 Jan 2017'): 1, 290 | pd.Timestamp('09 Jan 2017'): 2} 291 | t.amend(amendments) 292 | assert t.labels.eq([0, 1, 0, 0, 0, 0, 0, 0, 2, 0]).all() 293 | 294 | def test_amendments_periods(self): 295 | t = _Timeline(frame=_Frame(base_unit_freq='D', 296 | start='01 Jan 2017', end='10 Jan 2017'), 297 | data=0) 298 | amendments = {pd.Period('02 Jan 2017', freq='D'): 1, 299 | pd.Period('09 Jan 2017', freq='D'): 2} 300 | t.amend(amendments) 301 | assert t.labels.eq([0, 1, 0, 0, 0, 0, 0, 0, 2, 0]).all() 302 | 303 | def test_amendments_subperiods(self): 304 | t = _Timeline(frame=_Frame(base_unit_freq='D', 305 | start='01 Jan 2017', end='10 Jan 2017'), 306 | data=0) 307 | amendments = {pd.Period('02 Jan 2017 12:00', freq='H'): 1, 308 | pd.Period('09 Jan 2017 15:00', freq='H'): 2} 309 | t.amend(amendments) 310 | assert t.labels.eq([0, 1, 0, 0, 0, 0, 0, 0, 2, 0]).all() 311 | 312 | def test_amendments_outside(self): 313 | t = _Timeline(frame=_Frame(base_unit_freq='D', 314 | start='01 Jan 2017', end='10 Jan 2017'), 315 | data=0) 316 | amendments = {'02 Jan 2017': 1, '11 Jan 2017': 2} 317 | t.amend(amendments) 318 | assert t.labels.eq([0, 1, 0, 0, 0, 0, 0, 0, 0, 0]).all() 319 | 320 | def test_amendments_all_outside(self): 321 | t = _Timeline(frame=_Frame(base_unit_freq='D', 322 | start='01 Jan 2017', end='10 Jan 2017'), 323 | data=0) 324 | amendments = {'02 Jan 2016': 1, '11 Jan 2017': 2} 325 | t.amend(amendments) 326 | assert t.labels.eq([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).all() 327 | 328 | def test_amendments_outside_raise_and_clean(self): 329 | t = _Timeline(frame=_Frame(base_unit_freq='D', 330 | start='01 Jan 2017', end='10 Jan 2017'), 331 | data=0) 332 | amendments = {'02 Jan 2017': 1, '11 Jan 2017': 2} 333 | try: 334 | t.amend(amendments, not_in_range='raise') 335 | except OutOfBoundsError: 336 | assert t.labels.eq([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).all() 337 | else: 338 | pytest.fail(msg="DID NOT RAISE KeyError when not_in_range='raise'") 339 | 340 | def test_amendments_empty(self): 341 | t = _Timeline(frame=_Frame(base_unit_freq='D', 342 | start='01 Jan 2017', end='10 Jan 2017'), 343 | data=0) 344 | amendments = {} 345 | t.amend(amendments) 346 | assert t.labels.eq([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).all() 347 | 348 | def test_amendments_bad_timestamp_raise_and_clean(self): 349 | t = _Timeline(frame=_Frame(base_unit_freq='D', 350 | start='01 Jan 2017', end='10 Jan 2017'), 351 | data=0) 352 | amendments = {'02 Jan 2017': 1, 'bad timestamp': 2} 353 | try: 354 | t.amend(amendments) 355 | except ValueError: 356 | assert t.labels.eq([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).all() 357 | else: 358 | pytest.fail(msg='DID NOT RAISE for bad timestamp') 359 | 360 | 361 | class TestOrganizeCompoundWorkshifts(object): 362 | 363 | def test_organize_compound_all(self): 364 | f = _Frame(base_unit_freq='D', start='31 Dec 2016', end='10 Jan 2017') 365 | org = Organizer(marker='W', structure=[1, 2]) 366 | t = _Timeline(frame=f, organizer=org) 367 | assert t.labels.eq([1, 2, 1]).all() 368 | assert t._frameband.eq([0,0,2,2,2,2,2,2,2,9,9]).all() 369 | assert (t._wsband.index == [0, 2, 9]).all() 370 | 371 | def test_organize_compound_alternate(self): 372 | f = _Frame(base_unit_freq='D', start='31 Dec 2016', end='10 Jan 2017') 373 | org = Organizer(marker='W', structure=[100, [1, 2, 3]]) 374 | t = _Timeline(frame=f, organizer=org) 375 | assert t.labels.eq([100, 1, 2, 3, 1, 2, 3, 1, 100]).all() 376 | assert t._frameband.eq([0,0,2,3,4,5,6,7,8,9,9]).all() 377 | assert (t._wsband.index == [0, 2,3,4,5,6,7,8, 9]).all() 378 | 379 | def test_organize_compound_when_strings_are_labels(self): 380 | f = _Frame(base_unit_freq='D', start='31 Dec 2016', end='10 Jan 2017') 381 | org = Organizer(marker='W', structure=['abc', 'x']) 382 | t = _Timeline(frame=f, organizer=org) 383 | assert t.labels.eq(['abc', 'x', 'abc']).all() 384 | assert t._frameband.eq([0,0,2,2,2,2,2,2,2,9,9]).all() 385 | assert (t._wsband.index == [0, 2, 9]).all() -------------------------------------------------------------------------------- /timeboard/tests/test_patterns.py: -------------------------------------------------------------------------------- 1 | from timeboard.core import ( 2 | _Timeline, _skiperator, _Frame, _Span, RememberingPattern, 3 | TIMELINE_DEL_TEMP_OBJECTS 4 | ) 5 | import pytest 6 | 7 | 8 | class TestSkiperator(object): 9 | 10 | def test_skiperator_basic(self): 11 | g = _skiperator([1, 2, 3]) 12 | result = [] 13 | for i in range(10): 14 | result.append(next(g)) 15 | assert result == [1, 2, 3, 1, 2, 3, 1, 2, 3, 1] 16 | 17 | def test_skiperator_skip(self): 18 | g = _skiperator([1, 2, 3], skip=2) 19 | result = [] 20 | for i in range(10): 21 | result.append(next(g)) 22 | assert result == [3, 1, 2, 3, 1, 2, 3, 1, 2, 3] 23 | 24 | def test_skiperator_skip_more(self): 25 | g = _skiperator([1, 2, 3], skip=4) 26 | result = [] 27 | for i in range(10): 28 | result.append(next(g)) 29 | assert result == [2, 3, 1, 2, 3, 1, 2, 3, 1, 2] 30 | 31 | def test_skiperator_one_value(self): 32 | g = _skiperator([1]) 33 | result = [] 34 | for i in range(10): 35 | result.append(next(g)) 36 | assert result == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] 37 | 38 | def test_skiperator_short(self): 39 | g = _skiperator([1, 2, 3]) 40 | result = [] 41 | for i in range(2): 42 | result.append(next(g)) 43 | assert result == [1, 2] 44 | 45 | def test_skiperator_short_skip(self): 46 | g = _skiperator([1, 2, 3], skip=2) 47 | result = [] 48 | for i in range(2): 49 | result.append(next(g)) 50 | assert result == [3, 1] 51 | 52 | def test_skiperator_skip_negative(self): 53 | # negative value of skip is the same as skip=0 54 | g = _skiperator([1, 2, 3], skip=-1) 55 | result = [] 56 | for i in range(10): 57 | result.append(next(g)) 58 | assert result == [1, 2, 3, 1, 2, 3, 1, 2, 3, 1] 59 | 60 | def test_skiperator_empty(self): 61 | g = _skiperator([]) 62 | with pytest.raises(StopIteration): 63 | next(g) 64 | 65 | def test_skiperator_empty_skip(self): 66 | g = _skiperator([], skip=2) 67 | with pytest.raises(StopIteration): 68 | next(g) 69 | 70 | 71 | class TestTimelineConstructor(object): 72 | 73 | def test_time_line_constructor(self): 74 | 75 | f = _Frame(base_unit_freq='D', start='01 Jan 2017', end='10 Jan 2017') 76 | t = _Timeline(f) 77 | assert len(t)==10 78 | assert t.start_time == f.start_time 79 | assert t.end_time == f.end_time 80 | assert t.labels.isnull().all() 81 | 82 | def timeline_10d(data=None): 83 | return _Timeline(_Frame(base_unit_freq='D', 84 | start='01 Jan 2017', end='10 Jan 2017'), 85 | data=data) 86 | 87 | 88 | @pytest.mark.skipif(TIMELINE_DEL_TEMP_OBJECTS, 89 | reason="__apply_pattern uses object that is deleted " 90 | "after the timeline has been __init__'ed") 91 | class TestApplyPattern(object): 92 | 93 | def test_apply_pattern_basic(self): 94 | p = [1,2,3] 95 | t = timeline_10d() 96 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 97 | assert (t._ws_labels == [1, 2, 3, 1, 2, 3, 1, 2, 3, 1]).all() 98 | 99 | def test_apply_pattern_skip(self): 100 | p = [1,2,3] 101 | t = timeline_10d() 102 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=2)) 103 | assert (t._ws_labels == [3, 1, 2, 3, 1, 2, 3, 1, 2, 3]).all() 104 | 105 | def test_apply_pattern_as_string_skip(self): 106 | # this won't happen in real life as Organizer does not allow 107 | # pattern as a string 108 | p = '123' 109 | t = timeline_10d() 110 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=2)) 111 | assert (t._ws_labels == ['3', '1', '2', '3', '1', '2', '3', '1', '2', '3']).all() 112 | 113 | def test_apply_pattern_span_skip(self): 114 | p = [1,2,3] 115 | t = timeline_10d() 116 | t._Timeline__apply_pattern(p, _Span(1, 6, skip_left=2)) 117 | assert (t._ws_labels[1:7] == [3, 1, 2, 3, 1, 2]).all() 118 | 119 | def test_apply_pattern_double(self): 120 | p1 = [11, 12] 121 | p2 = [1, 2, 3] 122 | t = timeline_10d() 123 | t._Timeline__apply_pattern(p1, _Span(0, len(t.frame) - 1)) 124 | t._Timeline__apply_pattern(p2, _Span(1, 6, skip_left=2)) 125 | assert (t._ws_labels == [11, 3, 1, 2, 3, 1, 2, 12, 11, 12]).all() 126 | 127 | def test_apply_pattern_short(self): 128 | p = [1] 129 | t = timeline_10d() 130 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 131 | assert (t._ws_labels == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]).all() 132 | 133 | def test_apply_pattern_toolong(self): 134 | p = range(15) 135 | t = timeline_10d() 136 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 137 | assert (t._ws_labels == range(10)).all() 138 | 139 | def test_apply_pattern_toolong_skip(self): 140 | p = range(15) 141 | t = timeline_10d() 142 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=3)) 143 | assert (t._ws_labels == range(3, 13)).all() 144 | 145 | def test_apply_pattern_toolong_skip_more(self): 146 | p = range(15) 147 | t = timeline_10d() 148 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=10)) 149 | assert (t._ws_labels == [10, 11, 12, 13, 14, 0, 1, 2, 3, 4]).all() 150 | 151 | def test_apply_pattern_empty(self): 152 | p = [] 153 | t = timeline_10d(data=100) 154 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 155 | assert (t._ws_labels == [100]*10).all() 156 | 157 | 158 | @pytest.mark.skipif(TIMELINE_DEL_TEMP_OBJECTS, 159 | reason="__apply_pattern uses object that is deleted " 160 | "after the timeline has been __init__'ed") 161 | class TestApplyRememberingPattern(object): 162 | 163 | def test_apply_pattern_basic(self): 164 | p = RememberingPattern([1, 2, 3]) 165 | t = timeline_10d() 166 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 167 | assert (t._ws_labels == [1, 2, 3, 1, 2, 3, 1, 2, 3, 1]).all() 168 | 169 | def test_apply_pattern_skip(self): 170 | p = RememberingPattern([1, 2, 3]) 171 | t = timeline_10d() 172 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=2)) 173 | assert (t._ws_labels == [3, 1, 2, 3, 1, 2, 3, 1, 2, 3]).all() 174 | 175 | def test_apply_pattern_span_skip(self): 176 | p = RememberingPattern([1, 2, 3]) 177 | t = timeline_10d() 178 | t._Timeline__apply_pattern(p, _Span(1, 6, skip_left=2)) 179 | assert (t._ws_labels[1:7] == [3, 1, 2, 3, 1, 2]).all() 180 | 181 | def test_apply_pattern_short(self): 182 | p = RememberingPattern([1]) 183 | t = timeline_10d() 184 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 185 | assert (t._ws_labels == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]).all() 186 | 187 | def test_apply_pattern_toolong(self): 188 | p = RememberingPattern(range(15)) 189 | t = timeline_10d() 190 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 191 | assert (t._ws_labels == range(10)).all() 192 | 193 | def test_apply_pattern_toolong_skip(self): 194 | p = RememberingPattern(range(15)) 195 | t = timeline_10d() 196 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=3)) 197 | assert (t._ws_labels == range(3, 13)).all() 198 | 199 | def test_apply_pattern_toolong_skip_more(self): 200 | p = RememberingPattern(range(15)) 201 | t = timeline_10d() 202 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1, skip_left=10)) 203 | assert (t._ws_labels == [10, 11, 12, 13, 14, 0, 1, 2, 3, 4]).all() 204 | 205 | def test_apply_pattern_empty(self): 206 | p = RememberingPattern([]) 207 | t = timeline_10d(data=100) 208 | t._Timeline__apply_pattern(p, _Span(0, len(t.frame) - 1)) 209 | assert (t._ws_labels == [100]*10).all() 210 | 211 | def test_apply_pattern_with_memory(self): 212 | p = RememberingPattern([0, 1]) 213 | t = timeline_10d() 214 | t._Timeline__apply_pattern(p, _Span(0, 4)) 215 | t._Timeline__apply_pattern([9], _Span(5, 6)) 216 | t._Timeline__apply_pattern(p, _Span(7, 9)) 217 | assert (t._ws_labels == [0, 1, 0, 1, 0, 9, 9, 1, 0, 1]).all() 218 | 219 | def test_apply_pattern_with_memory_long(self): 220 | p = RememberingPattern([0, 1, 2, 3, 4, 5]) 221 | t = timeline_10d() 222 | t._Timeline__apply_pattern(p, _Span(0, 4)) 223 | t._Timeline__apply_pattern([9], _Span(5, 6)) 224 | t._Timeline__apply_pattern(p, _Span(7, 9)) 225 | assert (t._ws_labels == [0, 1, 2, 3, 4, 9, 9, 5, 0, 1]).all() 226 | 227 | def test_apply_pattern_with_memory_skip(self): 228 | p = RememberingPattern([0, 1]) 229 | t = timeline_10d() 230 | t._Timeline__apply_pattern(p, _Span(0, 4, skip_left=3)) 231 | t._Timeline__apply_pattern([9], _Span(5, 6)) 232 | t._Timeline__apply_pattern(p, _Span(7, 9)) 233 | assert (t._ws_labels == [1, 0, 1, 0, 1, 9, 9, 0, 1, 0]).all() 234 | 235 | def test_apply_pattern_with_memory_skip_long(self): 236 | p = RememberingPattern([0, 1, 2, 3, 4, 5]) 237 | t = timeline_10d() 238 | t._Timeline__apply_pattern(p, _Span(0, 4, skip_left=3)) 239 | t._Timeline__apply_pattern([9], _Span(5, 6)) 240 | t._Timeline__apply_pattern([9], _Span(5, 6)) 241 | t._Timeline__apply_pattern(p, _Span(7, 9)) 242 | assert (t._ws_labels == [3, 4, 5, 0, 1, 9, 9, 2, 3, 4]).all() 243 | 244 | def test_apply_pattern_with_memory_skip_2(self): 245 | # this situation is not natural as dangles do not appear in interior 246 | # subframes 247 | p = RememberingPattern([0, 1, 2, 3, 4, 5]) 248 | t = timeline_10d() 249 | t._Timeline__apply_pattern(p, _Span(0, 4, skip_left=3)) 250 | t._Timeline__apply_pattern([9], _Span(5, 6)) 251 | t._Timeline__apply_pattern(p, _Span(7, 9, skip_left=2)) 252 | assert (t._ws_labels == [3, 4, 5, 0, 1, 9, 9, 4, 5, 0]).all() 253 | -------------------------------------------------------------------------------- /timeboard/tests/test_timeboard.py: -------------------------------------------------------------------------------- 1 | import timeboard as tb 2 | import datetime 3 | import pytest 4 | import pandas as pd 5 | 6 | class TestVersion(object): 7 | 8 | def test_version(self): 9 | version = tb.read_from('VERSION.txt') 10 | assert version == tb.__version__ 11 | 12 | class TestTBConstructor(object): 13 | 14 | def test_tb_constructor_trivial(self): 15 | clnd = tb.Timeboard(base_unit_freq='D', 16 | start='01 Jan 2017', end='12 Jan 2017', 17 | layout=[1]) 18 | assert clnd._timeline.labels.eq([1]*12).all() 19 | assert clnd.start_time == datetime.datetime(2017, 1, 1, 0, 0, 0) 20 | assert clnd.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 21 | assert clnd.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 22 | assert clnd.base_unit_freq == 'D' 23 | 24 | def test_tb_constructor_empty_layout(self): 25 | clnd = tb.Timeboard(base_unit_freq='D', 26 | start='01 Jan 2017', end='12 Jan 2017', 27 | layout=[], 28 | ) 29 | assert clnd._timeline.labels.isnull().all() 30 | assert clnd.start_time == datetime.datetime(2017, 1, 1, 0, 0, 0) 31 | assert clnd.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 32 | assert clnd.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 33 | assert clnd.base_unit_freq == 'D' 34 | 35 | def test_tb_constructor_empty_layout_with_default_label(self): 36 | clnd = tb.Timeboard(base_unit_freq='D', 37 | start='01 Jan 2017', end='12 Jan 2017', 38 | layout=[], 39 | default_label=100) 40 | assert clnd._timeline.labels.eq([100]*12).all() 41 | assert clnd.start_time == datetime.datetime(2017, 1, 1, 0, 0, 0) 42 | assert clnd.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 43 | assert clnd.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 44 | assert clnd.base_unit_freq == 'D' 45 | 46 | def test_tb_constructor_trivial_with_amendments(self): 47 | clnd = tb.Timeboard(base_unit_freq='D', 48 | start='01 Jan 2017', end='12 Jan 2017', 49 | layout=[1], 50 | amendments={'11 Jan 2017': 2, 51 | '12 Jan 2017': 3}) 52 | assert clnd._timeline.labels.eq([1]*10 + [2,3]).all() 53 | assert clnd.start_time == datetime.datetime(2017, 1, 1, 0, 0, 0) 54 | assert clnd.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 55 | assert clnd.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 56 | assert clnd.base_unit_freq == 'D' 57 | 58 | def test_tb_constructor_amendments_outside(self): 59 | clnd = tb.Timeboard(base_unit_freq='D', 60 | start='01 Jan 2017', end='12 Jan 2017', 61 | layout=[1], 62 | amendments={'31 Dec 2016': 2, 63 | '12 Jan 2017': 3}) 64 | assert clnd._timeline.labels.eq([1]*11 + [3]).all() 65 | assert clnd.start_time == datetime.datetime(2017, 1, 1, 0, 0, 0) 66 | assert clnd.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 67 | assert clnd.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 68 | assert clnd.base_unit_freq == 'D' 69 | 70 | def test_tb_constructor_bad_layout(self): 71 | with pytest.raises(TypeError): 72 | tb.Timeboard(base_unit_freq='D', 73 | start='01 Jan 2017', end='12 Jan 2017', 74 | layout=1) 75 | 76 | def test_tb_constructor_duplicate_amendments(self): 77 | with pytest.raises(KeyError): 78 | tb.Timeboard(base_unit_freq='D', 79 | start='01 Jan 2017', end='12 Jan 2017', 80 | layout=[1], 81 | amendments={'02 Jan 2017 12:00': 2, 82 | '02 Jan 2017 15:15': 3}) 83 | 84 | def test_tb_constructor_bad_amendments(self): 85 | with pytest.raises(TypeError): 86 | tb.Timeboard(base_unit_freq='D', 87 | start='01 Jan 2017', end='12 Jan 2017', 88 | layout=[1], 89 | amendments=[0]) 90 | 91 | def test_tb_constructor_trivial_selector(self): 92 | clnd = tb.Timeboard(base_unit_freq='D', 93 | start='01 Jan 2017', end='12 Jan 2017', 94 | layout=[0, 1, 0, 2]) 95 | sdl = clnd.default_schedule 96 | selector = clnd.default_selector 97 | assert selector(clnd._timeline[1]) 98 | assert [selector(x) for x in clnd._timeline.labels] == [False, True] * 6 99 | assert (sdl.on_duty_index == [1, 3, 5, 7, 9, 11]).all() 100 | assert (sdl.off_duty_index == [0, 2, 4, 6, 8, 10]).all() 101 | 102 | def test_tb_constructor_trivial_custom_selector(self): 103 | 104 | def custom_selector(x): 105 | return x>1 106 | 107 | clnd = tb.Timeboard(base_unit_freq='D', 108 | start='01 Jan 2017', end='12 Jan 2017', 109 | layout=[0, 1, 0, 2], 110 | default_selector=custom_selector) 111 | sdl = clnd.default_schedule 112 | selector = clnd.default_selector 113 | assert not selector(clnd._timeline[1]) 114 | assert [selector(x) for x in clnd._timeline.labels] == [False, False, 115 | False, True] * 3 116 | assert (sdl.on_duty_index == [3, 7, 11]).all() 117 | assert (sdl.off_duty_index == [0, 1, 2, 4, 5, 6, 8, 9, 10]).all() 118 | 119 | 120 | class TestTBConstructorWithOrgs(object): 121 | 122 | def test_tb_constructor_week5x8(self): 123 | week5x8 = tb.Organizer(marker='W', structure=[[1, 1, 1, 1, 1, 0, 0]]) 124 | amendments = pd.Series(index=pd.date_range(start='01 Jan 2017', 125 | end='10 Jan 2017', 126 | freq='D'), 127 | data=0).to_dict() 128 | clnd = tb.Timeboard(base_unit_freq='D', 129 | start='28 Dec 2016', end='02 Apr 2017', 130 | layout=week5x8, 131 | amendments=amendments) 132 | 133 | assert clnd.start_time == datetime.datetime(2016, 12, 28, 0, 0, 0) 134 | assert clnd.end_time > datetime.datetime(2017, 4, 2, 23, 59, 59) 135 | assert clnd.end_time < datetime.datetime(2017, 4, 3, 0, 0, 0) 136 | assert clnd.base_unit_freq == 'D' 137 | assert clnd('28 Dec 2016').is_on_duty() 138 | assert clnd('30 Dec 2016').is_on_duty() 139 | assert clnd('31 Dec 2016').is_off_duty() 140 | assert clnd('01 Jan 2017').is_off_duty() 141 | assert clnd('10 Jan 2017').is_off_duty() 142 | assert clnd('11 Jan 2017').is_on_duty() 143 | assert clnd('27 Mar 2017').is_on_duty() 144 | assert clnd('31 Mar 2017').is_on_duty() 145 | assert clnd('01 Apr 2017').is_off_duty() 146 | assert clnd('02 Apr 2017').is_off_duty() 147 | 148 | 149 | class TestTimeboardSchedules(object): 150 | 151 | def test_tb_add_schedule(self): 152 | clnd = tb.Timeboard(base_unit_freq='D', 153 | start='31 Dec 2016', end='12 Jan 2017', 154 | #layout=[0, 1, 0, 0, 2, 0]) 155 | layout=['O', 'A', 'O', 'O', 'B', 'O']) 156 | assert len(clnd.schedules) == 1 157 | assert 'on_duty' in clnd.schedules 158 | clnd.add_schedule(name='sdl1', selector=lambda x: x == 'B') 159 | clnd.add_schedule(name='sdl2', selector=lambda x: x == 'C') 160 | assert len(clnd.schedules) == 3 161 | 162 | assert 'sdl1' in clnd.schedules 163 | sdl1 = clnd.schedules['sdl1'] 164 | assert sdl1.name == 'sdl1' 165 | assert not sdl1.is_on_duty(1) 166 | assert sdl1.is_on_duty(4) 167 | 168 | assert 'sdl2' in clnd.schedules 169 | sdl2 = clnd.schedules['sdl2'] 170 | assert sdl2.name == 'sdl2' 171 | assert not sdl2.is_on_duty(1) 172 | assert not sdl2.is_on_duty(4) 173 | 174 | assert clnd.default_schedule.name == 'on_duty' 175 | assert clnd.default_schedule.is_on_duty(1) 176 | assert clnd.default_schedule.is_on_duty(4) 177 | 178 | def test_tb_drop_schedule(self): 179 | clnd = tb.Timeboard(base_unit_freq='D', 180 | start='31 Dec 2016', end='12 Jan 2017', 181 | layout=[0, 1, 0, 0, 2, 0]) 182 | clnd.add_schedule(name='sdl', selector=lambda x: x > 1) 183 | assert len(clnd.schedules) == 2 184 | sdl = clnd.schedules['sdl'] 185 | clnd.drop_schedule(sdl) 186 | assert len(clnd.schedules) == 1 187 | with pytest.raises(KeyError): 188 | clnd.schedules['sdl'] 189 | # object itself continues to exists while referenced 190 | assert not sdl.is_on_duty(1) 191 | 192 | def test_tb_schedule_names(self): 193 | clnd = tb.Timeboard(base_unit_freq='D', 194 | start='31 Dec 2016', end='12 Jan 2017', 195 | layout=[0, 1, 0, 0, 2, 0]) 196 | clnd.add_schedule(name=1, selector=lambda x: x > 1) 197 | assert len(clnd.schedules) == 2 198 | assert clnd.schedules['1'].name == '1' 199 | with pytest.raises(KeyError): 200 | clnd.add_schedule(name='1', selector=lambda x: x > 2) 201 | 202 | def test_tb_bad_schedule(self): 203 | clnd = tb.Timeboard(base_unit_freq='D', 204 | start='31 Dec 2016', end='12 Jan 2017', 205 | layout=[0, 1, 0, 0, 2, 0]) 206 | with pytest.raises((ValueError, AttributeError)): 207 | clnd.add_schedule(name='sdl', selector='selector') 208 | with pytest.raises(TypeError): 209 | clnd.add_schedule(name='sdl', selector=lambda x,y: x+y) 210 | 211 | 212 | class TestTimeboardWorktime(object): 213 | 214 | def test_tb_default_worktime_source(self): 215 | clnd = tb.Timeboard(base_unit_freq='D', 216 | start='31 Dec 2016', end='12 Jan 2017', 217 | layout=[0, 1, 0, 0, 2, 0]) 218 | assert clnd.worktime_source == 'duration' 219 | 220 | def test_tb_set_worktime_source(self): 221 | clnd = tb.Timeboard(base_unit_freq='D', 222 | start='31 Dec 2016', end='12 Jan 2017', 223 | layout=[0, 1, 0, 0, 2, 0], 224 | worktime_source='labels') 225 | assert clnd.worktime_source == 'labels' 226 | 227 | def test_tb_bad_worktime_source(self): 228 | with pytest.raises(ValueError): 229 | tb.Timeboard(base_unit_freq='D', 230 | start='31 Dec 2016', end='12 Jan 2017', 231 | layout=[0, 1, 0, 0, 2, 0], 232 | worktime_source='bad_source') 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | #TODO: test timeboards with multiplied freqs 251 | 252 | class TestTimeboardToDataFrame(object): 253 | 254 | def test_timeboard_to_dataframe(self): 255 | clnd = tb.Timeboard(base_unit_freq='D', 256 | start='01 Jan 2017', end='12 Jan 2017', 257 | layout=[0, 1, 0, 2]) 258 | clnd.add_schedule('my_schedule', lambda x: True) 259 | df = clnd.to_dataframe() 260 | assert len(df) == 12 261 | # we are not hardcoding the list of columns here; 262 | # however, there must be at least 5 columns: two showing the start 263 | # and the end times of workshifts, one for the labels, 264 | # and two for the schedules 265 | assert len(list(df.columns)) >=5 266 | assert 'my_schedule' in list(df.columns) 267 | 268 | def test_timeboard_to_dataframe_selected_ws(self): 269 | clnd = tb.Timeboard(base_unit_freq='D', 270 | start='01 Jan 2017', end='12 Jan 2017', 271 | layout=[0, 1, 0, 2]) 272 | df = clnd.to_dataframe(1, 5) 273 | assert len(df) == 5 274 | 275 | def test_timeboard_to_dataframe_reversed_ws(self): 276 | clnd = tb.Timeboard(base_unit_freq='D', 277 | start='01 Jan 2017', end='12 Jan 2017', 278 | layout=[0, 1, 0, 2]) 279 | # This is ok. This way an empty df for a void interval is created. 280 | df = clnd.to_dataframe(5, 1) 281 | assert df.empty 282 | 283 | def test_timeboard_to_dataframe_bad_locations(self): 284 | clnd = tb.Timeboard(base_unit_freq='D', 285 | start='01 Jan 2017', end='12 Jan 2017', 286 | layout=[0, 1, 0, 2]) 287 | with pytest.raises(AssertionError): 288 | clnd.to_dataframe(1, 12) 289 | with pytest.raises(AssertionError): 290 | clnd.to_dataframe(12, 1) 291 | with pytest.raises(AssertionError): 292 | clnd.to_dataframe(-1, 5) 293 | with pytest.raises(AssertionError): 294 | clnd.to_dataframe(5, -1) 295 | 296 | 297 | -------------------------------------------------------------------------------- /timeboard/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from timeboard.utils import to_iterable 2 | 3 | class TestToIterable(object): 4 | 5 | def test_to_iterable(self): 6 | assert to_iterable(None) is None 7 | assert to_iterable(1) == [1] 8 | assert to_iterable('123') == ['123'] 9 | assert to_iterable([1]) == [1] 10 | assert to_iterable(['112']) == ['112'] 11 | assert to_iterable(['112', '345']) == ['112', '345'] 12 | assert to_iterable({1, 2, 3}) == {1, 2, 3} 13 | assert to_iterable(1 == 3) == [False] 14 | -------------------------------------------------------------------------------- /timeboard/tests/test_workshift_compound.py: -------------------------------------------------------------------------------- 1 | import timeboard as tb 2 | from timeboard.exceptions import OutOfBoundsError 3 | from timeboard.timeboard import _Location, LOC_WITHIN, OOB_LEFT, OOB_RIGHT 4 | from timeboard.core import get_timestamp, Organizer 5 | 6 | import datetime 7 | import pytest 8 | import pandas as pd 9 | 10 | def tb_12_days(): 11 | org = Organizer(marker='W', structure=[100, [0, 0, 1, 1]]) 12 | return tb.Timeboard(base_unit_freq='D', 13 | start='31 Dec 2016', end='12 Jan 2017', 14 | layout=org) 15 | # Dates 31 01 02 03 04 05 06 07 08 09 10 11 12 16 | # Labels 100 - 0 0 1 1 0 0 1 100 - - - 17 | # WS No. 0 1 2 3 4 5 6 7 8 18 | # WS Idx 0 2 3 4 5 6 7 8 9 19 | 20 | class TestWorkshiftCompoundConstructor(object): 21 | 22 | def test_locate(self): 23 | clnd = tb_12_days() 24 | loc = clnd._locate('31 Dec 2016') 25 | assert loc == _Location(0, LOC_WITHIN) 26 | loc = clnd._locate('01 Jan 2017') 27 | assert loc == _Location(0, LOC_WITHIN) 28 | loc = clnd._locate('04 Jan 2017') 29 | assert loc == _Location(3, LOC_WITHIN) 30 | loc = clnd._locate('11 Jan 2017') 31 | assert loc == _Location(8, LOC_WITHIN) 32 | loc = clnd._locate('12 Jan 2017') 33 | assert loc == _Location(8, LOC_WITHIN) 34 | 35 | def test_locate_outside(self): 36 | clnd = tb_12_days() 37 | loc = clnd._locate('13 Jan 2017') 38 | assert loc == _Location(None, OOB_RIGHT) 39 | loc = clnd._locate('30 Dec 2016') 40 | assert loc == _Location(None, OOB_LEFT) 41 | 42 | def test_workshift_ordinary_constructor(self): 43 | clnd = tb_12_days() 44 | ws = clnd.get_workshift('04 Jan 2017') 45 | assert ws._loc == 3 46 | assert ws.start_time == datetime.datetime(2017, 1, 4, 0, 0, 0) 47 | assert ws.end_time > datetime.datetime(2017, 1, 4, 23, 59, 59) 48 | assert ws.end_time < datetime.datetime(2017, 1, 5, 0, 0, 0) 49 | assert ws.label == 1 50 | assert ws.is_on_duty 51 | assert ws.duration == 1 52 | 53 | wsx = clnd('04 Jan 2017') 54 | assert wsx._loc == ws._loc 55 | 56 | def test_workshift_compound_constructor(self): 57 | clnd = tb_12_days() 58 | ws = clnd.get_workshift('11 Jan 2017') 59 | assert ws._loc == 8 60 | assert ws.start_time == datetime.datetime(2017, 1, 9, 0, 0, 0) 61 | assert ws.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 62 | assert ws.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 63 | assert ws.label == 100 64 | assert ws.is_on_duty 65 | assert ws.duration == 4 66 | 67 | wsx = clnd('11 Jan 2017') 68 | assert wsx._loc == ws._loc 69 | assert wsx._label == ws._label 70 | 71 | def test_workshift_constructor_compound_at_start(self): 72 | clnd = tb_12_days() 73 | ws = clnd.get_workshift('31 Dec 2016') 74 | assert ws._loc == 0 75 | assert ws.start_time == datetime.datetime(2016, 12, 31, 0, 0, 0) 76 | assert ws.end_time > datetime.datetime(2017, 1, 1, 23, 59, 59) 77 | assert ws.end_time < datetime.datetime(2017, 1, 2, 0, 0, 0) 78 | assert ws.label == 100 79 | assert ws.is_on_duty 80 | assert ws.duration == 2 81 | 82 | wsx = clnd('31 Dec 2016') 83 | assert wsx._loc == ws._loc 84 | assert wsx._label == ws._label 85 | 86 | def test_workshift_constructor_compound_at_end(self): 87 | clnd = tb_12_days() 88 | ws = clnd.get_workshift('12 Jan 2017') 89 | assert ws._loc == 8 90 | assert ws.start_time == datetime.datetime(2017, 1, 9, 0, 0, 0) 91 | assert ws.end_time > datetime.datetime(2017, 1, 12, 23, 59, 59) 92 | assert ws.end_time < datetime.datetime(2017, 1, 13, 0, 0, 0) 93 | assert ws.label == 100 94 | assert ws.is_on_duty 95 | assert ws.duration == 4 96 | 97 | wsx = clnd('12 Jan 2017') 98 | assert wsx._loc == ws._loc 99 | assert wsx._label == ws._label 100 | 101 | 102 | class TestRollForwardCompound(object): 103 | 104 | def test_rollforward_trivial_0_to_self(self): 105 | clnd = tb_12_days() 106 | ws = clnd('31 Dec 2016') 107 | new_ws = ws.rollforward() 108 | assert new_ws._loc == 0 109 | ws = clnd('04 Jan 2017') 110 | new_ws = ws.rollforward() 111 | assert new_ws._loc == 3 112 | ws = clnd('11 Jan 2017') 113 | new_ws = ws.rollforward() 114 | assert new_ws._loc == 8 115 | ws = clnd('12 Jan 2017') 116 | new_ws = ws.rollforward() 117 | assert new_ws._loc == 8 118 | 119 | def test_rollforward_trivial_0_to_next(self): 120 | org = Organizer(marker='W', structure=[False, [0, 0, 1, 1]]) 121 | clnd = tb.Timeboard(base_unit_freq='D', 122 | start='31 Dec 2016', end='12 Jan 2017', 123 | layout=org) 124 | ws = clnd('31 Dec 2016') 125 | new_ws = ws.rollforward() 126 | assert new_ws._loc == 3 127 | ws = clnd('03 Jan 2017') 128 | new_ws = ws.rollforward() 129 | assert new_ws._loc == 3 130 | 131 | def test_rollforward_on_0(self): 132 | clnd = tb_12_days() 133 | ws = clnd('31 Dec 2016') 134 | new_ws_list = [] 135 | for duty in ('on', 'off', 'same', 'alt', 'any'): 136 | new_ws_list.append(ws.rollforward(duty=duty)._loc) 137 | assert new_ws_list == [0, 1, 0, 1, 0] 138 | assert ws.rollforward(duty='off').start_time == get_timestamp('02 Jan ' 139 | '2017') 140 | ws1 = clnd('01 Jan 2017') 141 | assert ws1.rollforward(duty='off').start_time == get_timestamp('02 Jan ' 142 | '2017') 143 | 144 | def test_rollforward_off_0(self): 145 | org = Organizer(marker='W', structure=[False, [0, 0, 1, 1]]) 146 | clnd = tb.Timeboard(base_unit_freq='D', 147 | start='31 Dec 2016', end='12 Jan 2017', 148 | layout=org) 149 | ws = clnd('31 Dec 2016') 150 | new_ws_list = [] 151 | for duty in ('on', 'off', 'same', 'alt', 'any'): 152 | new_ws_list.append(ws.rollforward(duty=duty)._loc) 153 | assert new_ws_list == [3, 0, 0, 3, 0] 154 | 155 | def test_rollforward_on_n(self): 156 | clnd = tb_12_days() 157 | ws = clnd('31 Dec 2016') 158 | new_ws_list = [] 159 | for duty in ('on', 'off', 'same', 'alt', 'any'): 160 | new_ws_list.append(ws.rollforward(steps=2, duty=duty)._loc) 161 | assert new_ws_list == [4, 5, 4, 5, 2] 162 | 163 | def test_rollforward_off_n(self): 164 | org = Organizer(marker='W', structure=[False, [0, 0, 1, 1]]) 165 | clnd = tb.Timeboard(base_unit_freq='D', 166 | start='31 Dec 2016', end='12 Jan 2017', 167 | layout=org) 168 | ws = clnd('31 Dec 2016') 169 | new_ws_list = [] 170 | for duty in ('on', 'off', 'same', 'alt', 'any'): 171 | new_ws_list.append(ws.rollforward(steps=2, duty=duty)._loc) 172 | assert new_ws_list == [7, 2, 2, 7, 2] 173 | 174 | def test_rollforward_on_n_negative_from_last_element(self): 175 | clnd = tb_12_days() 176 | ws = clnd('11 Jan 2017') 177 | assert ws.rollforward(steps=-2, duty='on')._loc == 4 178 | assert ws.rollforward(steps=-2, duty='any')._loc == 6 179 | with pytest.raises(OutOfBoundsError): 180 | ws.rollforward(steps=-2, duty='off') 181 | 182 | 183 | class TestRollBackCompound(object): 184 | 185 | def test_rollback_trivial_0_to_self(self): 186 | clnd = tb_12_days() 187 | ws = clnd('11 Jan 2017') 188 | new_ws = ws.rollback() 189 | assert new_ws._loc == 8 190 | ws = clnd('12 Jan 2017') 191 | new_ws = ws.rollback() 192 | assert new_ws._loc == 8 193 | 194 | 195 | def test_rollback_trivial_0_to_next(self): 196 | clnd = tb_12_days() 197 | ws = clnd('02 Jan 2017') 198 | new_ws = ws.rollback() 199 | assert new_ws._loc == 0 200 | assert new_ws.start_time == get_timestamp('31 Dec 2016') 201 | 202 | def test_rollback_on_0(self): 203 | clnd = tb_12_days() 204 | ws = clnd('10 Jan 2017') 205 | new_ws_list = [] 206 | for duty in ('on', 'off', 'same', 'alt', 'any'): 207 | new_ws_list.append(ws.rollback(duty=duty)._loc) 208 | assert new_ws_list == [8, 6, 8, 6, 8] 209 | 210 | def test_rollback_off_0(self): 211 | org = Organizer(marker='W', structure=[False, [0, 0, 1, 1]]) 212 | clnd = tb.Timeboard(base_unit_freq='D', 213 | start='31 Dec 2016', end='12 Jan 2017', 214 | layout=org) 215 | ws = clnd('12 Jan 2017') 216 | new_ws_list = [] 217 | for duty in ('on', 'off', 'same', 'alt', 'any'): 218 | new_ws_list.append(ws.rollback(duty=duty)._loc) 219 | assert new_ws_list == [7, 8, 8, 7, 8] 220 | 221 | def test_rollback_on_n(self): 222 | clnd = tb_12_days() 223 | ws = clnd('11 Jan 2017') 224 | new_ws_list = [] 225 | for duty in ('on', 'off', 'same', 'alt', 'any'): 226 | new_ws_list.append(ws.rollback(steps=2, duty=duty)._loc) 227 | assert new_ws_list == [4, 2, 4, 2, 6] 228 | 229 | def test_rollback_off_n(self): 230 | clnd = tb_12_days() 231 | ws = clnd('07 Jan 2017') 232 | new_ws_list = [] 233 | for duty in ('on', 'off', 'same', 'alt', 'any'): 234 | new_ws_list.append(ws.rollback(steps=2, duty=duty)._loc) 235 | assert new_ws_list == [0, 2, 2, 0, 4] 236 | 237 | def test_rollback_on_n_negative(self): 238 | clnd = tb_12_days() 239 | ws = clnd('05 Jan 2017') 240 | new_ws_list = [] 241 | for duty in ('on', 'off', 'same', 'alt', 'any'): 242 | new_ws_list.append(ws.rollback(steps=-2, duty=duty)._loc) 243 | assert new_ws_list == [8, 6, 8, 6, 6] 244 | 245 | def test_rollback_off_n_negative(self): 246 | clnd = tb_12_days() 247 | ws = clnd('03 Jan 2017') 248 | new_ws_list = [] 249 | for duty in ('on', 'off', 'same', 'alt', 'any'): 250 | new_ws_list.append(ws.rollback(steps=-2, duty=duty)._loc) 251 | assert new_ws_list == [4, 6, 6, 4, 4] 252 | 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /timeboard/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | import six 4 | 5 | 6 | try: 7 | from collections.abc import Iterable, Mapping 8 | except ImportError: 9 | from collections import Iterable, Mapping 10 | 11 | 12 | try: 13 | _pandas_is_subperiod = pd.tseries.frequencies.is_subperiod 14 | except AttributeError: 15 | _pandas_is_subperiod = pd._libs.tslibs.frequencies.is_subperiod 16 | 17 | try: 18 | _ = pd.Series.to_numpy 19 | except AttributeError: 20 | nonzero = np.nonzero 21 | else: 22 | def _nonzero(a): 23 | if isinstance(a, pd.Series): 24 | return np.nonzero(a.to_numpy()) 25 | else: 26 | return np.nonzero(a) 27 | 28 | nonzero = _nonzero 29 | 30 | 31 | 32 | def is_string(obj): 33 | return isinstance(obj, six.string_types) 34 | 35 | def is_iterable(obj): 36 | return (isinstance(obj, Iterable) and 37 | not is_string(obj)) 38 | 39 | 40 | def to_iterable(x): 41 | if x is None: 42 | return x 43 | if is_iterable(x): 44 | return x 45 | else: 46 | return [x] 47 | 48 | def is_dict(obj): 49 | return isinstance(obj, Mapping) 50 | 51 | def is_null(x): 52 | return pd.isnull(x) 53 | 54 | def assert_value(x, value_render_function, value_test_function, title='Parameter'): 55 | if value_render_function is not None: 56 | x = value_render_function(x) 57 | if value_test_function is not None: 58 | assert value_test_function(x), "Got invalid {} = {!r}".format(title, x) 59 | return x 60 | -------------------------------------------------------------------------------- /timeboard/when.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | from dateutil.easter import easter 4 | 5 | # import timeit 6 | # timers1 = [] 7 | # timers2 = [] 8 | # timers3 = [] 9 | 10 | def from_start_of_each(pi, normalize_by=None, **kwargs): 11 | """Calculate point in time specified by offset. 12 | 13 | To the start time of each period in the period index `pi` add an offset 14 | specified by the supplied keyword arguments. If the resulting timestamp 15 | is not within the period, silently omit this result from the returned array. 16 | 17 | Parameters 18 | ---------- 19 | pi : pandas.PeriodIndex 20 | normalize_by : str (pandas time frequency), optional 21 | If given, snap results to the start time of the `normalize_by` 22 | periods. 23 | **kwargs 24 | keyword arguments for pandas.DateOffset 25 | 26 | Returns 27 | ------- 28 | pandas.DatetimeIndex 29 | 30 | """ 31 | offset = pd.DateOffset(**kwargs) 32 | testtime = pd.Timestamp('01 Jan 2004') # longest month of longest year 33 | if testtime + offset < testtime: 34 | # with negative offset all results will fall out of their periods 35 | return pd.DatetimeIndex([]) 36 | 37 | start_times = pi.to_timestamp(how='start', freq='S') 38 | end_times = pi.to_timestamp(how='end', freq='S') 39 | 40 | 41 | # result = start_times + offset 42 | # The above raises VallueError in pandas > 0.22. 43 | # https://github.com/pandas-dev/pandas/issues/26258 44 | # Workaround: 45 | result = pd.DatetimeIndex([t + offset for t in start_times]) 46 | 47 | if normalize_by is not None: 48 | result = pd.PeriodIndex(result, 49 | freq=normalize_by).to_timestamp(how='start') 50 | result = result[result <= end_times] 51 | # Generally we should also filter result with [result >= start_times] 52 | # since normalize_by frequency can be anything, incl. having period larger 53 | # than pi frequency (i.e. pi freq='D', normalize_by='M'). In this case. 54 | # despite of positive (future-directed) offset, normalization can throw a 55 | # point in result in the past beyond start_times. 56 | # However this does not make sense (at least, in the context of this 57 | # package), and this function is always called from within this package 58 | # with normalize_by=_Frame.base_unit_freq. 59 | # Therefore, we'd rather save time on filtering. 60 | return result 61 | 62 | 63 | def nth_weekday_of_month(pi, month, week, weekday, shift=None, **kwargs): 64 | """Calculate nth weekday of a month. 65 | 66 | For each element of the period index `pi` take the month when this 67 | element begins. Starting from this point go to the M-th month (number M is 68 | given by `month`) and find the N-th `weekday` within that month 69 | (number N is given by `week`). If `shift` is specified, 70 | shift the result by the corresponding number of days. Return an 71 | array of timestamps for the start times of the days found. 72 | Duplicates are removed. If, for a particular element of `pi`, 73 | the requested day does not exist within the month, the result for this 74 | element is silently omitted from the returned array. 75 | 76 | Parameters 77 | ---------- 78 | pi : pandas.PeriodIndex 79 | month : int 1..12 80 | 1 refers to the month when the current element of `pi` begins, 81 | 2 refers to the next month, and so on. If the current element of `pi` 82 | points to the start of a year, then 1 is January, 12 is December 83 | of this year. 84 | week : int -5..5 85 | Use ``week=1`` to find the first weekday in the month, ``week=-1`` 86 | to find the last. ``week=0`` is not allowed. 87 | weekday : int 1..7 88 | Monday is 1, Sunday is 7. 89 | shift : int, optional 90 | Number of days to add to the calculated dates, may be negative. 91 | **kwargs 92 | not used; it is here for parameter compatibility with other functions 93 | of this module. 94 | 95 | Returns 96 | ------- 97 | pandas.DatetimeIndex 98 | 99 | """ 100 | # TODO: (Prio: LOW) Optimize performance 101 | # Creating model timeline '3-DoW-per-M' (compound workshifts on daily frame 102 | # with marks on 3 weekdays per month) takes ~1 sec for 100 years, 103 | # ~1,8 sec for 278 years. 104 | # Counting per workshift it is more than 100 times slower than 'CC-2-8-18' 105 | # model which uses `from_start_of_each`. However, in practice 106 | # weekday-of-month marks occur with a frequency that is ~100 times lower 107 | # than that of 'CC-2-8-18', so in a practical case these markers will 108 | # be on par with each other. 109 | # Speed profile of this function: 110 | # @timer1 : 24% 111 | # @timer2 : 8% 112 | # @timer3 : 68% 113 | 114 | # timer0 = timeit.default_timer() 115 | assert month in range(1, 13) 116 | assert weekday in range(1, 8) 117 | assert week in range(1, 6) or week in range(-5, 0) 118 | if shift is None: 119 | shift = 0 120 | 121 | dti = pi.asfreq(freq='M', how='START').drop_duplicates().to_timestamp( 122 | how='start') 123 | m_start_times = dti + pd.tseries.offsets.MonthBegin(month - 1, 124 | normalize=True) 125 | m_end_times = dti + pd.tseries.offsets.MonthEnd(month, normalize=True) 126 | 127 | # timer1 = timeit.default_timer() 128 | # make sure we are inside put periods pi - this check makes sense when 129 | # pi.freq is based on 'M' 130 | pi_end_times = pi.to_timestamp(how='end') 131 | m_start_times = m_start_times[m_start_times <= pi_end_times] 132 | m_end_times = m_end_times[m_end_times <= pi_end_times] 133 | 134 | # timer2 = timeit.default_timer() 135 | if week > 0: 136 | off_by_one_bug_flag = m_start_times.weekday == weekday - 1 137 | week_factors = week * np.ones(len(m_start_times), 138 | dtype=np.int8) - off_by_one_bug_flag 139 | week_offsets = week_factors * pd.tseries.offsets.Week( 140 | weekday=weekday - 1, normalize=True) 141 | dtw = pd.DatetimeIndex( 142 | [m_start_times[i] + week_offsets[i] 143 | for i in range(len(m_start_times))]) 144 | 145 | dtw = dtw[dtw <= m_end_times] 146 | 147 | else: 148 | off_by_one_bug_flag = m_end_times.weekday == weekday - 1 149 | week_factors = week * np.ones(len(m_end_times), 150 | dtype=np.int8) + off_by_one_bug_flag 151 | week_offsets = week_factors * pd.tseries.offsets.Week( 152 | weekday=weekday - 1, normalize=True) 153 | dtw = pd.DatetimeIndex( 154 | [m_end_times[i] + week_offsets[i] 155 | for i in range(len(m_end_times))]) 156 | dtw = dtw[dtw >= m_start_times] 157 | 158 | # timer3 = timeit.default_timer() 159 | # timers1.append(timer1 - timer0) 160 | # timers2.append(timer2 - timer1) 161 | # timers3.append(timer3 - timer2) 162 | 163 | return dtw + pd.DateOffset(days=shift) 164 | 165 | 166 | def from_easter(pi, easter_type='western', normalize_by=None, shift=None, 167 | **kwargs): 168 | """Calculate point in time related to Easter. 169 | 170 | In each period in the period index `pi` find the start time of the day of 171 | Easter and add an offset specified by the supplied keyword arguments. 172 | If the resulting timestamp is not within the period, silently omit this 173 | result from the returned array. 174 | 175 | Parameters 176 | ---------- 177 | pi : pandas.PeriodIndex 178 | easter_type : {``'western'``, ``'orthodox'``}, optional (default ``'western'``) 179 | normalize_by : str (pandas time frequency), optional 180 | If given, snap results to the start time of the `normalize_by` 181 | periods. 182 | kwargs : 183 | keyword arguments for pandas.DateOffset 184 | 185 | Returns 186 | ------- 187 | pandas.DatetimeIndex 188 | """ 189 | assert easter_type in ['western', 'orthodox'] 190 | if easter_type == 'western': 191 | _easter_type = 3 192 | elif easter_type == 'orthodox': 193 | _easter_type = 2 194 | 195 | if shift is not None: 196 | kwargs['days']=shift 197 | offset = pd.DateOffset(**kwargs) 198 | testtime = pd.Timestamp('01 Jan 2004') 199 | shift_to_future = testtime + offset >= testtime 200 | 201 | pi_start_times = pi.to_timestamp(how='start', freq='S') 202 | pi_end_times = pi.to_timestamp(how='end', freq='S') 203 | 204 | easter_dates = pd.DatetimeIndex([easter(y, _easter_type) for y in pi.year]) 205 | easter_dates = easter_dates[(easter_dates >= pi_start_times) & 206 | (easter_dates <= pi_end_times)] 207 | 208 | result = easter_dates + offset 209 | if normalize_by is not None: 210 | result = pd.PeriodIndex(result, 211 | freq=normalize_by).to_timestamp(how='start') 212 | if shift_to_future: 213 | result = result[result <= pi_end_times] 214 | else: 215 | result = result[result >= pi_start_times] 216 | # See comment about filtering for the other end of periods in 217 | # from_start_of_each function 218 | return result 219 | 220 | 221 | def from_easter_orthodox(pi, normalize_by=None, **kwargs): 222 | return from_easter(pi, easter_type='orthodox', normalize_by=normalize_by, 223 | **kwargs) 224 | 225 | from_easter_western = from_easter 226 | --------------------------------------------------------------------------------