Module Timedesc

Time description and manipulations

Timedesc provides utilities to describe points of time, and properly handle calendar and time zone information.

Tutorial

Getting started

Suppose we want to get the time right now, we can simply do Timedesc.now (). But what if we want to get the time right now in a different time zone? Say New York? Then we can simply do:

Timedesc.now ~tz_of_date_time:(Timedesc.Time_zone.make_exn "America/New_York") ().

And if we want to construct a date time from scratch, we can use constructors such as make, with similar time zone specification:

Timedesc.make ~tz:(Timedesc.Time_zone.make_exn "Australia/Sydney") ~year:2021 ~month:5 ~day:30 ~hour:14 ~minute:10 ~second:0 ().

Since we deal with timestamps quite frequently, lets have a look at how Timedesc also makes working with them easier. Suppose we receive a timestamp similar to the result returned by Unix.gettimeofday, i.e. seconds since unix epoch in float, we can digest it in myriad ways. If we just want to construct a date time out of it, then we can use of_timestamp_float_s. If we want to get it into the representation used in Timedesc, say to perform arithmetic operations over it etc, then we can use Timestamp.of_float_s. But in either case, we can always swap back and forth via to_timestamp and of_timestamp.

In general it is better to use timestamp as much as possible, unless you require a precision higher than nanosecond. This is because floating point is a lossy representation - if you convert a date time to floating point and back, you may not get the same date time back (i.e. it may not round trip). Also, performing arithmetic operations over floating points can introduce more and more errors, and it is advisable to use the arithmetic functions provided in Span or Timestamp.

To access the values of date time, we can use the constructors such as year, month, day, hour.

Time zone

By now, one nicety should be obvious: you don't have to worry about what is the time zone offset at when and where - Timedesc takes care of that for you properly! All you have to do is to make a time zone following the *nix naming convention. However, even though we follow the same naming convention, we don't actually rely on the OS time zone database, and our code will run fine on any platform.

To see what time zones Timedesc supports during run time, we can refer to Time_zone.available_time_zones. Alternatively, for a text file containing all the supported time zones by default, refer to available-time-zones.txt in the repository.

If you are aware of DST: Yes, Timedesc takes care of that for you properly as well - Timedesc does not allow you to construct a date time that does not exist for the particular time zone, and any ambiguity is made explicit as return type via local_result.

This does mean Timedesc does not "resolve" the result into one of the possibilities arbitrarily, and you need to resolve the ambiguity yourself. If such a coercion is desirable, however, then you can use either min_of_local_result or max_of_local_result.

Span/duration

Timedesc offers both machine-friendly and human-friendly ways of dealing with spans.

For the machine-friendly side, functions in the top level of Span provide efficient constructions and arithmetic operations.

For the human-friendly side, Span.For_human provides functions which work at a level closer to human language. For instance, we say things like "2 hours and 15 minutes" quite frequently, to represent this as Span.t, we can do:

Timedesc.Span.For_human.make_exn ~hours:2 ~minutes:15 ()

And in the case of fractional descriptions, such as "1.5 hours", we can do:

Timedesc.Span.For_human.make_frac_exn ~hours:1.5 ()

Finally, to access the human friendly "view", we can use Span.For_human.view.

Using both Ptime and Timedesc

Ptime is a (very) commonly used package in projects due to being very portable, and robust. However, it lacks certain features which Timedesc provides, such as first class support for time zones, support for different date systems. As such one may wish to use both Ptime and Timedesc, especially if Ptime is already being used for a particular project.

To facilitate such use of both Ptime and Timedesc, utilities for converting to and from Ptime types are available as:

Note that Timedesc only supports nanosecond precision, while Ptime supports picosecond precision. If subnanosecond precision is a concern for you, then the above functions are not suitable.

Advanced usage

Unambiguous date time

Occasionally, we receive date times which carry both the time zone and the exact offset from UTC. Naturally we can discard the time zone since the offset alone suffices in deducing the precise timestamp. However, we can actually ask Timedesc to digest both via make_unambiguous, which checks the offset against the time zone record to make sure it is actually a possible offset.

