Time, Calendar and periods

This section is a dive into three of the time constructs available from Shyft: the time type, the calendar, and the time period. ̨ It is necessary to understand the underlying time representation, and how to manipulate it, to be able to both display and work with time series data correctly, and to set up and perfom computations.


Contents:


To start of we need to import the time series library of Shyft, we put it into an alias sts.

import shyft.time_series as sts

Shyft time

Shyft considers time as UTC+0 internally, is is stored and used as a number in the Shyft core and it can represent time at the microsecond resolution.

In the Python API time is exposed through the time class. Objects of this class is not that interesting, their main purpose is so the C++ implementation of Shyft can speak with the Python API correctly. In addition to this this class helps with parsing and stringifying UTC+0 time as well.

When using the time class is that values or objects of this class can both represent a concrete date/time, or a delta or offset without any particular meaning as a point in time. It is simply a value able to be interpreted in multiple ways.

We will mostly ignore the sub-second resolution of time.


The first way to initialize a time value is by passing it a number of seconds since the epoch. The epoch is the origin of the time system, Shyft uses the regular epoch: 1970-01-01T00:00:00.

time_point = sts.time(1559563200)
time_point # output: time(1559563200)

We can stringify the time value we just made. This will give us a time string following ISO8601:

str(time_point) # output: '2019-06-03T12:00:00Z'

We can also pass the ISO8601 string back to the time class to construct a moment as specified in the string.

Note that Shyft only parses a subset of ISO8601 so many perfectly valid time strings produces errors, and it does not even parse every time string it produces itself.

# mostly iso8601 time strings.
sts.time('2019-06-03T12:00:00.000000Z')  # output: time(1559563200)

An example of a ISO8601 that does not work is if we add the UTC-offset

try:
    sts.time('2019-06-03T11:00:00.000000+01')
except RuntimeError as e:
    print(e) # output: Needs format 'YYYY-MM-DDThh:mm:ssZ': got 2019-06-03T11:00:00.000000+01

Since time is a simple number we can work with it using simple arithmetics. We can add or subtract to it using + and -, multiply and divide it using * and /.

When doing arithmetics with time we can use regular numbers, the result will be a time object if at least one time object is involved in the computation. Numbers will be interpreted as seconds, while any fractional parts of the numbers will be interpreted as sub-seconds down to microseconds.

The next cell add one hour, 3600 seconds, to the time point we defined in the start of this section:

str(time_point + 3600) # output: '2019-06-03T13:00:00Z'

To get the current time we can use utctime_now() that we imported earlier. This function returns a time point representing the time at which the function was called:

str(sts.utctime_now()) # output: '2023-08-22T08:10:47.037387Z'

The three time constants we imported represent respectivly the least and largest time representable, and invalid time.

The minimum and maximum time constructs are mostly usefull as default values, and for creating time periods spanning “all” of time.

Invalid time may be returned by some functions to represent that whatever operation it were to do did not work, and some Shyft constructs initialize to it by default.

sts.min_utctime # output: time.min
sts.max_utctime # output: time.max
sts.no_utctime  # output: time.undefined

Calendars

Since Shyft represent time solely as UTC+0 we need some construct that is able to work with localized time. This is the purpose of the calendar construct.

Initializing calendars

To construct a Shyft calendar we need to provide the time zone specification it should use. If we do not specify any time zone it defaults to UTC+0.

utc = sts.Calendar()
utc_p5 = sts.Calendar('UTC+5')
utc_m2 = sts.Calendar('UTC-2')
oslo = sts.Calendar('Europe/Oslo')

We can list all the available specification by calling the function region_id_list on the calendar:

list(sts.Calendar.region_id_list())

Construct localized time

Calendars can construct time points by using the time function on them. This function can not accept ISO8601 string, it instead accepts numbers specifying the time point.

The only required argument is the year, but it can accept as many as seven values, in order: year, month, day, hour, minute, second, microsecond. As long as we specify the year, we can specify as few or many of the other, but we always have to specify all values in the order listed.

utc.time(2019) # output: time(1546300800)
utc.time(2019, 5, 31, 13, 52, 30) # output: time(1559310750)

To demonstrate that calendars produce localized time, take a look at the two following cells. Notice that the same time on the same day is specified, but the results are not equal:

utc.time(2019, 5, 29, 12) # output: time(1559131200)
oslo.time(2019, 5, 29, 12) # output: time(1559124000)

