TimeManager

TimeManager defines ITime, the time type for CliMA simulations, alongside with various functions to work with it.

ITime

ITime is a type to describe times and dates. The "I" in ITime stands for integer: internally, ITime uses integers to represent times and dates, meaning that operations with ITime are exact and do not occur in floating-point errors.

ITime can be thought a combination of three quantities, a counter, a period, and (optionally) an epoch, with the counter counting how many periods have elapsed since epoch. In other words, ITime counts clock cycles from an epoch, with each clock cycle lasting a period.

Another useful mental model for ITime is that it is a time with some units. It is useful to keep this abstraction in mind when working with binary operations that involve ITimes because it helps with determining the output type of the operation (more on this later).

Let us start getting familiar with ITime by exploring some basic operations.

First steps with ITime

The first step in using ITime is to load it. You can load ITime by loading the TimeManager module as in the following:

using ClimaUtilities.TimeManager

This will load ITime as well as some other utilities. If you only wish to load ITime, you can change using with import and explicitly ask for ITime

import ClimaUtilities.TimeManager: ITime

In these examples, we will stick with using ClimaUtilites.TimeManager because we will call other functions as well.

By default, ITime assumes that the clock cycle is one second:

ITime(5)
5.0 seconds [counter = 5, period = 1 second]

The output that is printed shows the time represented (5 seconds), and its break down into the integer counter (5) and the duration of each clock cycle (1 second).

This ITime does not come with any date information attached to it. To add it, you can pass the epoch keyword

using Dates
ITime(5; epoch = DateTime(2012, 12, 21))
5.0 seconds (2012-12-21T00:00:05) [counter = 5, period = 1 second, epoch = 2012-12-21T00:00:00]

Now, the output also reports epoch and current date (5 seconds after midnight of the 21st of December 2012).

The period can also be customized with the period keyword. This keyword accepts any Dates.FixedPeriod (fixed periods are intervals of time that have a fixed duration, such as Week, but unlike Month), for example

time1 = ITime(5; period = Hour(20), epoch = DateTime(2012, 12, 21))
100.0 hours (2012-12-25T04:00:00) [counter = 5, period = 20 hours, epoch = 2012-12-21T00:00:00]

All these quantities are accessible with functions:

@show "time1" counter(time1) period(time1) epoch(time1) date(time1)
2012-12-25T04:00:00

Now that we know how to create ITimes, we can manipulate them. ITimes support most (but not all) arithmetic operations.

For ITimes with an epoch, it is only possible to combine ITimes that have the same epoch. ITimes can be composed to other times and arithmetic operations propagate epochs.

This works:

time1 = ITime(5; epoch = DateTime(2012, 12, 21))
time2 = ITime(15; epoch = DateTime(2012, 12, 21))
time1 + time2
20.0 seconds (2012-12-21T00:00:20) [counter = 20, period = 1 second, epoch = 2012-12-21T00:00:00]

This works too

time1 = ITime(5; epoch = DateTime(2012, 12, 21))
time2 = ITime(15)
time1 + time2
20.0 seconds (2012-12-21T00:00:20) [counter = 20, period = 1 second, epoch = 2012-12-21T00:00:00]

This does not work because the two ITimes don't have the same epoch

time1 = ITime(5; epoch = DateTime(2012, 12, 21))
time2 = ITime(15; epoch = DateTime(2025, 12, 21))
time1 + time2
Cannot find common epoch

When dealing with binary operations, it is convenient to think of ITimes as dimensional quantities (quantities with units). In the previous examples, addition returns a new ITimes. Division will behave differently

time1 = ITime(15)
time2 = ITime(5)
time1 / time2
3//1

In this case, the return value is just a number (essentially, we divided 15 seconds by 5 seconds, resulting the dimensionless factor of 3).