Other calendar systems

Other than Gregorian calendar, Timedesc also supports ISO week date and ISO ordinal date.

To construct date time in the alternative systems, we can use constructors such as ISO_week_date_time.make and ISO_ord_date_time.make.

Then to access the representation in the alternative date systems, we can use accessors such as iso_week_year, iso_week, and day_of_year.

Using date by itself

Sometimes we are only interested in the date component rather than both date and time. We can use Date module in this case.

To construct a Gregorian calendar date, we can use Date.Ymd_date.make. To construct ISO week date and ISO ordinal date, we can use Date.ISO_week_date.make and Date.ISO_ord_date respectively.

We have similar set of accessors for accessing values of Date.t, such as Date.year, Date.iso_week_year, Date.day_of_year.

To obtain a "view" (in a manner similar to the human-friendly "view" from Span.For_human), we can use Date.ISO_week_date.view and Date.ISO_ord_date.view.

Further reading

Misconceptions

  • Time zone offsets are always in hours
  • What we typically consider a time zone, e.g. "Europe/Paris", always has a constant offset
  • With a time zone and a specific date time, we can always obtain a unique "unix timestamp" (time since unix epoch)
  • We can always calculate time zone offset at some date time, and apply it universally for any other date time in the same time zone
  • Many more on various online resources...

Time zone, time zone offset, and date time

It is tempting to think that a time zone maps cleanly to a constant offset, and indeed we may define time zone as such, e.g. UTC+1, UTC-10, but this is far from what we mean in everyday context.

Very often, what we consider to be time zone actually represents a table which records what offset to use in which period, which we index/refer to by geographical names like "Europe/Paris", "Australia/Sydney". These tables are defined by governmental bodies, and attributes of the table, such as offset of any particular period, start and end of any particular period, may not show any observable pattern.

Thus it is not uncommon to see date time errors arising from attempts of applying some formulas universally, which might work well for a lot of cases in contemporary time periods, but fail for some combinations.

We make explicit of above explanation by considering "Europe/Paris" as an example, which observes a common form of transition called Daylight Saving Time (DST).

When DST starts (usually in March), the clocks "jump forward" by 1 hour, usually jumping from 2am to 3am, leading 2am to 3am (exclusive) to become non-existent.

Indeed we can observe the lack of continuity of Europe/Paris timeline below (UTC timeline is always continuous):

                         Mar
UTC          -------------|-------------
                         1am

Europe/Paris -------------|-------------
                       2am 3am
                      (+1) (+2)

Paris time zone offset also changes from UTC+1 to UTC+2.

When DST ends (usually in Oct), clocks "jump backward" by 1 hour, usually jumping from 3am to 2am, leading to 2am to 3am (exclusive) becoming duplicated:

                         Oct
UTC          -------------|-------------
                         1am

Europe/Paris -------------|-------------
                       3am 2am
                      (+2) (+1)

Paris time zone offset also changes from UTC+2 to UTC+1.

Another way of looking at above is when DST is in effect, Paris observes UTC+2, and UTC+1 otherwise:

                          |-------------DST on------------|
             |---DST off--|                               |---DST off--|

                         Mar                             Oct
UTC          -------------|------------- ... -------------|-------------
                         1am                             1am

Europe/Paris -------------|------------- ... -------------|-------------
                       2am 3am                         3am 2am
                      (+1) (+2)                       (+2) (+1)

This start and end of the DST on and off periods, along with the corresponding offsets, form the basis of the table we mentioned above.

Timedesc date time API behaviour highlights

We highlight some critical cases in practice, and how Timedesc behaves and how it may differ from other libraries.

Take year 2021 for example, DST starts on 2021 Mar 28 for Paris, causing clocks to jump from 2am to 3am. Pick any intermediate point, say 2:30am, we yield an undefined date time. In this case, Timedesc refuses the construction of such t in make etc, while some libraries coerce the result into 3:30am.