Stringifying local time

Calendars have a to_string function this function produces ISO8601 strings with offset like the calendar.

Note that Shyft (at the time of writing) cannot parse these strings again, as it only parses ISO8601 string where both the hour and minute of the offset is specified, but it writes strings with only the hour.

# stringify now using the local calendar
oslo.to_string(sts.utctime_now()) # output: '2023-08-22T10:26:53.434103+02'

Trim time

Often when given an arbitrary time point we can trim it to move it back to a time point with some specific meaning like the beggining of the hour or week containing the time point. Take a look at the end of the calendar section to see the list of time constants available for trimming.

Trimming a time point is done using calendar semantics, so you can get different results by using different calendars.

# get the current time, then trim it to the start of the current hour
now = sts.utctime_now()
now_hour = oslo.trim(now, sts.Calendar.HOUR)

oslo.to_string(now_hour) # output: '2023-08-22T10:00:00+02'
# get the current time, then trim it to the start of the current hour
now = sts.utctime_now()
now_week = oslo.trim(now, sts.Calendar.WEEK)

oslo.to_string(now_week) # output: '2023-08-21T00:00:00+02'

Add to time

If you want to add some offset to a time point that is not a specific length, like a month which could be 28, 29, 30, or 31 days, you should use the add function which handles these offsets easily.

The predefined offsets available for add is listed at the end of the calendar section.

The three next cells demonstrates the add function, first we retreive the current time and trim it to the start of the current month. The next two cells first add two months without calendar sematics and then with calendar semantics. Notice how you get diffenet results.

# get the current time, then trim it to the start of the current hour
now = sts.utctime_now()
now_month = oslo.trim(now, sts.Calendar.MONTH)

oslo.to_string(now_month) # output: '2023-08-01T00:00:00+02'
# add two months without calendar semantics
two_months = now_month + 2*sts.Calendar.MONTH

oslo.to_string(two_months) # output: '2023-09-30T00:00:00+02'
# add two months with calendar semantics
two_months = oslo.add(now_month, sts.Calendar.MONTH, 2)

oslo.to_string(two_months) # output: '2023-10-01T00:00:00+02'

Diff moments

Using a calendar we can also get the number of times we can subdivide a timespan. Some timespans work with calendar semantics, like a month, or a year which can be off differing length. The predefined time spans that have calendar semantics is listed at the end of the calendar section.

When using the diff_units function we specify first the start and end time points, then the delta to subdivide with.

The function does not return any remainder, and only return the whole number of times the delta subdivied the period. You need check for a remainder manually if required.

# define start, end, and delta
t0 = utc.time(2018, 5, 5)
tN = utc.time(2018, 9, 17)
dt = sts.Calendar.WEEK

# get the number of times dt subdivides [t0, tN>
n_weeks = utc.diff_units(t0, tN, dt)
n_weeks # output: 19

Notice that the timespan we choose did not subdivide fully into weeks. If we add the number of weeks to the start point we do not get fully to the end point:

# add the computed number of weeks to the start point
utc.to_string(t0 + n_weeks*dt) # output: '2018-09-15T00:00:00Z'

Time constants

The predefined time constants in Shyft is listed below. These are the ones for wich calendar semantics work.

  • Calendar.SECOND: One second

  • Calendar.MINUTE: One minute, 60 seconds

  • Calendar.HOUR: One hour, 3600 seconds

  • Calendar.WEEK: Defines a semantic week

  • Calendar.DAY: Defines a semantic day

  • Calendar.QUARTER: Defines a semantic quarter, 3 months

  • Calendar.MONTH: Defines a semantic month

  • Calendar.YEAR: Defines a semantic year


Time periods

Shyft has a time period construct to represent a pair of time points representing a timespan. The type exposed through the Shyft Python API is the UtcPeriod construct.

The time period is represented as a half-open interval: The start value is included in the interval, while the end value is not included.

The two values of the period are time values, and thus represented as UTC+0.

Initialization

A UtcPeriod is initialized from two time values or two numbers representing time:

sts.UtcPeriod(utc.time(2018), utc.time(2019)) # output: [2018-01-01T00:00:00Z,2019-01-01T00:00:00Z>
sts.UtcPeriod(1514764800, 1546300800) # output: [2018-01-01T00:00:00Z,2019-01-01T00:00:00Z>

