Doing Calculations

Calendar calculations are performed either with an individual workshift or with an interval of workshifts.

Also, each calculation is based on a specific schedule in order to reason about duty statuses of workshifts involved.

Therefore, to carry out a calculation you need to obtain either a workshift or an interval and indicate which schedule you will be using.

The import statement to run the examples:

>>> import timeboard as tb

Obtaining a Workshift

Most likely you will want to identify a workshift by a timestamp which represents a point in time somewhere within the workshift. This is done by calling Timeboard.get_workshift() . The result returned will be an instance of Workshift.

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> clnd.get_workshift('01 Oct 2017')
Workshift(1) of 'D' at 2017-10-01

Even simpler, you get the same result by calling the instance of Timeboard which will invoke get_workshift() for you:

>>> clnd('01 Oct 2017')
Workshift(1) of 'D' at 2017-10-01

The argument passed to get_workshift() is Timestamp-like meaning it may be a timestamp, or a string convertible to timestamp, or an object which implement to_timestamp() method.

Alternatively, you can call Workshift() constructor directly if you know the workshift’s position on the timeline:

>>> tb.Workshift(clnd, 1)
Workshift(1) of 'D' at 2017-10-01

Every workshift comes with an attached schedule. This schedule is used in calculations carried out with this workshift unless it is overridden by schedule parameter of the method called to perform the calculation.

By default, a new workshift returned by get_workshift() method or Workshift() constructor receives the default schedule of the timeboard. You may attach a specific schedule to a new workshift by passing it in schedule parameter:

>>> sdl = clnd.add_schedule(name='my_schedule',
...                         selector=lambda label: label>1)
>>> clnd.get_workshift('01 Oct 2017', schedule=sdl)
Workshift(1, my_schedule) of 'D' at 2017-10-01
>>> tb.Workshift(clnd, 1, sdl)
Workshift(1, my_schedule) of 'D' at 2017-10-01

Note