Similarly, adding a number to an ITime is not allowed (because the number doesn't have units), but multiplying by is fine.

In the same spirit, when ITimes with different periods are combined, units are transformed to the

time1 = ITime(5; period = Hour(1))
time2 = ITime(1; period = Minute(25))
time1 - time2
275.0 minutes [counter = 55, period = 5 minutes]

As we can see, the return value of 5 hours - 25 minutes is 275 minutes and it is represented as 55 clock cycles of a period of 5 minutes. Minute(5) was picked because it the greatest common divisor between Hour(1) and Minute(25).

At any point, we can obtain a date or a number of seconds from an ITime:

time1 = ITime(5; period = Day(10), epoch = DateTime(2012, 12, 21))
date(time1)
2013-02-09T00:00:00

And

time1 = ITime(5; period = Day(10), epoch = DateTime(2012, 12, 21))
seconds(time1)
4.32e6

In this, note that the seconds function always returns a Float64.

In this section, we saw that ITimes can be used to represent dates and times and manipulated in a natural way.

ITimes support another feature, fractional times, useful for further partitioning an interval of time.

Dealing with times that cannot be represented

Sometimes, one need to work with fractions of a period. The primary application of this is timestepping loops, which are typically divided in stages which are a fraction of a timestep.

In these cases, we round to the nearest amount representable as an ITime. Furthermore, these cases are constrained to only when an ITime is multiplied by a float between zero and one. These are the only cases we consider, because the only instance in which the problem of handling fraction of a period is in the timestepping loops.

See the examples below.

julia> time = ITime(1; period = Hour(1), epoch = DateTime(2012, 12, 21))1.0 hour (2012-12-21T01:00:00) [counter = 1, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 0.3 * time # round down0.0 hours (2012-12-21T00:00:00) [counter = 0, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 0.5 * time # round down0.0 hours (2012-12-21T00:00:00) [counter = 0, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 0.7 * time # round up1.0 hour (2012-12-21T01:00:00) [counter = 1, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 1.2 * time # error because 1.2 > 1.0ERROR: In most cases, multiplying an ITime by a float is not desirable. Cast the ITime into a float
julia> -0.2 * time # error because -0.2 < 0.0ERROR: In most cases, multiplying an ITime by a float is not desirable. Cast the ITime into a float

If multiplication between an ITime and a float is needed, you most likely want to cast the ITime into a float. For example, in functions that compute tendencies, there are no operations that are of the form t + a * dt where a is a float, so it is safe to cast the ITime into a float as it will not lead to a loss of accuracy in time.

In cases where you compute an expression of the form t + a * dt where a is a float or something similar, then rounding occurs. For packages like ClimaTimeSteppers, this means that any time stepping stages will incur a slight inaccuracy in the time if the period is not divisible by dt.

How to use ITimes in packages

The two key interfaces to work with ITimes are ClimaUtilities.TimeManager.seconds and ClimaUtilities.TimeManager.date, which respectively return the time as Float64 number of seconds and the associated DateTime.

If you want to support AbstractFloats and ITimes, some useful functions are float (which returns the number of seconds as a Float64), zero, one, and oneunit. The difference between one and oneunit is that the latter returns a ITime type, while the former returns a number. This is not what happens with zero, which returns an ITime.

You might have the temptation to just sprinkle float everywhere to transition your code to using ITimes. Resist to this temptation because it might defy the purpose of using integer times in the first place.

For typical codes and simulation, we recommend only setting t_start and t_end with a epoch: the epoch will be propagated naturally to all the other times involved in the calculations.

Regarding the question on what period to use: if there are natural periods (e.g., you are dealing with an hourly diagnostic variable), use it, otherwise you can stick with the default. The period can always be changed by setting it in t_start and t_end.

We provide a constructor from floating point numbers to assist you in transitioning your package to using ITimes. This constructor guesses and uses the largest period that can be used to properly represent the data.

ITime(60.0)
1.0 minute [counter = 1, period = 1 minute]

Beside the rounding which can lead to different results compared to using floats, the other change can come from using float to convert an ITime to a floating point type. At the moment, float(t::ITime) returns a Float64 which can cause changes if the model is ran with Float32 instead of Float64.

Compatibility with ClimaTimeSteppers

Only IMEXAlgorithms and SSPKnoth in ClimaTimeSteppers are compatible with ITime.

Common problems

How do I make several ITimes have the same type?

One can use promote to make all the variables have the same type. See the example below of making t0 and tf have the same type.

julia> t0 = ITime(0.25; epoch = DateTime(2012, 12, 21))250.0 milliseconds (2012-12-21T00:00:00.250) [counter = 250, period = 1 millisecond, epoch = 2012-12-21T00:00:00]
julia> tf = ITime(10; period = Hour(1), epoch = DateTime(2012, 12, 21))10.0 hours (2012-12-21T10:00:00) [counter = 10, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> typeof(t0) == typeof(tf)false
julia> t0_same_type, tf_same_type = promote(t0, tf)(250.0 milliseconds (2012-12-21T00:00:00.250) [counter = 250, period = 1 millisecond, epoch = 2012-12-21T00:00:00], 3.6e7 milliseconds (2012-12-21T10:00:00) [counter = 36000000, period = 1 millisecond, epoch = 2012-12-21T00:00:00])
julia> typeof(t0_same_type) == typeof(tf_same_type)true

Similarly, one can also make ITimes in an array have the same type with a bit more effort.

julia> itime_array = [t0, tf]2-element Vector{ClimaUtilities.TimeManager.ITime{Int64, DT, Dates.DateTime} where DT}:
 250.0 milliseconds (2012-12-21T00:00:00.250) [counter = 250, period = 1 millisecond, epoch = 2012-12-21T00:00:00]
 10.0 hours (2012-12-21T10:00:00) [counter = 10, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> eltype(itime_array)ClimaUtilities.TimeManager.ITime{Int64, DT, Dates.DateTime} where DT
julia> same_type_itime_arr = [promote(itime_array...)...]2-element Vector{ClimaUtilities.TimeManager.ITime{Int64, Dates.Millisecond, Dates.DateTime}}: 250.0 milliseconds (2012-12-21T00:00:00.250) [counter = 250, period = 1 millisecond, epoch = 2012-12-21T00:00:00] 3.6e7 milliseconds (2012-12-21T10:00:00) [counter = 36000000, period = 1 millisecond, epoch = 2012-12-21T00:00:00]
julia> eltype(same_type_itime_arr)ClimaUtilities.TimeManager.ITime{Int64, Dates.Millisecond, Dates.DateTime}

How do I multiply by a number by an ITime?

One can use float to cast the ITime into a floating point number representing the number of seconds since the epoch. Casting to a floating point number is okay as long as the floating point number is not used to keep track of time of the simulation.

julia> t = ITime(1, period = Minute(1), epoch = DateTime(2010))1.0 minute (2010-01-01T00:01:00) [counter = 1, period = 1 minute, epoch = 2010-01-01T00:00:00]
julia> 0.1 * float(t) # 6 seconds6.0

For more information, see the section Dealing with times that cannot be represented.

Developer notes

Why not use Dates directly?

Periods in Julia's Dates are also implemented as integer counters attached to a type that encode its units. So why not using them directly?

There are a few reasons why ITime is preferred over Julia's Dates:

  • Dates do not support fractional intervals, which are needed for the timestepping loop;
  • Dates only support the Gregorian calendar. ITime provides an abstraction layer that will allow us to support other calendars without changing packages;
  • Dates only allow the given periods, but it often natural to pick the simulation timestep as period;
  • Julia's Dates are not necessarily faster or better and, being part of the standard library, means that it is hard to improve them.

Why not support Rational?

Handling the case when the counter is rational introduces more complexity than necessary. For instance, it is unclear how to handle the case when a float is not perfectly representable as a rational number. This case comes up when examining the time stepping stages of ARS343 in ClimaTimeSteppers. In particular, the value $\gamma$ is the middle root of the polynomial $6x^3 - 18x^2 + 9x - 1 = 0$ and irrational. One could approximate $\gamma$ as a rational number, but large integers for the numerator and denominator are needed to approximate $\gamma$ to high accuracy. For example, $\gamma$ approximated as a rational number with tolerance within machine epsilon of Float64 is 19126397//43881317. This could lead to overflowing in either the numerator or denominator as $\gamma$ propagates through the code. Hence, rational numbers are not considered for this reason.

TimeManager API

ClimaUtilities.TimeManager.ITimeType
ITime ("Integer Time")

ITime is an integer (quantized) time.

ITime can be thought of as counting clock cycles (counter), with each tick having a fixed duration (period).

Another way to think about this is that this is time with units.

This type is currently using Dates, but one of the design goals is to try to be as agnostic as possible with respect to this so that in the future in will be possible to use a different calendar.

When using Dates, the minimum unit of time that can be represented is 1 nanosecond. The maximum unit of time is determined by the maximum integer number that can be represented.

Overflow occurs at 68 year * (1 Second / dt) for Int32 and 300 gigayear * (1 Second / dt) for Int64.

Fields

  • counter::INT: The number of clock cycles.
  • period::DT: The duration of each cycle.
  • epoch::EPOCH: An optional start date.
source
ClimaUtilities.TimeManager.ITimeMethod
ITime(t; epoch = nothing)

Construct an ITime from a number t representing a time interval.

The function attempts to find a Dates.FixedPeriod such that t can be represented as an integer multiple of that period.

If t is approximately zero, it defaults to a period of 1 second.

source
ClimaUtilities.TimeManager.ITimeMethod
ITime(t; epoch = nothing)

Construct an ITime from a number t representing a time interval.

The function attempts to find a Dates.FixedPeriod such that t can be represented as an integer multiple of that period.

If t is approximately zero, it defaults to a period of 1 second.

source
ClimaUtilities.TimeManager.dateFunction
date(t::ITime)

Return the date associated with t. If the time is fractional, round it to millisecond.

For this to work, t has to have a epoch.

source
Base.promoteMethod
promote(ts::ITime...)

Promote a tuple of ITime instances to a common type.

This function determines a common epoch and period for all the input ITime instances and returns a tuple of new ITime instances with the common type. It throws an error if the start dates are different.

source
Base.::Method
Base.:(:)(start::ITime, step::ITime, stop::ITime)

Range operator. start:step:stop constructs a range from start to stop with a step size equal to step.

source
Base.modMethod
Base.mod(x::ITime, y::ITime)

Return the counter of x modulo counter of y after promote x and y to the same period and epoch.

source
Base.iszeroMethod
Base.iszero(x::ITime)

Return true if the counter of x is zero.

source
Base.lengthMethod
Base.length(x::ITime)

Return the length of an ITime which is always one.

source
Base.floatMethod
float(t::ITime)

Convert an ITime to a floating-point number representing the time in seconds.

source
Base.oneMethod
Base.one(t::T) where {T <: ITime}

Return the multiplicative identity for an ITime which is 1.

source
Base.oneunitMethod
Base.oneunit(t::T) where {T <: ITime}

Return ITime(1, period(t), epoch(t)).

source
Base.zeroMethod
Base.zero(t::T) where {T <: ITime}

Return the additive identity element for an ITime which is ITime(0, period(t), epoch(t)).

source
Base.:*Method
Base.:*(t::ITime, a::AbstractFloat)

Multiplication between an ITime and float return an ITime whose counter is round(a * counter(t)), the same period as t, and the same epoch as t if it exists. The float a can only be between 0 and 1.

This function should only be used when subdividing time is necessary (e.g. time stepping stages in ClimaTimeSteppers). In most cases, it is preferable to convert t into a float if multiplication by a float is needed.

source