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 period
s 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 ITime
s 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 ITime
s, we can manipulate them. ITime
s support most (but not all) arithmetic operations.
For ITime
s with an epoch
, it is only possible to combine ITime
s that have the same epoch
. ITime
s can be composed to other times and arithmetic operations propagate epoch
s.
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 ITime
s as dimensional quantities (quantities with units). In the previous examples, addition returns a new ITime
s. 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 ITime
s 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 ITime
s can be used to represent dates and times and manipulated in a natural way.
ITime
s 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 down
0.0 hours (2012-12-21T00:00:00) [counter = 0, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 0.5 * time # round down
0.0 hours (2012-12-21T00:00:00) [counter = 0, period = 1 hour, epoch = 2012-12-21T00:00:00]
julia> 0.7 * time # round up
1.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.0
ERROR: 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.0
ERROR: 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 ITime
s in packages
The two key interfaces to work with ITime
s 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 AbstractFloat
s and ITime
s, 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 ITime
s. 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 ITime
s. 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.
Only IMEXAlgorithms and SSPKnoth in ClimaTimeSteppers are compatible with ITime.
Common problems
How do I make several ITime
s 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 ITime
s 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 seconds
6.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 asperiod
;- 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.ITime
— TypeITime ("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.
ClimaUtilities.TimeManager.ITime
— MethodITime(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.
ClimaUtilities.TimeManager.ITime
— MethodITime(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.
ClimaUtilities.TimeManager.seconds
— Functionseconds(t::ITime)
Return the time represented by t
in seconds, as a floating-point number.
ClimaUtilities.TimeManager.counter
— Functioncounter(t::ITime)
Return the counter of the ITime
t
.
ClimaUtilities.TimeManager.period
— Functionperiod(t::ITime)
Return the period of the ITime
t
.
ClimaUtilities.TimeManager.epoch
— Functionepoch(t::ITime)
Return the start date of the ITime
t
.
ClimaUtilities.TimeManager.date
— Functiondate(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
.
Base.promote
— Methodpromote(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.
Base.::
— MethodBase.:(:)(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.
Base.mod
— MethodBase.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.
Base.iszero
— MethodBase.iszero(x::ITime)
Return true
if the counter of x
is zero.
Base.length
— MethodBase.length(x::ITime)
Return the length of an ITime which is always one.
Base.float
— Methodfloat(t::ITime)
Convert an ITime
to a floating-point number representing the time in seconds.
Base.one
— MethodBase.one(t::T) where {T <: ITime}
Return the multiplicative identity for an ITime
which is 1
.
Base.oneunit
— MethodBase.oneunit(t::T) where {T <: ITime}
Return ITime(1, period(t), epoch(t))
.
Base.zero
— MethodBase.zero(t::T) where {T <: ITime}
Return the additive identity element for an ITime
which is ITime(0, period(t), epoch(t))
.
Base.:*
— MethodBase.:*(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.