From 70c759747c62330c714ba500e29b8ce8deaa9388 Mon Sep 17 00:00:00 2001 From: Gabriele Bozzola Date: Sun, 3 Nov 2024 19:43:15 -0800 Subject: [PATCH] Add ITime, an integer time type This commit introduces the `ITime` type, a new approach to handling time and dates within CliMA simulations. `ITime` utilizes integer-based representation and operations, offering three advantages: - Eliminates floating-point errors. - Provides a trivial way to go from times to dates. - Provides an abstraction layer over the calendar system, enabling future support for calendars beyond the Gregorian calendar currently used by Dates. `ITime` is defined by a `counter`, a `period`, and an optional `start_date`. The counter tracks the number of elapsed periods since the start date and the period is customizable (1 second by default). `ITime`s also support fractional counters (currently represented by `Rational`s) to account for stages in the timestepper loop. `ITime`s support the arithmetic operations (+, -, *, /, div) while maintaining consistency in the units and the start date. This allows users to just specify `start_date` and `period` for one of their `ITime`s (e.g., `t_start`), and everything will be automatically propagated. Given an `ITime` `t`, one can obtain its time with `seconds(t)` and the date with `date(t)`. I also added `float(t)` to help with the transition. --- NEWS.md | 24 +++ README.md | 7 +- docs/src/timemanager.md | 223 +++++++++++++++++++++++++-- src/ITime.jl | 331 ++++++++++++++++++++++++++++++++++++++++ src/TimeManager.jl | 2 + test/Project.toml | 2 + test/itime.jl | 188 +++++++++++++++++++++++ test/runtests.jl | 3 +- test/timemanager.jl | 32 ++++ 9 files changed, 797 insertions(+), 15 deletions(-) create mode 100644 src/ITime.jl create mode 100644 test/itime.jl diff --git a/NEWS.md b/NEWS.md index a23b2229..e68cc141 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,30 @@ ClimaUtilities.jl Release Notes main ------ +v0.1.18 +------ + +#### New integer time type, `TimeManager.ITime`. PR [#124](https://github.com/CliMA/ClimaUtilities.jl/pull/124) + +`ClimaUtilities` now comes with a new type that represent times and date, +`ITime`. `ITime` stands for "integer time" and is a time type that does not +incur in floating point errors. `ITime`s also encode dates and support +arithmetic operations and fractions (to be used in timestepping loops). + +```julia-repl +julia> using ClimaUtilities.TimeManager, Dates; +julia> time1 = ITime(5; period = Hour(20), start_date = DateTime(2012, 12, 21)) +100 hours (2012-12-25T04:00:00) [counter = 5, period = 20 hours, start_date = 2012-12-21T00:00:00] +julia> seconds(time1) +360000.0 +julia> date(time1) +2012-12-25T04:00:00 +``` + +The [documentation](https://clima.github.io/ClimaUtilities.jl/dev/timemanager/) +provides further information about this new type and has a section dedicated to +helping developers port their codes to `ITime`s. + ### Bug fixes - Fixed `@clima_artifact` for Julia 1.12. PR [#123](https://github.com/CliMA/ClimaUtilities.jl/pull/123) diff --git a/README.md b/README.md index b8786a2d..a07c033c 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,15 @@ ClimaUtilities.jl -`ClimaUtilities` is collection of general-purpose tools for CliMA packages. +`ClimaUtilities` is collection of general-purpose tools and types for CliMA packages. `ClimaUtilities.jl` contains: - [`ClimaArtifacts`](https://clima.github.io/ClimaUtilities.jl/dev/climaartifacts/), a module that provides an MPI-safe way to lazily download artifacts and to tag artifacts that are being accessed in a given simulation. +- [`TimeManager`](https://clima.github.io/ClimaUtilities.jl/dev/timemanager/) to + handle time and dates. `TimeManager` implements `ITime`, the time type used in + CliMA simulations. - [`SpaceVaryingInputs` and `TimeVaryingInputs`](https://clima.github.io/ClimaUtilities.jl/dev/inputs/) to work with external input data. @@ -26,8 +29,6 @@ ClimaUtilities.jl to process input data and remap it onto the simulation grid. - [`OutputPathGenerator`](https://clima.github.io/ClimaUtilities.jl/dev/outputpathgenerator/) to prepare the output directory structure of a simulation. -- [`TimeManager`](https://clima.github.io/ClimaUtilities.jl/dev/timemanager/) to - handle dates. ## ClimaUtilities.jl Developer Guidelines diff --git a/docs/src/timemanager.md b/docs/src/timemanager.md index f69eac74..63ff1db5 100644 --- a/docs/src/timemanager.md +++ b/docs/src/timemanager.md @@ -1,16 +1,217 @@ # TimeManager -This module contains functions that handle dates and times -in simulations. The functions in this module often call -functions from Julia's [Dates](https://docs.julialang.org/en/v1/stdlib/Dates/) module. +`TimeManager` defines `ITime`, the time type for CliMA simulations, alongside +with various functions to work with it. -## TimeManager API +## `ITime` -```@docs -ClimaUtilities.TimeManager.to_datetime -ClimaUtilities.TimeManager.strdate_to_datetime -ClimaUtilities.TimeManager.datetime_to_strdate -ClimaUtilities.TimeManager.trigger_callback -ClimaUtilities.TimeManager.Monthly -ClimaUtilities.TimeManager.EveryTimestep +`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) a `start_date`, with the `counter` counting how many +`period`s have elapsed since `start_date`. 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: + +```@example example1 +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` + +```@julia example +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: +```@example example1 +ITime(5) +``` +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 `start_date` keyword +```@example example1 +using Dates +ITime(5; start_date = DateTime(2012, 12, 21)) ``` +Now, the output also reports `start_date` 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 +```@example example1 +time1 = ITime(5; period = Hour(20), start_date = DateTime(2012, 12, 21)) +``` +All these quantities are accessible with functions: +```@example example1 +@show "time1" counter(time1) period(time1) start_date(time1) date(time1) +``` + +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 a `start_date`, it is only possible to combine `ITime`s that +have the same `start_date`. `ITime`s can be composed to other times and +arithmetic operations propagate `start_date`s. + +This works: +```@example example1 +time1 = ITime(5; start_date = DateTime(2012, 12, 21)) +time2 = ITime(15; start_date = DateTime(2012, 12, 21)) +time1 + time2 +``` +This works too +```@example example1 +time1 = ITime(5; start_date = DateTime(2012, 12, 21)) +time2 = ITime(15) +time1 + time2 +``` +This does not work because the two `ITimes` don't have the same `start_date` +```@example example1 +time1 = ITime(5; start_date = DateTime(2012, 12, 21)) +time2 = ITime(15; start_date = DateTime(2025, 12, 21)) +time1 + time2 +``` + +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 +```@example example1 +time1 = ITime(15) +time2 = ITime(5) +time1 / time2 +``` +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 +```@example example1 +time1 = ITime(5; period = Hour(1)) +time2 = ITime(1; period = Minute(25)) +time1 - time2 +``` +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`: +```@example example1 +time1 = ITime(5; period = Day(10), start_date = DateTime(2012, 12, 21)) +date(time1) +``` +And +```@example example1 +time1 = ITime(5; period = Day(10), start_date = DateTime(2012, 12, 21)) +seconds(time1) +``` +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. + +### Fractional times + +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. + +`ITime`s can represent fractions of a `period` using rational numbers. For +example +```@example example1 +time1 = ITime(5) +time1 / 2 +``` +Here, we can see that the counter is now `5//2` (the fraction 5 divided by 2). +Only integer and rational counters are allowed. + +`ITime`s with fractional counters largely behave as `ITime`s with integer +counters with the exception that it is generally not possible to covert them to +dates without rounding. Given that Julia's Dates follow the UNIX representation +by being encoded with the number of milliseconds from an epoch, we also truncate +fractions to milliseconds when converting to dates. + +!!! note + The current implementation of fractional times uses the `Rational` module in the + standard library. This might have performance implications. Please, open an + issue if you have any. + +### How to use `ITime`s in packages + +The two key interfaces to work with `ITime`s are [`seconds`](@ref) and +[`date`](@ref), 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 `start_date`: the `start_date` 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. +```@example example1 +ITime(60.0) +``` + +## Developer notes + +### Why not using `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. + + + diff --git a/src/ITime.jl b/src/ITime.jl new file mode 100644 index 00000000..824e423f --- /dev/null +++ b/src/ITime.jl @@ -0,0 +1,331 @@ +export ITime, counter, period, start_date, date, seconds + +# ITime needs to support ratios too because the timestepper deals with stages, +# which are fractions of a basic unit. +const IntegerOrRatio = Union{Integer, Rational} + +""" + 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. + +`ITime` can also represent fractions of a cycle. It is recommended to stick with +integer times as much as possible. Fractions of a cycle cannot be converted to +dates. + +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. +- `start_date::START_DATE`: An optional start date. +""" +struct ITime{ + INT <: IntegerOrRatio, + DT <: Dates.FixedPeriod, + START_DATE <: Union{Nothing, Dates.DateTime}, +} + counter::INT + period::DT + start_date::START_DATE + + function ITime(counter, period, start_date) + if counter isa Rational && counter.den == 1 + return new{Int, typeof(period), typeof(start_date)}( + counter.num, + period, + start_date, + ) + else + return new{typeof(counter), typeof(period), typeof(start_date)}( + counter, + period, + start_date, + ) + end + end +end + +""" + ITime(counter::IntegerOrRatio; period::Dates.FixedPeriod = Dates.Second(1), start_date = nothing) + +Construct an `ITime` from a counter, a period, and an optional start date. + +If the `start_date` is provided as a `Date`, it is converted to a `DateTime`. +""" +function ITime( + counter::IntegerOrRatio; + period::Dates.FixedPeriod = Dates.Second(1), + start_date = nothing, +) + # Convert start_date to DateTime if it is not nothing (from, e.g., Date) + isnothing(start_date) || (start_date = Dates.DateTime(start_date)) + return ITime(counter, period, start_date) +end + +""" + seconds(t::ITime) + +Return the time represented by `t` in seconds, as a floating-point number. +""" +function seconds(t::ITime) + return float(t) +end + +# Accessors +""" + counter(t::ITime) + +Return the counter of the `ITime` `t`. +""" +function counter(t::ITime) + return t.counter +end + +""" + period(t::ITime) + +Return the period of the `ITime` `t`. +""" +function period(t::ITime) + return t.period +end + +""" + start_date(t::ITime) + +Return the start date of the `ITime` `t`. +""" +function start_date(t::ITime) + return t.start_date +end + +function date(t::ITime{<:IntegerOrRatio, <:Dates.FixedPeriod, Nothing}) + error("Time does not have start_date information") +end + +function date(t::ITime{<:Rational}) + # For Rational counters, we truncate at millisecond (which is the time + # resolution in Dates any way) + period_ms = Dates.toms(period(t)) + + # Compute time in ms rounding it off to the nearest Millisecond + time_ms = Int(round(float(counter(t) * period_ms))) + return start_date(t) + Dates.Millisecond(time_ms) +end + +""" + 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 `start_date` +""" +function date(t::ITime) + return start_date(t) + counter(t) * period(t) +end + +""" + DateTime(t::ITime) + +Convert an `ITime` to a `DateTime`. +""" +function Dates.DateTime(t::ITime) + return date(t) +end + +""" + ITime(t; start_date = 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. +""" +function ITime(t; start_date = nothing) + # If it is zero, assume seconds + isapprox(t, 0) && return ITime(0, Dates.Second(1), start_date) + + # Promote t to Float64 to avoid loss of precision + t = Float64(t) + periods = [ + Dates.Week, + Dates.Day, + Dates.Hour, + Dates.Minute, + Dates.Second, + Dates.Millisecond, + Dates.Microsecond, + Dates.Nanosecond, + ] + for period in periods + period_ns = Dates.tons(period(1)) + t_int = 1_000_000_000 / period_ns * t + if isinteger(t_int) + return ITime(Int(t_int), period(1), start_date) + end + end + error("Cannot represent $t as integer multiple of a Dates.FixedPeriod") +end + +function Base.show(io::IO, time::ITime) + # Hack to pretty print fractional times. We cannot just use Dates to print + # them because they cannot be nicely converted to Periods, instead of + # reconstruct the string from the type name and the value (obtained my + # multiplying the counter and the number of units in the period) + value = counter(time) * period(time).value + plural_s = abs(value) != 1 ? "s" : "" + unit = lowercase(string(nameof(typeof(period(time))))) * plural_s + + print(io, "$value $unit ") + # Add date, if available + if !isnothing(start_date(time)) && counter(time) isa Integer + print(io, "($(date(time))) ") + end + print(io, "[counter = $(counter(time)), period = $(period(time))") + # Add start date, if available + if !isnothing(start_date(time)) + print(io, ", start_date = $(start_date(time))") + end + print(io, "]") +end + +""" + promote(ts::ITime...) + +Promote a tuple of `ITime` instances to a common type. + +This function determines a common `start_date` 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. +""" +function Base.promote(ts::ITime...) + unique_start_dates = + Set(start_date(t) for t in ts if !isnothing(start_date(t))) + periods = Set(period(t) for t in ts) + + # Determine the common start_date + length(unique_start_dates) > 1 && + error("Incompatible start_dates: Cannot promote") + common_start_date = + length(unique_start_dates) == 0 ? nothing : first(unique_start_dates) + + # Determine the common period + common_period = reduce(gcd, periods) + + # Promote each ITime instance by computing the scaling factor needed + return map( + t -> ITime( + counter(t) * div(period(t), common_period), + common_period, + common_start_date, + ), + ts, + ) +end + +macro itime_unary_op(op) + return esc( + quote + Base.$op(t::T) where {T <: ITime} = + ITime($op(t.counter), t.period, t.start_date) + end, + ) +end + +macro itime_binary_op(op) + return esc( + quote + function Base.$op(t1::T1, t2::T2) where {T1 <: ITime, T2 <: ITime} + t1p, t2p = promote(t1, t2) + ITime( + $op(t1p.counter, t2p.counter), + t1p.period, + t1p.start_date, + ) + end + end, + ) +end + +macro itime_binary_op_notype(op) + return esc( + quote + function Base.$op(t1::T1, t2::T2) where {T1 <: ITime, T2 <: ITime} + t1p, t2p = promote(t1, t2) + $op(t1p.counter, t2p.counter) + end + end, + ) +end + +@itime_unary_op abs +@itime_unary_op - + +@itime_binary_op + +@itime_binary_op - + +Base.isnan(t::ITime) = Base.isnan(t.counter) + +@itime_binary_op_notype isless +@itime_binary_op_notype == +@itime_binary_op_notype isequal +@itime_binary_op_notype isapprox +@itime_binary_op_notype div +@itime_binary_op_notype // +Base.:/(t1::T1, t2::T2) where {T1 <: ITime, T2 <: ITime} = t1 // t2 + +# Multiplication/division by numbers +Base.div(t::T1, num::IntegerOrRatio) where {T1 <: ITime} = + ITime(div(t.counter, num), t.period, t.start_date) +function Base.://(t::T1, num::IntegerOrRatio) where {T1 <: ITime} + new_counter_rational = t.counter // num + new_counter = + new_counter_rational.dem == 1 ? new_counter_rational.num : + new_counter_rational + ITime(t.counter // num, t.period, t.start_date) +end +Base.:/(t::T1, num::IntegerOrRatio) where {T1 <: ITime} = t // num +Base.:*(num::IntegerOrRatio, t::T) where {T <: ITime} = + ITime(num * t.counter, t.period, t.start_date) +Base.:*(t::T, num::IntegerOrRatio) where {T <: ITime} = + ITime(num * t.counter, t.period, t.start_date) + +# TODO: Add errors when working with Floats + +# Pay attention to the units here! zero and one are not symmetric +Base.one(t::T) where {T <: ITime} = 1 +Base.oneunit(t::T) where {T <: ITime} = ITime(1, t.period, t.start_date) +Base.zero(t::T) where {T <: ITime} = ITime(0, t.period, t.start_date) + +""" + float(t::ITime) + +Convert an `ITime` to a floating-point number representing the time in seconds. +""" +function Base.float(t::T) where {T <: ITime} + if VERSION >= v"1.11" + return Dates.seconds(t.period) * t.counter + else + return Dates.tons(t.period) / 1_000_000_000 * t.counter + end +end + +# Behave as a scalar when broadcasted +Base.Broadcast.broadcastable(t::ITime) = Ref(t) diff --git a/src/TimeManager.jl b/src/TimeManager.jl index 7f129c01..eedbbfc5 100644 --- a/src/TimeManager.jl +++ b/src/TimeManager.jl @@ -15,6 +15,8 @@ export to_datetime, Monthly, EveryTimestep +include("ITime.jl") + """ to_datetime(date) diff --git a/test/Project.toml b/test/Project.toml index dfe26b1b..d60d815a 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -5,6 +5,7 @@ CFTime = "179af706-886a-5703-950a-314cd64e0468" ClimaComms = "3a4d1b5c-c61d-41fd-a00a-5873ba7a1b0d" ClimaCore = "d414da3d-4745-48bb-8d80-42e94e092884" ClimaCoreTempestRemap = "d934ef94-cdd4-4710-83d6-720549644b70" +ClimaTimeSteppers = "595c0a79-7f3d-439a-bc5a-b232dc3bde79" ClimaUtilities = "b3f4f4ca-9299-4f7f-bd9b-81e1242a7513" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" @@ -14,6 +15,7 @@ LazyArtifacts = "4af54fe1-eca0-43a8-85a7-787d91b784e3" NCDatasets = "85f8d34a-cbdd-5861-8df4-14fed0d494ab" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" +SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] diff --git a/test/itime.jl b/test/itime.jl new file mode 100644 index 00000000..733835fb --- /dev/null +++ b/test/itime.jl @@ -0,0 +1,188 @@ +using ClimaUtilities.TimeManager + +using Test, Dates + +@testset "ITime" begin + @testset "Constructors" begin + # Constructor with just an integer counter + t1 = ITime(10) + @test t1.counter == 10 + @test t1.period == Dates.Second(1) + @test isnothing(t1.start_date) + + # Constructor with just an integer counter in Int32 + t1_int32 = ITime(Int32(10)) + @test t1_int32.counter isa Int32 + @test t1_int32.counter == 10 + + # Constructor with just an integer ratio + t2 = ITime(1 // 2) + @test t2.counter == 1 // 2 + @test t2.period == Dates.Second(1) + @test isnothing(t2.start_date) + + # Constructor with start date + start_date = Dates.DateTime(2024, 1, 1) + t3 = ITime(10, start_date = start_date) + @test t3.start_date == start_date + + # Start date as Date (not DateTime) + t4 = ITime(10, start_date = Dates.Date(2024, 1, 1)) + @test t4.start_date == start_date + + # Explicit period + t5 = ITime(10, period = Dates.Millisecond(100)) + @test t5.period == Dates.Millisecond(100) + + # Rational with denominator 1 converts to Integer + t6 = ITime(3 // 1) + @test t6.counter == 3 + @test t6.counter isa Int + + # From float + t6 = ITime(0.0) + @test t6.counter == 0 + @test t6.period == Dates.Second(1) + + t7 = ITime(1.0) + @test t7.counter == 1 + @test t7.period == Dates.Second(1) + + t8 = ITime(0.001) + @test t8.counter == 1 + @test t8.period == Dates.Millisecond(1) + + t9 = ITime(1.5) + @test t9.counter == 1500 + @test t9.period == Dates.Millisecond(1) + + + t10 = ITime(1.5; start_date = Dates.DateTime(2020, 1, 1)) + @test t10.start_date == Dates.DateTime(2020, 1, 1) + + # Cannot be represented exactly + @test_throws ErrorException ITime(1e-20) + end + + @testset "Accessors" begin + t = ITime(10, Dates.Millisecond(50), Dates.DateTime(2024, 1, 1)) + @test counter(t) == 10 + @test period(t) == Dates.Millisecond(50) + @test start_date(t) == Dates.DateTime(2024, 1, 1) + end + + @testset "date" begin + t1 = ITime(10, start_date = Dates.DateTime(2024, 1, 1)) + @test date(t1) == Dates.DateTime(2024, 1, 1) + Dates.Second(10) + @test Dates.DateTime(t1) == + Dates.DateTime(2024, 1, 1) + Dates.Second(10) + + # Correct conversion with rational counter + t2 = ITime(1 // 2, start_date = Dates.DateTime(2024, 1, 1)) + @test date(t2) == t2.start_date + Dates.Millisecond(500) + + # Cannot convert to date without a start date + t3 = ITime(10) + @test_throws ErrorException date(t3) # No start date + end + + @testset "Promote" begin + t1 = ITime(10, period = Dates.Millisecond(100)) + t2 = ITime(100, period = Dates.Millisecond(10)) + t1_promoted, t2_promoted = promote(t1, t2) + @test t1_promoted.counter == 100 + @test t2_promoted.counter == 100 + @test t1_promoted.period == Dates.Millisecond(10) + + t3 = ITime(10, start_date = Dates.DateTime(2024, 1, 1)) + t4 = ITime(20) + + t3_promoted, t4_promoted = promote(t3, t4) + @test t3_promoted.start_date == Dates.DateTime(2024, 1, 1) + @test t4_promoted.start_date == Dates.DateTime(2024, 1, 1) + + @test_throws ErrorException promote( + t3, + ITime(10, start_date = Dates.DateTime(2024, 1, 2)), + ) + end + + @testset "Arithmetic Operations" begin + t1 = ITime(10) + t2 = ITime(5) + @test t1 + t2 == ITime(15) + @test t1 - t2 == ITime(5) + @test -t1 == ITime(-10) + @test abs(t1) == ITime(10) + + t3 = ITime(10, period = Dates.Millisecond(100)) + t4 = ITime(100, period = Dates.Millisecond(10)) + @test t3 + t4 == ITime(200, period = Dates.Millisecond(10)) # 10*100 + 100*10 = 2000 ms = 2s + + @test !(t1 < t2) + @test t1 > t2 + @test t1 == ITime(10) + @test t1 != ITime(5) + @test isapprox(t1, ITime(10)) + + @test t1 / 2 == ITime(5) + @test t1 * 2 == ITime(20) + @test 2 * t1 == ITime(20) + @test div(t1, 2) == ITime(5) + @test t1 / t2 == 2 + @test t2 / t1 == 1 // 2 + + @test one(t1) == 1 + @test oneunit(t1) == ITime(1) + @test zero(t1) == ITime(0) + + + t5 = ITime(10, start_date = Dates.DateTime(2024, 1, 1)) + t6 = ITime(5, start_date = Dates.DateTime(2024, 1, 1)) + @test (t5 + t6).start_date == t5.start_date + + t7 = ITime(5, start_date = Dates.DateTime(2024, 10, 1)) + # Arithmetic operations between ITime with different start_dates are disallowed + @test_throws ErrorException t6 + t7 + end + + @testset "Float Conversion and Broadcasting" begin + t1 = ITime(10, period = Dates.Millisecond(100)) + @test float(t1) == 1.0 + + # Test broadcasting (simple example) + @test float.(t1) == 1.0 + end + + @testset "Show method" begin + t1 = ITime(10) + @test sprint(show, t1) == "10 seconds [counter = 10, period = 1 second]" + + t2 = ITime(10, start_date = Dates.DateTime(2024, 1, 1)) + @test sprint(show, t2) == + "10 seconds (2024-01-01T00:00:10) [counter = 10, period = 1 second, start_date = 2024-01-01T00:00:00]" + + t3 = ITime(1 // 2) + @test sprint(show, t3) == + "1//2 seconds [counter = 1//2, period = 1 second]" + + t4 = ITime( + 10, + period = Dates.Hour(1), + start_date = Dates.DateTime(2024, 1, 1), + ) + @test sprint(show, t4) == + "10 hours (2024-01-01T10:00:00) [counter = 10, period = 1 hour, start_date = 2024-01-01T00:00:00]" + end + + @testset "Rational counter tests" begin + t1 = ITime(2) + t2 = ITime(1 // 2) + + @test t1 + t2 == ITime(5 // 2) + @test t1 - t2 == ITime(3 // 2) + @test t2 - t1 == ITime(-3 // 2) + @test t2 * 2 == ITime(1) + @test t1 / 2 == ITime(1) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index beeb92bc..1fc97bca 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -29,7 +29,8 @@ end end @safetestset "TimeManager tests" begin - include("timemanager.jl") + # include("timemanager.jl") + include("itime.jl") end @safetestset "DataStructures tests" begin diff --git a/test/timemanager.jl b/test/timemanager.jl index da5fba1a..289a5dd7 100644 --- a/test/timemanager.jl +++ b/test/timemanager.jl @@ -3,6 +3,38 @@ import Dates import CFTime using Test +import ClimaTimeSteppers +import SciMLBase + +@testset "TimeStepper" begin + # Checking that we can complete an integration with SSPKnoth with both an + # explicit and implicit components + λ = -0.1 + Y0 = [1.0] + t0 = TimeManager.ITime(0.0; start_date = Dates.DateTime(2024)) + tf = TimeManager.ITime(10.0) + dt = TimeManager.ITime(0.1) + tendency! = (Yₜ, Y, _, t) -> Yₜ .= λ .* Y + ode_algo = ClimaTimeSteppers.RosenbrockAlgorithm( + ClimaTimeSteppers.tableau(ClimaTimeSteppers.SSPKnoth()), + ) + Wfact = (W, Y, p, dtγ, t) -> W .= dtγ * λ - 1 + jac_prototype = [0.0;;] + T_imp! = SciMLBase.ODEFunction( + tendency!; + jac_prototype = jac_prototype, + Wfact = Wfact, + ) + prob = SciMLBase.ODEProblem( + ClimaTimeSteppers.ClimaODEFunction(T_exp! = tendency!, T_imp! = T_imp!), + Y0, + (t0, tf), + (;), + ) + + SciMLBase.solve(prob, ode_algo; dt) +end + for FT in (Float32, Float64) @testset "test to_datetime for FT=$FT" begin # Test non-leap year behavior