And DST ends on 2021 Oct 31, causing clocks to jump from 3am to 2am. Say we pick 2:30am again, we are actually pointing at two time points (there are two 2:30am) unless we make an explicit selection between the first or second occurance. Whenever ambiguity of this form is a possiblity for the result of a function, say to_timestamp, Timedesc uses local_result variant type, of which `Single _ indicates lack of ambiguity for the particular result, and `Ambiguous _ indicates the result is ambiguous.

Some other libraries coerce the ambiguous result into one of the two possible choices (which exact one may not be guaranteed). If user wishes to do similar coercions, they may use min_of_local_result or max_of_local_result.

For constructions, make yields a possibly ambiguous construction, while make_unambiguous yields an unambiguous construction. In general, if you are provided with the exact offset to UTC, then make_unambiguous is the better choice.

Basic exceptions

exception Invalid_format_string of string

Printing exception

exception ISO8601_parse_exn of string

Basic types

type weekday = [
| `Sun
| `Mon
| `Tue
| `Wed
| `Thu
| `Fri
| `Sat
]
type 'a local_result = [
| `Single of 'a
| `Ambiguous of 'a * 'a
]

Result for when a local date time may be involved, e.g. using a date time with no precise time zone offset attached.

  • `Single is yielded when the date time maps to exactly one 'a. This happens when date time carries an accurate offset, or when the date time is not affected by any offset shifts (thus an accurate offset can be inferred).
  • `Ambiguous is yielded when date time maps to more than one (exactly two) 'a. This happens when DST ends and "goes back an hour" for instance.
val min_of_local_result : 'a local_result -> 'a

For min_of_local_result x

  • if x = `Single a, yields a,
  • if x = `Ambiguous (a, b), yields a,
val max_of_local_result : 'a local_result -> 'a

For max_of_local_result x

  • if x = `Single a, yields a,
  • if x = `Ambiguous (a, b), yields b,
val equal_local_result : eq:('a -> 'a -> bool) -> 'a local_result -> 'a local_result -> bool

Span

module Span : sig ... end
type timestamp = Span.t

Definition of timestamp throughout the library follows the "seconds since unix epoch" definition

Date time components

Date

Implementation of date in:

module Date : sig ... end

Time

Implementation of time of day with nanosecond precision

module Time : sig ... end

Time zone

Implementation of time zone which uses IANA time zone database underneath

module Time_zone : sig ... end

Date time

Implementation of time zone aware date time in:

type t

This is the main type, and represents a point in the local timeline with respect to the residing time zone. Conceptually a triple of "date", "time" (or "time of day"), and time zone.

A t always maps to at least one point on the UTC timeline, and make fails if this is not the case. t may also map to two points on the UTC timeline in the case of DST and without an unambiguous offset, however.

In the ambiguous case, functions which return _ local_result will yield an `Ambiguous _ value, and `Single _ otherwise.

ns may be >= 10^9 to represent leap second, but always remains < 2 * 10^9.

s is always >= 0 and < 60, even when second 60 is used during construction. In other words, second 60 is represented via ns field.

type error = [
| `Does_not_exist
| `Invalid_year of int
| `Invalid_month of int
| `Invalid_day of int
| `Invalid_hour of int
| `Invalid_minute of int
| `Invalid_second of int
| `Invalid_s_frac of float
| `Invalid_ns of int
| `Invalid_tz_info of string option * Span.t
]
exception Error_exn of error
val string_of_error : error -> string

Constructors

val make : ?⁠tz:Time_zone.t -> ?⁠ns:int -> ?⁠s_frac:float -> year:int -> month:int -> day:int -> hour:int -> minute:int -> second:int -> unit -> (terror) Stdlib.result

Constructs a date time providing only a time zone (defaults to local time zone).

A precise offset is inferred if possible.

Note that this may yield a ambiguous date time if the time zone has varying offsets, causing a local date time to appear twice, e.g. countries with DST.

See make_unambiguous for the more precise construction.

See Date.Ymd_date.make for error handling of date specification.

See Time.make for error handling of time of day specification.

val make_exn : ?⁠tz:Time_zone.t -> ?⁠ns:int -> ?⁠s_frac:float -> year:int -> month:int -> day:int -> hour:int -> minute:int -> second:int -> unit -> t
raises Error_exn

if make fails

val make_unambiguous : ?⁠tz:Time_zone.t -> ?⁠ns:int -> ?⁠s_frac:float -> year:int -> month:int -> day:int -> hour:int -> minute:int -> second:int -> offset_from_utc:Span.t -> unit -> (terror) Stdlib.result

Constructs a date time providing time zone offset (offset from UTC), and optionally a time zone. As an example, for "UTC+1", you would give a duration of positive 1 hour for offset_from_utc.

Subsecond value of offset_from_utc is ignored.

Nanosecond used is the addition of ns and s_frac * 10^9.

If a time zone is provided, then offset_from_utc is checked against the time zone record, and returns Error `Invalid_tz_info if offset_from_utc is not a possible offset for the particular date time in said time zone.

Otherwise same leap second handling and error handling as make.

val make_unambiguous_exn : ?⁠tz:Time_zone.t -> ?⁠ns:int -> ?⁠s_frac:float -> year:int -> month:int -> day:int -> hour:int -> minute:int -> second:int -> offset_from_utc:Span.t -> unit -> t
raises Error_exn

if make_umabiguous fails

Accessors

val date : t -> Date.t
val ymd_date : t -> Date.Ymd_date.view
val iso_week_date : t -> Date.ISO_week_date.view
val iso_ord_date : t -> Date.ISO_ord_date.view
val year : t -> int
val month : t -> int
val day : t -> int
val weekday : t -> weekday
val iso_week_year : t -> int
val iso_week : t -> int
val day_of_year : t -> int
val time : t -> Time.t
val time_view : t -> Time.view
val hour : t -> int
val minute : t -> int
val second : t -> int
val ns : t -> int
val is_leap_second : t -> bool
val tz : t -> Time_zone.t
val offset_from_utc : t -> Span.t local_result

Conversion

val to_timestamp : t -> timestamp local_result

to_timestamp loses information about leap second

val to_timestamp_single : t -> timestamp
raises Invalid_argument

if to_timestamp does not yield a `Single result

val to_timestamp_float_s : t -> float local_result

Returns timestamp in seconds, fraction represent

val to_timestamp_float_s_single : t -> float
raises Invalid_argument

if to_timestamp_float_s does not yield a `Single result

val of_timestamp : ?⁠tz_of_date_time:Time_zone.t -> timestamp -> t option
val of_timestamp_exn : ?⁠tz_of_date_time:Time_zone.t -> timestamp -> t
val of_timestamp_float_s : ?⁠tz_of_date_time:Time_zone.t -> float -> t option
val of_timestamp_float_s_exn : ?⁠tz_of_date_time:Time_zone.t -> float -> t

Comparison

val equal : t -> t -> bool
val compare_chrono_min : t -> t -> int

Compare based on ordering of min_of_local_result @@ to_timestamp _

Warning: compare_chrono_min x y = 0 does not imply equal x y

val compare_chrono_max : t -> t -> int

Compare based on ordering of max_of_local_result @@ to_timestamp _

Warning: compare_chrono_max x y = 0 does not imply equal x y

val compare_struct : t -> t -> int

Structural comparison, compare_struct x y = 0 implies equal x y

Ordering does not correspond to chronological ordering

Constants

val min_val : t
val max_val : t

Now

val now : ?⁠tz_of_date_time:Time_zone.t -> unit -> t

Pretty printing

exception Date_time_cannot_deduce_offset_from_utc of t
val pp : ?⁠format:string -> unit -> Stdlib.Format.formatter -> t -> unit

Pretty printing for date time.

Default format string:

{year} {mon:Xxx} {day:0X} {hour:0X}:{min:0X}:{sec:0X}{sec-frac:.} \
{tzoff-sign}{tzoff-hour:0X}:{tzoff-min:0X}:{tzoff-sec:0X}

Format string specification:

{{               literal {
{year}           year
{mon:Xxx}        abbreviated month name (e.g. Jan), casing of 'x' controls the casing
{mon:Xx*}        full month name (e.g. January), casing of first 'x' controls casing of first letter,
                 casing of second 'x' controls casing of following letters
{mon:cX}         month in number form (e.g. 01) character 'c' before 'X' is used for padding
                 (leave out character for no padding)
{day:cX}         month day (e.g.  1) character 'c' before 'X' is used for padding
                 (leave out character for no padding)
{wday:Xxx}       abbreviated weekday name (e.g. Sun), the casing of 'x' controls the casing
{wday:Xx*}       full weekday name (e.g. Sunday), casing of first 'x' controls casing of first letter,
                 casing of second 'x' controls casing of following letters
{hour:cX}        hour in 24-hour format, character 'c' before 'X' determines padding
                 (leave out character for no padding)
{12hour:cX}      hour in 12-hour format, character 'c' before 'X' determines padding
                 (leave out character for no padding)
{min:cX}         minute, character 'c' before 'X' determines padding
                 (leave out character for no padding)
{sec:cX}         second, character 'c' before 'X' determines padding
                 (leave out character for no padding)
{ns}             nanosecond
{sec-frac:cN}    fraction of second
                 character c is used as the decimal separator
                 N determines the number of digits to take after decimal separator
                 if N is not specified, then the smallest number of digits required
                 after decimal separator for a lossless representation is used
                 result is truncated to said number of digits
{tzoff-sign}     time zone offset sign ('+' or '-')
                 raises Date_time_cannot_deduce_offset_from_utc if time zone offset cannot be calculated
{tzoff-hour:cX}  time zone offset hour, follows same padding rule as "{hour:cX}"
                 raises Date_time_cannot_deduce_offset_from_utc if time zone offset cannot be calculated
{tzoff-min:cX}   time zone offset minute, follows same padding rule as "{min:cX}"
                 raises Date_time_cannot_deduce_offset_from_utc if time zone offset cannot be calculated
{tzoff-sec:cX}   time zone offset second, follows same padding rule as "{sec:cX}"
                 raises Date_time_cannot_deduce_offset_from_utc if time zone offset cannot be calculated
val to_string : ?⁠format:string -> t -> string option

String conversion using pp.

Returns None instead of raising exception when time zone offset cannot be deduced but required by the format string

val pp_rfc3339 : ?⁠frac_s:int -> unit -> Stdlib.Format.formatter -> t -> unit

Pretty prints according to RFC3339, e.g. 2020-01-20T13:00:00.0001+10.

frac_s defaults to as many digits as required for a lossless representation.

raises Invalid_argument

if frac_s < 0 || frac_s > 9

raises Date_time_cannot_deduce_offset_from_utc

if time zone offset cannot be calculated

val pp_rfc3339_milli : Stdlib.Format.formatter -> t -> unit
val pp_rfc3339_micro : Stdlib.Format.formatter -> t -> unit
val pp_rfc3339_nano : Stdlib.Format.formatter -> t -> unit
val to_rfc3339 : ?⁠frac_s:int -> t -> string option

String conversion using pp_rfc3339.

Returns None if time zone offset cannot be deduced instead of raising exception.

val to_rfc3339_milli : t -> string option
val to_rfc3339_micro : t -> string option
val to_rfc3339_nano : t -> string option

Parsing

val of_iso8601 : string -> (t, string) Stdlib.result

Parses a subset of ISO8601, up to 9 fractional digits for second (nanosecond precision).

If more than 9 fractional digits are provided, then only the first 9 digits are used, i.e. no rounding.

val of_iso8601_exn : string -> t
raises ISO8601_parse_exn

if of_iso8601 fails

Sexp

val to_sexp : t -> CCSexp.t
val to_sexp_string : t -> string
val of_sexp : CCSexp.t -> (t, string) Stdlib.result
val of_sexp_string : string -> (t, string) Stdlib.result
val pp_sexp : Stdlib.Format.formatter -> t -> unit

Timestamp

module Timestamp : sig ... end

Interval

module Interval : sig ... end

Time zone-less date time

module Zoneless : sig ... end

Other date time systems

module ISO_week_date_time : sig ... end
module ISO_ord_date_time : sig ... end

Misc

module Time_zone_info : sig ... end
module Utils : sig ... end