List of functions

UtcPeriod has several built-in functions and properties.

  • start

  • end

  • timespan

  • valid

  • to_string

  • trim

  • intersection

  • overlaps

  • contains

  • diff_units

Start & end

start and end are properites which simply returns the start and end time points of the period.

period = sts.UtcPeriod(utc.time(2018), utc.time(2019))
period.start, period.end # output: (time(1514764800), time(1546300800))

Timespan

timespan returns the $Delta t$ in seconds

period.timespan() # output: time(31536000)

Valid

valid returns a boolean if the period is valid or not.

period.valid() # output: True
invalid_period = sts.UtcPeriod(utc.time(2019), utc.time(2018))
invalid_period.valid() # output: False

To string

to_string returns the period start and end in ISO8601 format with the half-open interval symbols as a string.

period.to_string() # output: '[2018-01-01T00:00:00Z,2019-01-01T00:00:00Z>'

Trimming periods

As with time values, time periods can also be trimmed to multiples of specific time deltas. When you have a UtcPeriod object you call the trim function on it to do this.

The time deltas are the same ones listed at the end of the calendar section.

When trimming time periods we have two modes to choose between. We can trim outwards to a multiple of the delta, or inwards to a multiple of the delta. This is specified as the third argument to the function. If it is left unspecified the function defaults to trimming inwards.

The first argument to the function is the calendar to use when trimming, the second argument is the delta to trim to, and the optional third argument is the trim policy.

period = sts.UtcPeriod(utc.time(2017, 6), utc.time(2019, 6))
period.trim(utc, sts.Calendar.YEAR, sts.trim_policy.TRIM_IN)
# output: [2018-01-01T00:00:00Z,2019-01-01T00:00:00Z>
period = sts.UtcPeriod(utc.time(2017, 6), utc.time(2019, 6))
period.trim(utc, sts.Calendar.YEAR, sts.trim_policy.TRIM_OUT)
# output: [2017-01-01T00:00:00Z,2020-01-01T00:00:00Z>

Intersection & overlap

The time period construct have the functions to determine overlap between periods, and compute the intersection between two periods.

To demonstrate we define three periods, one for the first three month of the year, one for the middle three, and one for the last three:

period_1 = sts.UtcPeriod(utc.time(2018, 1), utc.time(2018, 7))
period_2 = sts.UtcPeriod(utc.time(2018, 4), utc.time(2018, 10))
period_3 = sts.UtcPeriod(utc.time(2018, 7), utc.time(2019, 1))

To determine if they overlap we can call the overlaps function on one of the periods, and pass the other as argument:

period_1.overlaps(period_2) # output: True
period_1.overlaps(period_3) # output: False

The intersection is not implemented as a function in the UtcPeriod structure, it is a free function. This is why we had to implement it separatly.

To compute the intersection we pass the intersection function both the periods to compute intersection for. We get a special period out if the periods does not overlap.

sts.intersection(period_1, period_2) # output: [2018-04-01T00:00:00Z,2018-07-01T00:00:00Z>
sts.intersection(period_1, period_3) # output: [not-valid-period>

Moment in period

To check if a time point is inside a period we can call the contains function on the period with the time point as argument.

period = sts.UtcPeriod(utc.time(2018), utc.time(2019))

The contains function accepts the time point, and returns a boolean signifying whether the point is within the period or not:

moment = utc.time(2017, 6)
period.contains(moment) # output: False
moment = utc.time(2018, 6)
period.contains(moment) # output: True

Diff a period

To determine how many times a period can be subdivided by a delta, we do not need to unpack the points defining the period and use Calendar.diff_units. The UtcPeriod construct have a diff_units function accepting a calendar and the delta. Otherwise this function works just like the Calendar.diff_units.

To demonstrate we define a period of some length:

# define a period
period = sts.UtcPeriod(utc.time(2018, 3, 15, 17), utc.time(2018, 10, 2, 4))

By using UtcPeriod.diff_units on the period with Calendar.DAY as the delta, we get the number of whole days in the period:

# determine the number of whole days the period can be subdivided into
period.diff_units(utc, sts.Calendar.DAY) # output: 200

Or we can determine the number of minutes the period can be subdivided into:

# determine the number of whole minutes the period can be subdivided into
period.diff_units(utc, sts.Calendar.MINUTE) # output: 288660