You cannot obtain a workshift by calling the instance of Timeboard if you want to attach the schedule.` Use get_workshift() only.

Besides, a workshift can be obtained as a return value of a method performing a calculation over the timeboard. The schedule attached to this workshift is the schedule used by the method which has produced the workshift.

Workshift-based calculations

Method Result
is_on_duty() Find out if the workshift is on duty.
is_off_duty() Find out if the workshift is off duty.
worktime() Return workshift’s work time.
rollforward() Return a workshift by taking the specified number of steps toward the future.
+ (plus) Shortcut for rollforward()
rollback() Return a workshift by taking the specified number of steps toward the past.
- (minus) Shortcut for rollback()

Each of the above methods must use some schedule to identify workshift’s duty. The schedule is selected as follows:

  • if a schedule is explicitly given as method’s parameter, then use this schedule;
  • else use the schedule attached to this workshift when it has been instantiated;
  • if no schedule parameter was given to the workshift constructor, use the default schedule of the timeboard.

Determining duty

Examples:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> my_schedule = clnd.add_schedule(name='my_schedule',
                                    selector=lambda label: label>1)
>>> ws1 = clnd.get_workshift('01 Oct 2017')
>>> ws2 = clnd.get_workshift('01 Oct 2017', schedule=my_schedule)

ws1 and ws2 are the same workshift but with different schedules attached. ws1 comes with the default schedule of the timeboard, while ws2 is given my_schedule.

The workshift has label 1. Its duty under the default schedule:

>>> ws1.is_on_duty()
True
>>> ws2.is_on_duty(schedule=clnd.default_schedule)
True

and under my_schedule:

>>> ws1.is_on_duty(schedule=my_schedule)
False
>>> ws2.is_on_duty()
False

Obtaining work time

The source of the information about workshift’s work time is determined by Timeboard.worktime_source attribute.

Workshift.worktime() method returns the work time of the workshift if the duty value passed to the method corresponds to that of the workshift. Otherwise, it returns zero.

By default, the work time equals to workshift’s duration:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[4, 8, 4, 8],
...                     default_selector = lambda label: label>4)
>>> ws = tb.Workshift(clnd, 3)
>>> ws.label
8.0
>>> ws.duration
1
>>> ws.is_on_duty()
True
>>> ws.worktime()
1
>>> ws.worktime(duty='off')
0
>>> ws.worktime(duty='any')
1

In the example below, the work time is taken from the labels:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[4, 8, 4, 8],
...                     default_selector = lambda label: label>4,
...                     worktime_source = 'labels')
>>> ws = tb.Workshift(clnd, 3)
>>> ws.worktime()
8.0
>>> ws.worktime(duty='off')
0
>>> ws.worktime(duty='any')
8.0
>>> ws = tb.Workshift(clnd, 2)
>>> ws.label
4.0
>>> ws.is_off_duty()
True
>>> ws.worktime()
0
>>> ws.worktime(duty='off')
4.0
>>> ws.worktime(duty='any')
4.0

The query with duty='off' can be interpreted as “What is the work time for a worker who comes in when the main workforce is off duty?”

Rolling forward and back

The methods rollforward() and rollback() allow to identify the workshift which is located in a specified distance from the current workshift.

Actually, the methods do not roll, they step. The distance is measured in a number of steps with regard to a certain duty. It means that, when taking steps, the methods tread only on the workshifts with this duty, ignoring all others.

rollforward and rollback operate in the same manner except for the direction of time. You specify the number of steps and the duty to tread on. The default values are steps=0, duty='on'. The algorithm has two stages.

Stage 1. If you call a method omitting the number of steps (same as steps=0) it finds the closest workshift with the required duty.

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> print(clnd)
Timeboard of 'D': 2017-09-30 -> 2017-10-11

        ws_ref      start  duration        end  label  on_duty
loc
0   2017-09-30 2017-09-30         1 2017-09-30    0.0    False
1   2017-10-01 2017-10-01         1 2017-10-01    1.0     True
2   2017-10-02 2017-10-02         1 2017-10-02    0.0    False
3   2017-10-03 2017-10-03         1 2017-10-03    2.0     True
4   2017-10-04 2017-10-04         1 2017-10-04    0.0    False
5   2017-10-05 2017-10-05         1 2017-10-05    1.0     True
6   2017-10-06 2017-10-06         1 2017-10-06    0.0    False
7   2017-10-07 2017-10-07         1 2017-10-07    2.0     True
8   2017-10-08 2017-10-08         1 2017-10-08    0.0    False
9   2017-10-09 2017-10-09         1 2017-10-09    1.0     True
10  2017-10-10 2017-10-10         1 2017-10-10    0.0    False
11  2017-10-11 2017-10-11         1 2017-10-11    2.0     True
>>> clnd('05 Oct 2017').rollforward()
Workshift(5) of 'D' at 2017-10-05
>>> clnd('06 Oct 2017').rollforward()
Workshift(7) of 'D' at 2017-10-07
>>> clnd('05 Oct 2017').rollback()
Workshift(5) of 'D' at 2017-10-05
>>> clnd('06 Oct 2017').rollback()
Workshift(5) of 'D' at 2017-10-05

A method returns the self workshift if its duty is the same as the duty sought. Otherwise it returns the next (rollforward) or the previous (rollback) workshift with the required duty. The example above illustrates this behavior for duty='on', the example below - for duty='off':

>>> clnd('05 Oct 2017').rollforward(duty='off')
Workshift(6) of 'D' at 2017-10-06
>>> clnd('06 Oct 2017').rollforward(duty='off')
Workshift(6) of 'D' at 2017-10-06
>>> clnd('05 Oct 2017').rollback(duty='off')
Workshift(4) of 'D' at 2017-10-04
>>> clnd('06 Oct 2017').rollback(duty='off')
Workshift(6) of 'D' at 2017-10-06

The result of stage 1 is called the “zero step workshift”.

Stage 2. If the number of steps is not zero, a method proceeds to stage 2. After the zero step workshift has been found the method takes the required number of steps in the appropriate direction treading only on the workshifts with the specified duty:

>>> clnd('05 Oct 2017').rollforward(2)
Workshift(9) of 'D' at 2017-10-09
>>> clnd('06 Oct 2017').rollforward(2)
Workshift(11) of 'D' at 2017-10-11
>>> clnd('05 Oct 2017').rollback(2)
Workshift(1) of 'D' at 2017-10-01
>>> clnd('06 Oct 2017').rollback(2)
Workshift(1) of 'D' at 2017-10-01
>>> clnd('05 Oct 2017').rollforward(2, duty='off')
Workshift(10) of 'D' at 2017-10-10
>>> clnd('06 Oct 2017').rollforward(2, duty='off')
Workshift(10) of 'D' at 2017-10-10
>>> clnd('05 Oct 2017').rollback(2, duty='off')
Workshift(0) of 'D' at 2017-09-30
>>> clnd('06 Oct 2017').rollback(2, duty='off')
Workshift(2) of 'D' at 2017-10-02

Note

If you don’t care about the duty and want to step on all workshifts, use duty='any'. This way the zero step workshift is always self.

As with the other methods, you can override the workshift’s schedule in method’s parameter. Take note that the returned workshift will have the schedule used by the method:

>>> my_schedule = clnd.add_schedule(name='my_schedule',
...                                 selector=lambda label: label>1)
>>> ws = clnd('05 Oct 2017').rollforward(schedule=my_schedule)
>>> ws
Workshift(7, my_schedule) of 'D' at 2017-10-07
>>> ws.rollforward(1)
Workshift(11, my_schedule) of 'D' at 2017-10-11

Using operators + and -

You can add or subtract an integer number to/from a workshift. This is the same as calling, accordingly, rollforward or rollback with duty='on'.

# under default schedule
>>> clnd('05 Oct 2017') + 1
Workshift(7) of 'D' at 2017-10-07
>>> clnd('06 Oct 2017') - 1
Workshift(3) of 'D' at 2017-10-03
# under my_schedule
>>> ws = clnd.get_workshift('05 Oct 2017', schedule=my_schedule)
>>> ws + 1
Workshift(11, my_schedule) of 'D' at 2017-10-11

Caveats

steps can take a negative value. A method will step in the opposite direction, however, the algorithm of seeking the zero step workshift does not change. Therefore, the results of rollforward with negative steps and rollback with the same but positive value of steps may differ:

>>> clnd('06 Oct 2017').rollforward(-1)
Workshift(5) of 'D' at 2017-10-05
>>> clnd('06 Oct 2017').rollback(1)
Workshift(3) of 'D' at 2017-10-03

As the workshift of October 6 is off duty while method’s duty is “on” by default, the method must seek the zero step workshift. In doing that, rollforward looks in the future and finds October 7, while rollback looks in the past and find October 5. Then both methods take one “on-duty” step to the past and arrive at the results shown above.

The analogous behavior takes place with rollback(-n) and rollforward(n):

>>> clnd('05 Oct 2017').rollback(-1, duty='off')
Workshift(6) of 'D' at 2017-10-06
>>> clnd('05 Oct 2017').rollforward(1, duty='off')
Workshift(8) of 'D' at 2017-10-08

There is no such discrepancy if method’s duty is the same as workshift’s duty.

Obtaining an Interval

Method Result
Timeboard.get_interval() Create an interval with regard to specific points or periods of time: from two points in time, or from a calendar period, or specify the starting point and the length of the interval.
calling Timeboard instance Shortcut for Timeboard.get_interval()
Interval() Instantiate an interval from the first and the last workshifts or from their sequence numbers on the timeline.
Interval.overlap() Get an interval that is the intersection of two intervals.
* (multiplication) Shortcut for overlap()

To create an interval with regard to the specific points or periods of time call Timeboard.get_interval(). This method takes several combinations of parameters. In most cases, you can also use a shortcut by calling the instance of Timeboard which will invoke get_interval() for you.

Obtaining an interval from two points in time:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '15 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> clnd.get_interval(('02 Oct 2017', '08 Oct 2017'))
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

# Shortcut:

>>> clnd(('02 Oct 2017', '08 Oct 2017'))
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

The points in time come as a tuple of two values which are timestamps, or strings convertible to timestamps, or objects which implement to_timestamp() method.

Note that the points in time are not the boundaries of the interval but references to the first and the last workshifts of the interval. The points in time may be located anywhere within these workshifts. The following operation produces the same interval as the one above:

>>> clnd.get_interval(('02 Oct 2017 15:15', '08 Oct 2017 23:59'))
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

You may also pass a null value (such as None, NaN, or NaT) in place of a point in time. If the first element of the tuple is null, then the interval will start on the first workshift of the timeboard. If the second element is null, then the interval will end on the last workshift of the timeboard.

>>> clnd.get_interval((None, '08 Oct 2017 23:59'))
Interval((0, 8)): 'D' at 2017-09-30 -> 'D' at 2017-10-08 [9]
>>> clnd(('02 Oct 2017 15:15', None))
Interval((2, 15)): 'D' at 2017-10-02 -> 'D' at 2017-10-15 [14]

Building an interval of a specified length:

>>> clnd.get_interval('02 Oct 2017', length=7)
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

# Shortcut:

>>> clnd('02 Oct 2017', length=7)
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

Obtaining an interval from a calendar period:

>>> clnd.get_interval('05 Oct 2017', period='W')
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

# Shortcut:

>>> clnd('05 Oct 2017', period='W')
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

You can also build an interval directly from pandas.Period object but the shortcut is not available:

>>> import pandas as pd
>>> p = pd.Period('05 Oct 2017', freq='W')
>>> clnd.get_interval(p)
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

# NO shortcut!

>>> clnd(p)
Workshift(2) of 'D' at 2017-10-02

Finally, you can convert the entire timeline into the interval:

>>> clnd.get_interval()
Interval((0, 15)): 'D' at 2017-09-30 -> 'D' at 2017-10-15 [16]

# Shortcut:

>>> clnd()
Interval((0, 15)): 'D' at 2017-09-30 -> 'D' at 2017-10-15 [16]

Alternatively, you can call Interval() constructor directly if you have got the first and the last workshifts of the interval or know their sequence numbers on the timeline:

>>> ws_first = clnd('02 Oct 2017')
>>> ws_first
Workshift(2) of 'D' at 2017-10-02
>>> ws_last = clnd('08 Oct 2017')
>>> ws_last
Workshift(8) of 'D' at 2017-10-08
>>> tb.Interval(clnd, (ws_first, ws_last))
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]
>>> tb.Interval(clnd, (2, 8))
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

If you have got two intervals you can obtain an interval representing their intersection by calling overlap() on any of the two while passing the other as the parameter:

>>> ivl = tb.Interval(clnd, (2, 8))
>>> other = tb.Interval(clnd, (6, 10))
>>> ivl.overlap(other)
Interval((6, 8)): 'D' at 2017-10-06 -> 'D' at 2017-10-08 [3]

As a shortcut, * (multiplication) operator can be used:

>>> ivl * other
Interval((6, 8)): 'D' at 2017-10-06 -> 'D' at 2017-10-08 [3]

Every interval comes with an attached schedule. This schedule is used in calculations carried out with this interval unless it is overridden by schedule parameter of the method called to perform the calculation.

By default, a new interval receives the default schedule of the timeboard or inherits the schedule from its parent interval (i.e. from the interval on which overlap() has been called).

You may attach a specific schedule to a new interval by passing it in schedule parameter of any method you use to instantiate an interval:

>>> my_schedule = clnd.add_schedule(name='my_schedule',
...                                 selector=lambda label: label>1)
>>> clnd(('02 Oct 2017', '08 Oct 2017'), schedule=my_schedule)
Interval((2, 8), my_schedule): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]
>>> tb.Interval(clnd, (2,8), schedule=my_schedule)
Interval((2, 8), my_schedule): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]
>>> ivl.overlap(other, schedule=my_schedule)
Interval((6, 8), my_schedule): 'D' at 2017-10-06 -> 'D' at 2017-10-08 [3]

Caveats

There are a few caveats when you instantiate an interval from a calendar period.

Period extends beyond timeline

If the calendar period extends beyond the timeline, the interval is created as the intersection of the timeline and the calendar period.

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '15 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> clnd('Oct 2017', period='M')
Interval(1, 15): 'D' at 2017-10-01 -> 'D' at 2017-10-15 [15]

There is a parameter called clip_period which determines how this situation is handled. By default clip_period=True which results in the behavior illustrated above. If it is set to False, PartialOutOfBoundsError is raised:

>>> clnd('Oct 2017', period='M', clip_period=False)
-----------------------------------------------------------------------
PartialOutOfBoundsError               Traceback (most recent call last)
  ...
PartialOutOfBoundsError: The right bound of interval referenced by `Oct
2017` is outside Timeboard of 'D': 2017-09-30 -> 2017-10-15

Workshift straddles period boundary

Consider the following timeboard:

>>> clnd = tb.Timeboard('12H', '01 Oct 2017 21:00', '03 Oct 2017',
...                     layout=[1])
>>> print(clnd)

                 ws_ref               start  duration                 end
loc
0   2017-10-01 21:00:00 2017-10-01 21:00:00         1 2017-10-02 08:59:59
1   2017-10-02 09:00:00 2017-10-02 09:00:00         1 2017-10-02 20:59:59
2   2017-10-02 21:00:00 2017-10-02 21:00:00         1 2017-10-03 08:59:59

# columns "label" and "on_duty" have been omitted to fit the output
# to the page

Suppose we want to build an interval corresponding to the day of October 2. The workshifts at locations 0 and 2 straddle the boundaries of the day: they partly lay within October 2 and partly - without.

This ambiguity is solved with Timeboard.workshift_ref attribute. The workshift is considered a member of the calendar period where its reference timestamp belongs. By default, workshift’s reference timestamp is its start time (workshift_ref='start'). This is shown in column ‘workshift’ in the output above. Hence, workshift’s membership in a calendar period is determined by its start time. In our timeboard, consequently, workshift 0 belongs to October 1 while workshift 2 stays with October 2:

>>> clnd('02 Oct 2017', period='D')
Interval((1, 2)): '12H' at 2017-10-02 09:00 -> '12H' at 2017-10-02 21:00 [2]

Note the change in ‘workshift’ column in the output below when workshift_ref='end':

>>> clnd = tb.Timeboard('12H', '01 Oct 2017 21:00', '03 Oct 2017',
...                     layout=[1],
...                        ws_ref_ref='end')
>>> print(clnd)
Timeboard of '12H': 2017-10-01 21:00 -> 2017-10-02 21:00

                 ws_ref               start  duration                 end
loc
0   2017-10-02 08:59:59 2017-10-01 21:00:00         1 2017-10-02 08:59:59
1   2017-10-02 20:59:59 2017-10-02 09:00:00         1 2017-10-02 20:59:59
2   2017-10-03 08:59:59 2017-10-02 21:00:00         1 2017-10-03 08:59:59

# columns "label" and "on_duty" have been omitted to fit the output
# to the page

In this way, the end time of workshift is used as the indicator of period membership. Workshift 0 becomes a member of October 2 while workshift 2 goes with October 3:

>>> clnd('02 Oct 2017', period='D')
Interval((0, 1)): '12H' at 2017-10-01 21:00 -> '12H' at 2017-10-02 09:00 [2]

Due to the skewed workshift alignment, in both cases the boundaries of the produced interval do not coincide with the period given as the interval reference (the day of October 2).

Period too short for workshifts

In a corner case, you can try to obtain an interval from a period which is shorter than the workshifts in this area of the timeline. For example, in a timeboard with daily workshifts you seek an interval defined by an hour:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '05 Oct 2017', layout=[1])
>>> ivl = clnd.get_interval('02 Oct 2017 00:00', period='H')

However meaningless, this operation is handled according to the same logic of attributing a workshift to the period as discussed in the previous section. In this timeboard, the workshift reference time is its start time (the default setting). The hour starting at 02 Oct 2017 00:00 contains the reference time of the daily workshift of October 2. Technically, this one-day workshift is the member of the one-hour period and, therefore, becomes the only element of the sought interval:

>>> print(ivl)
Interval((2, 2)): 'D' at 2017-10-02 -> 'D' at 2017-10-02 [1]

        ws_ref      start  duration        end  label  on_duty
loc
2   2017-10-02 2017-10-02         1 2017-10-02    1.0     True

On the other hand, if you try to obtain an interval from another hour of the same day, VoidIntervalError will be raised as no workshift has its reference time within that hour:

>>> clnd.get_interval('02 Oct 2017 01:00', period='H')
---------------------------------------------------------------------------
VoidIntervalError                         Traceback (most recent call last)
  ...
VoidIntervalError: Attempted to create reversed or void interval
referenced by `02 Oct 2017 01:00` within Timeboard of 'D': 2017-09-30 ->
2017-10-05

Interval-based calculations

Method Result
nth() Find n-th workshift with the specified duty in the interval.
first() Find the first workshift with the specified duty in the interval.
last() Find the last workshift with the specified duty in the interval.
workshifts() Iterate through workshifts with the specified duty.
count() Count workshifts with the specified duty in the interval.
worktime() The total work time of workshifts with the specified duty.
what_portion_of() What portion of another interval this interval takes up.
/ (division) Shortcut for what_portion_of()
count_periods() How many calendar periods fit into the interval.

All methods are duty-aware meaning that they “see” only workshifts with the specified duty ignoring the others.

Each of the above methods must use some schedule to identify workshift’s duty. The schedule is selected as follows:

  • if a schedule is explicitly given as method’s parameter, then use this schedule;
  • else use the schedule attached to this interval when it has been instantiated;
  • if no schedule parameter was given to the interval constructor, use the default schedule of the timeboard.

Note

If you don’t care about the duty and want to take into account all workshifts in the interval, use duty='any'.

Seeking and counting workshifts

Create an interval for the examples:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '15 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> ivl = clnd(('02 Oct 2017', '08 Oct 2017'))
>>> print(ivl)
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

        ws_ref      start  duration        end  label  on_duty
loc
2   2017-10-02 2017-10-02         1 2017-10-02    0.0    False
3   2017-10-03 2017-10-03         1 2017-10-03    2.0     True
4   2017-10-04 2017-10-04         1 2017-10-04    0.0    False
5   2017-10-05 2017-10-05         1 2017-10-05    1.0     True
6   2017-10-06 2017-10-06         1 2017-10-06    0.0    False
7   2017-10-07 2017-10-07         1 2017-10-07    2.0     True
8   2017-10-08 2017-10-08         1 2017-10-08    0.0    False

Seeking and counting with duty='on':

>>> ivl.first()
Workshift(3) of 'D' at 2017-10-03
>>> ivl.nth(1)
Workshift(5) of 'D' at 2017-10-05
>>> ivl.last()
Workshift(7) of 'D' at 2017-10-07
>>> ivl.count()
3

With duty='off':

>>> ivl.first(duty='off')
Workshift(2) of 'D' at 2017-10-02
>>> ivl.nth(1, duty='off')
Workshift(4) of 'D' at 2017-10-04
>>> ivl.last(duty='off')
Workshift(8) of 'D' at 2017-10-08
>>> ivl.count(duty='off')
4

With duty='on' under another schedule:

>>> my_schedule = clnd.add_schedule(name='my_schedule',
...                                 selector=lambda label: label>1)
>>> ivl.nth(1, schedule=my_schedule)
Workshift(7, my_schedule) of 'D' at 2017-10-07
>>> ivl.count(duty='on', schedule=my_schedule)
2

Not taking the duty into account:

>>> ivl.first(duty='any')
Workshift(2) of 'D' at 2017-10-02
>>> ivl.nth(1, duty='any')
Workshift(3) of 'D' at 2017-10-03
>>> ivl.last(duty='any')
Workshift(8) of 'D' at 2017-10-08
>>> ivl.count(duty='any')
7

Itertating over the interval

workshifts() returns a generator that iterates over the interval and yields workshifts with the specified duty. By default, the duty is “on”.

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '15 Oct 2017',
...                     layout=[0, 1, 0, 2])
>>> ivl = clnd(('02 Oct 2017', '08 Oct 2017'))
>>> print(ivl)
Interval((2, 8)): 'D' at 2017-10-02 -> 'D' at 2017-10-08 [7]

        ws_ref      start  duration        end  label  on_duty
loc
2   2017-10-02 2017-10-02         1 2017-10-02    0.0    False
3   2017-10-03 2017-10-03         1 2017-10-03    2.0     True
4   2017-10-04 2017-10-04         1 2017-10-04    0.0    False
5   2017-10-05 2017-10-05         1 2017-10-05    1.0     True
6   2017-10-06 2017-10-06         1 2017-10-06    0.0    False
7   2017-10-07 2017-10-07         1 2017-10-07    2.0     True
8   2017-10-08 2017-10-08         1 2017-10-08    0.0    False
>>> for ws in ivl.workshifts():
...     print("{}\t{}".format(ws.start_time, ws.label))
2017-10-03 00:00:00     2
2017-10-05 00:00:00     1
2017-10-07 00:00:00     2
>>> list(ivl.workshifts(duty='off'))
[Workshift(2) of 'D' at 2017-10-02,
 Workshift(4) of 'D' at 2017-10-04,
 Workshift(6) of 'D' at 2017-10-06,
 Workshift(8) of 'D' at 2017-10-08]

You can also use the interval itself as a generator that yields every workshift of the interval. This is the same generator as returned by ivl.workshifts(duty='any').

>>> for ws in ivl:
...     print("{}\t{}".format(ws.start_time, ws.label))
2017-10-02 00:00:00 0
2017-10-03 00:00:00 2
2017-10-04 00:00:00 0
2017-10-05 00:00:00 1
2017-10-06 00:00:00 0
2017-10-07 00:00:00 2
2017-10-08 00:00:00 0
>>> list(ivl.workshifts(duty='any'))
[Workshift(2) of 'D' at 2017-10-02,
 Workshift(3) of 'D' at 2017-10-03,
 Workshift(4) of 'D' at 2017-10-04,
 Workshift(5) of 'D' at 2017-10-05,
 Workshift(6) of 'D' at 2017-10-06,
 Workshift(7) of 'D' at 2017-10-07,
 Workshift(8) of 'D' at 2017-10-08]

Measuring work time

The source of the information about workshifts’ work time is determined by Timeboard.worktime_source attribute.

Interval.worktime() method returns the sum of the work times of the workshifts with the specified duty. If the interval does not contain workshifts with this duty, the method returns zero.

By default, workshift’s work time equals to workshift’s duration:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[4, 8, 4, 8],
...                     default_selector = lambda label: label>4)
>>> ivl = tb.Interval(clnd, (1, 3))
>>> print (ivl)
Interval((1, 3)): 'D' at 2017-10-01 -> 'D' at 2017-10-03 [3]

        ws_ref      start  duration        end  label  on_duty
loc
1   2017-10-01 2017-10-01         1 2017-10-01    8.0     True
2   2017-10-02 2017-10-02         1 2017-10-02    4.0    False
3   2017-10-03 2017-10-03         1 2017-10-03    8.0     True
>>> ivl.worktime()
2
>>> ivl.worktime(duty='off')
1
>>> ivl.worktime(duty='any')
3

In the example below, the work time is taken from the labels:

>>> clnd = tb.Timeboard('D', '30 Sep 2017', '11 Oct 2017',
...                     layout=[4, 8, 4, 8],
...                     default_selector = lambda label: label>4,
...                     worktime_source = 'labels')
>>> ivl = tb.Interval(clnd, (1, 3))
>>> ivl.worktime()
16.0
>>> ivl.worktime(duty='off')
4.0
>>> ivl.worktime(duty='any')
20.0

Note

To count the total duration of the workshifts in the interval (regardless of the work time) call Interval.total_duration().

Relation with another interval

what_portion_of() builds the intersection of this interval and another and returns the ratio of the workshift count in the intersection to the workshift count in the other interval. Only workshifts with the specified duty are counted.

If the two intervals do not overlap or their intersection contains no workshifts with the specified duty, zero is returned.

The common use of this method is to answer questions like “what portion of year 2017 has employee X been with the company?”. In the examples below, for the purpose of demonstration, the question is scaled down to “what portion of the week?..”:

>>> clnd = tb.Timeboard('D', '02 Oct 2017', '15 Oct 2017',
...                     layout=[1, 1, 1, 1, 1, 0, 0])
>>> week1 = clnd('02 Oct 2017', period='W')

week1 contains five working days and two days off.

>>> X_in_staff = clnd(('05 Oct 2017', '07 Oct 2017'))

X was was with the company Thursday through Saturday of week1 (two working days and one day off).

>>> .what_portion_of(week1)
0.4
>>> 2 / 5 # working days
0.4
>>> X_in_staff.what_portion_of(week1, duty='off')
0.5
>>> 1 / 2 # days off
0.5
>>> X_in_staff.what_portion_of(week1, duty='any')
0.42857142857142855
>>> 3 / 7 # all days
0.42857142857142855

You can use / (division) operator as a shortcut. It calls what_portion_of() with the default parameter values (so, the duty is ‘on’):

>>> X_in_staff / week1
0.4

X had already left before week2 started:

>>> week2 = clnd('09 Oct 2017', period='W')
>>> X_in_staff.what_portion_of(week2, duty='any')
0.0

Y has worked the entire week1 and stayed afterwards:

>>> Y_in_staff = clnd(('02 Oct 2017', '11 Oct 2017'))
>>> decade.what_portion_of(week1)
1.0

A corner case:

>>> weekend = clnd(('07 Oct 2017', '08 Oct 2017'))

All days of weekend are also the days of week1 but they are not working days, so:

>>> weekend.what_portion_of(week1)
0.0

However, weekend contains all off-duty days of week1:

>>> weekend.what_portion_of(week1, duty='off')
1.0

Counting periods

Call count_periods() to find out how many calendar periods of the specific frequency fit into the interval. As with the other methods, the duty of workshifts is taken into account. The method returns a float number.

To obtain the result, the interval is sliced into calendar periods of the given frequency and then each slice of the interval is compared to its corresponding period duty-wise. That is to say, the count of workshifts in the interval’s slice is divided by the total count of workshifts in the period containing this slice but only workshifts with the specified duty are counted. The quotients for each period are summed to produce the return value of the method.

If some period does not contain workshifts of the required duty, it contributes zero to the returned value.

Regardless of the period frequency, the method returns 0.0 if there are no workshifts with the specified duty in the interval.

The common use of this method is to answer questions like “Exactly, how many years has X worked in the company?” In the examples below, for the purpose of demonstration, the question is scaled down to “how many days?..” for a timeboard with hourly shifts.

Examples:

>>> clnd = tb.Timeboard('H', '01 Oct 2017', '08 Oct 2017 23:59',
...                     layout=[0, 1, 0, 2])
>>> X_in_staff = clnd(('01 Oct 2017 13:00', '02 Oct 2017 23:59'))

X’s tenure spans two days: it contains 11 of 24 workshifts of October 1, and all 24 workshifts of October 2:

>>> X_in_staff.count_periods('D', duty='any')
1.4583333333333333
>>> 11.0/24 + 24.0/24
1.4583333333333333

The timeboard’s layout defines that all workshifts taking place on even hours are off duty, and those on odd hours are on duty. The first workshift of the interval (01 October 13:00 - 13:59) is on duty. Hence, interval X_in_staff contains 6 of 12 on-duty workshifts of October 1, and all 12 on-duty workshifts of October 2:

>>> X_in_staff.count_periods('D')
1.5
>>> 6.0/12 + 12.0/12
1.5

The interval contains 5 of 12 off-duty workshifts of October 1, and all 12 off-duty workshifts of October 2:

>>> X_in_staff.count_periods('D', duty='off')
1.4166666666666667
>>> 5.0/12 + 12.0/12
1.4166666666666667

If we change the schedule to my_schedule, on-duty workshifts will start only at 3, 7, 11, 15, 19, and 23 o’clock yielding 6 on-duty workshifts per day. Interval X_in_staff will contain 3/6 + 6/6 on-duty days and 8/18 + 18/18 off-duty days:

>>> my_schedule = clnd.add_schedule(name='my_schedule',
...                                 selector=lambda label: label>1)
>>> X_in_staff.count_periods('D', schedule=my_schedule)
1.5
>>> 3.0/6 + 6.0/6
1.5
>>> X_in_staff.count_periods('D', duty='off', schedule=my_schedule)
1.4444444444444444
>>> 8.0/18 + 18.0/18
1.4444444444444444

Note that an interval containing exactly one calendar period with regard to some duty may be larger than this period, as well as smaller:

# Interval of 25 hours
>>> ivl = clnd(('01 Oct 2017 00:00', '02 Oct 2017 00:59'))
>>> ivl
Interval((0, 24)): 'H' at 2017-10-01 00:00 -> 'H' at 2017-10-02 00:00 [25]
>>> ivl.count_periods('D')
1.0
# Interval of 23 hours
>>> ivl = clnd(('01 Oct 2017 01:00', '01 Oct 2017 23:59'))
>>> ivl
Interval((1, 23)): 'H' at 2017-10-01 01:00 -> 'H' at 2017-10-01 23:00 [23]
>>> ivl.count_periods('D')
1.0

Caveats

Period extends beyond timeline

Consider the timeboard and two intervals:

>>> clnd = tb.Timeboard('H', '01 Oct 2017', '08 Oct 2017 23:59',
...                     layout=[0, 1, 0, 2])
>>> ivl1 = clnd(('02 Oct 2017 00:00', '02 Oct 2017 23:59'))
>>> ivl2 = clnd(('01 Oct 2017 13:00', '02 Oct 2017 23:59'))

We can count how many weeks are in interval ivl1 but not in ivl2.

All workshifts of ivl1 belong to the week of October 2 - 8 which is situated entirely within the timeboard. On the other hand, in ivl2 there are the workshifts belonging to the week of September 25 - October 1. This week extends beyond the timeboard. We may not guess what layout could be applied to the workshifts of Sep 25 - Sep 30 if the week were included in the timeboard entirely. We are not authorized to extrapolate the existing layout outside the timeboard. Moreover, for some complex layouts, any attempt at extrapolation would be ambiguous.

>>> ivl1.count_periods('W')
0.14285714285714285
>>> ivl2.count_periods('W')
-----------------------------------------------------------------------
PartialOutOfBoundsError               Traceback (most recent call last)
  ...
PartialOutOfBoundsError: The left bound of interval or period referenced by `2017-09-25/2017-10-01` is outside Timeboard of 'H': 2017-10-01 00:00 -> 2017-10-08 23:00

Workshift straddles period boundary

This case is analogous to the already reviewed issue of constructing an interval from a calendar period. Timeboard.workshift_ref attribute is used to identify workshift’s membership in a period.

Period too short for workshifts

If you try to count periods which are shorter than (some) of the workshifts in the interval, you are likely to encounter a period which does not contain any workshift’s reference whatever the duty. This makes any result meaningless and, consequently, UnacceptablePeriodError is raised.

You may accidentally run into this issue in two situations:

  • You use compound workshifts and while most of the workshifts (usually those covering the working time) are of one size, there are a few workshifts (usually those covering the closed time) which are much larger. Trying to count periods, you have in mind the smaller workshifts. If a larger one gets into the interval and your period is not long enough, you will find yourself with UnacceptablePeriodError.
  • You have misinterpreted the purpose of count_periods() method and try to use it as a general time counter. For example, in a timeboard with workshifts of varying duration measured in hours, you want to find out how many clock hours there are in an interval. In order to do that use pandas.Timedelta tools with start_time and end_time attributes of workshifts and intervals.