Parsing String to Dates.CompoundPeriod

Hello,

I have Strings representing duration like "0.123" (in seconds, with millisecond resolution), "1:2.123" (minute:second.millisecond), "1:2:3.456" (hour:minute:second.millisecond)
I wonder if some functions to parse String to Dates.CompoundPeriod exists.

I did my own functions

using Test
using Dates
import Base: parse, tryparse


function parse(::Type{Second}, s)
    # s.xxxxxxxxx
    n = count(i->(i=='.'), s)
    if n == 0
        Second(s)
    elseif n == 1
        s, ns = split(s, ".")
        if length(ns) > 9
            throw(InexactError)
        else
            while(length(ns) < 9)
                ns = ns * "0"
            end
        end
        Second(s) + Millisecond(ns[1:3]) + Microsecond(ns[4:6]) + Nanosecond(ns[7:9])
    else
        throw(ArgumentError("invalid s.ms \"$s\""))
    end
end

function tryparse(::Type{Second}, s, args... ; kwargs...)
    try
        parse(Second, s, args... ; kwargs...)
    catch
        nothing
    end
end

@test parse(Second, "4") == Second(4)
@test parse(Second, "4.321") == Second(4) + Millisecond(321)
@test parse(Second, "4.3") == Second(4) + Millisecond(300)
@test parse(Second, "4.300") == Second(4) + Millisecond(300)
@test parse(Second, "4.003") == Second(4) + Millisecond(3)
@test parse(Second, "4.003002") == Second(4) + Millisecond(3) + Microsecond(2)
@test parse(Second, "4.003002001") == Second(4) + Millisecond(3) + Microsecond(2) + Nanosecond(1)
@test parse(Second, "0") == Second(0)
@test parse(Second, "0.0") == Second(0)
@test parse(Second, "0.0") == Dates.CompoundPeriod()


function parse(::Type{Dates.CompoundPeriod}, s::AbstractString)
    n = count(i->(i==':'), s)
    if n == 0
        parse(Second, s)
    elseif n == 1  # mm:ss
        mm, ss = split(s, ":")
        ss = parse(Second, ss)
        Minute(mm) + ss
    elseif n == 2  # hh:mm:ss
        hh, mm, ss = split(s, ":")
        ss = parse(Second, ss)
        Hour(hh) + Minute(mm) + ss
    else
        throw(ArgumentError("invalid hh:mm:ss \"$s\""))
    end
end


function tryparse(::Type{Dates.CompoundPeriod}, s, args... ; kwargs...)
    try
        parse(Dates.CompoundPeriod, s, args... ; kwargs...)
    catch
        nothing
    end
end

@test parse(Dates.CompoundPeriod, "4.321") == Second(4) + Millisecond(321)
@test parse(Dates.CompoundPeriod, "5:4.321") == Minute(5) + Second(4) + Millisecond(321)
@test parse(Dates.CompoundPeriod, "6:5:4.321") == Hour(6) + Minute(5) + Second(4) + Millisecond(321)
@test parse(Dates.CompoundPeriod, "0") == Dates.CompoundPeriod()

but I can’t imagine that it doesn’t exist (better written) in either Base or in an existing package.

I’m not looking for parsing as Dates.Time (because hour can exceed 24).

Any idea?

Kind regards

There may not be a parse function if the format is not considered standard. Also, defining a method for a type you don’t own is type piracy. It is perfectly fine though to define your own type, eg CustomCompundParser, and define a tryparse for that.

Looks like you have some sort of definition of the string format, when you have the prior knowledge of the input format, you could use regular expression.

Made some quick pattern using https://regex101.com/, this should get you pretty close. (no guarantee)

function parse_date(s::AbstractString)
    HHMMSS = r"(((\d*):)?(\d*):)?((\d+)(\.(\d+))?)"
    regex = match(HHMMSS, s)
    
    HH = regex.captures[3] != nothing ? tryparse(Int, regex.captures[3]) : 0
    MM = regex.captures[4] != nothing ? tryparse(Int, regex.captures[4]) : 0
    SS = tryparse(Int,regex.captures[6]) # it should always have something?? idk
    ss = regex.captures[8] != nothing ? tryparse(Int, regex.captures[8]) : 0

    return Hour(HH) + Minute(MM) + Second(SS) + Millisecond(ss)
end

One thing about type piracy, I think you can define this special time format as a custom type, like HHMMSSssString, then define

parse(::Type{Dates.CompoundPeriod},s::HHMMSSssString)

Technically only you know what this format looks like, and how to parse it into compound period, so

parse(::Type{Dates.CompoundPeriod}, s::AbstractString)

is a bit too greedy.

1 Like

Thanks @Tamas_Papp and @cchderrick for your warning about type piracy to avoid to be to “greedy” with type.

@cchderrick thanks for the regex but I don’t think your code is correct

6:5:4.32 

I haven’t test it now… but I think 32 will become 32 millisecond (instead of 320)

You are correct that my code doesn’t work. I was a bit hasty. It looks like it have to do some of your logic on the ss string.

        if length(ns) > 9
            throw(InexactError)
        else
            while(length(ns) < 9)
                ns = ns * "0"
            end
        end

and you can probably use rpad

julia> rpad("3",9,"0")
"300000000"

or just numerical round

julia> round(Int,0.3*1000)
300

julia> round(Int,0.32*1000)
320

julia> round(Int,0.321*1000)
321

Using rpad is a good idea

but I noticed that using CompoundPeriod for storing duration is in fact a bad idea

julia> Dates.Minute(1) + Dates.Second(1) > Dates.Minute(1)
ERROR: MethodError: no method matching isless(::Minute, ::Dates.CompoundPeriod)
Closest candidates are:
  isless(::Missing, ::Any) at missing.jl:70
  isless(::Union{Day, Hour, Microsecond, Millisecond, Minute, Nanosecond, Second, Week}, ::Union{Month, Year}) at C:\cygwin\home\Administrator\buildbot\worker\package_win64\build\usr\share\julia\stdlib\v1.1\Dates\src\periods.jl:444
  isless(::P<:Period, ::P<:Period) where P<:Period at C:\cygwin\home\Administrator\buildbot\worker\package_win64\build\usr\share\julia\stdlib\v1.1\Dates\src\periods.jl:70
  ...
Stacktrace:
 [1] <(::Minute, ::Dates.CompoundPeriod) at .\operators.jl:260
 [2] >(::Dates.CompoundPeriod, ::Minute) at .\operators.jl:286
 [3] top-level scope at none:0

because I want to compare duration.

What is the Julia equivalent to Python datetime.timedelta (to be able to compare duration?)

hmm… interesting. I would also have thought it is implemented, given +, and - are already implemented.

Well, here is my attempt, using differences.

function duration_gt(x::Dates.CompoundPeriod, y::Dates.CompoundPeriod)
    d = y-x
    # probably bad assumption that the first period is always 
    # the lower time resolution and canonicalized?
    first(d.periods).value < 0 
end
duration_gt(x::Dates.CompoundPeriod, y::Dates.Period) = duration_gt(x,Dates.CompoundPeriod(y))
duration_gt(x::Dates.Period, y::Dates.CompoundPeriod) = duration_gt(Dates.CompoundPeriod(x),y)
duration_gt(x::Dates.Period, y::Dates.Period) = duration_gt(Dates.CompoundPeriod(x),Dates.CompoundPeriod(y))

Some quick test cases:

julia> W1D1 = Week(1)+Day(1)
1 week, 1 day

julia> S2 = Second(2)
2 seconds

julia> MS300 = Microsecond(300)
300 microseconds

julia> duration_gt(W1D1,S2)
true

julia> duration_gt(S2,W1D1)
false

julia> duration_gt(S2,MS300)
true

julia> duration_gt(MS300,S2)
false

I think it is not implemented, because Year and Month are not fixed duration, it doesn’t work with CompoundPeriond with Month or Year in it.

In fact to allow such comparison we must assume that we are comparing “fixed” CompoundPeriod.

I don’t know if such function exist in Base but I would define something like:

function isfixed(x::Dates.CompoundPeriod)
   typ = map(typeof, x.periods)
   !(Year in typ || Month in typ)
end

and will add something like

@assert(isfixed(x), "x must be a fixed CompoundPeriod")

and

@assert(isfixed(y), "y must be a fixed CompoundPeriod")

Issue about implementing CompoundPeriod comparison opened

https://github.com/JuliaLang/julia/issues/32389

It’s also fine if you’re not putting something in a package intended for use by others, right? In my data analysis projects I often commit type piracy, but I don’t ever expect someone to using MyProject.

Looks like FixedPeriod is defined:
https://github.com/JuliaLang/julia/blob/master/stdlib/Dates/src/periods.jl#L382

julia> Dates.FixedPeriod
Union{Day, Hour, Microsecond, Millisecond, Minute, Nanosecond, Second, Week}

Type piracy can also have unintended consequences if it diverts dispatch of existing methods. It can, of course, be perfectly fine under the right conditions, but I think it is a good habit to reserve it for cases where it is definitely needed.

If you need it often, that may be a code smell.

1 Like