Hi all. The ISO 8601 standard allows referring to ...
# kotlinx-datetime
k
Hi all. The ISO 8601 standard allows referring to
24:00
as midnight corresponding to the instant occuring at the end of a calendar day. Is there any way we could support doing such a thing in kotlinx-datetime? Currently, it's impossible to model an open end range of data that spans from midnight to midnight with local times, for example, because of this.
Copy code
LocalTime(hour = 0, minute = 0) ..< LocalTime(hour = 24, minute = 0) // invalid
LocalTime(hour = 0, minute = 0) ..< LocalTime(hour = 0, minute = 0) // empty range 
LocalTime(hour = 0, minute = 0) ..< LocalTime(hour = 23, minute = 59, second = 59, nanosecond = 999999) // technically incorrect
I'm happy to open a github issue if the discussion should continue there.
1
Here's the section of ISO 8601:2004(E) specifying this. Side note, why in the world do I have to purchase this document to view the specific in its original text? 🫠
e
Technically, that's no longer valid since support for "24:00" was removed in ISO 8601:2019. And looking at local time in that way could be a bad idea in the face of daylight savings.
k
I suspect the language semantics should be such that: 1.
LocalTime(hour = 0, minute = 0) < LocalTime(hour = 24, minute = 0) == true
2.
LocalDate(year = 2023, monthNumber = 1, dayOfMonth = 1).atTime(LocalTime(hour = 24, minute = 0)) == LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 2, hour = 0, minute = 0)
3.
LocalDateTime.time
should always return
00:00
and not
24:00
I feel like I’m definitely missing something, though, as surely the change can’t be this easy. If it is this easy I’m happy to make a PR!
Oh I didn’t realize it was removed!
This is what happens when you download and outdated version of ISO 8601 on libgen when it’s not available freely to the public but is an industry standard 🙃
e
ISO needs to make their money somehow!
k
You’d think since it’s an industry standard it’d have a foundation!
but I digress. I agree with you
r
Copy code
LocalTime(hour = 0, minute = 0) ..< LocalTime(hour = 23, minute = 59, second = 59, nanosecond = 999_999_999)
Would be a bit cumbersome but not technically incorrect. At least on the JVM,
999_999_999
is the maximum value for
NANO_OF_SECOND
.
If multiplatform, then 🤷 -- guess you'd need an expect actual to get the correct max value on each platform.
All that to say, yeah, you should be able to represent this with 24:00 IMO
Or alternatively hide the platform-specific complexity of an
LocalTime.EOD
val in the library so users's can just use
<..LocalTime.EOD
.
@kevin.cianfarini if you create an issue/PR for this can you post it here? I'd like to follow it.
👀 1
k
Copy code
LocalTime(hour = 0, minute = 0) ..< LocalTime(hour = 23, minute = 59, second = 59, nanosecond = 999_999_999)
Is not correct because this is an open end range. If we were to represent it as a closed range it would look like this
Copy code
LocalTime(hour = 0, minute = 0) .. LocalTime(hour = 23, minute = 59, second = 59, nanosecond = 999_999_998)
Does that nanosecond matter? probably not, but it’s still technically incorrect.
Also, considering that the ISO 8601:2019 standard removed support for 240000 I believe it’s probably prudent to follow their guidance and not offer a 24 analog
r
Good point. I don't see any reason not to use the closed range though. Presumably you want to be able to do contains operators on any time interval, which could be the entire day, so representing it as a closed range is fine. The open range would make sense only for
24:00
. Agreed on ISO, but implementing an
LocalTime.EOD
constant would be useful if there are complexities getting this technically correct for multiple platforms. And knowing dates/times, there almost certainly are.
k
The open end range happens to be convenient for every other scenario other than ones ending at midnight. But yes I suspect that will be the best method to do so
d
Maybe the code you're writing could be restructured in a more straightforward manner that also wouldn't rely on
hour = 24
? Working directly with
LocalTime
is itself suspicious, actually: what do you want to happen if, due to a DST transition, 00:30 directly followed 23:30 of the previous day?
r
@Dmitry Khalanskiy [JB] I'm not the OP, but speaking for myself... If I want to check if an event happened in local time between a certain range of local times, why would I care about DST? It would be impossible for the event to happen between 23:30 and 00:30 local, because those times never existed in local time.
k
@Dmitry Khalanskiy [JB] The use case is tough. This is representing recurring events in two categories — weekends and weekdays. We generally don’t care about specific dates here, only that there’s a difference between a weekday and a weekend day. A separate use case for us is modeled with
OpenEndRange<Instant>
and that works wonderfully.
r
@kevin.cianfarini And presumably the recurring event is defined in local time, which translates to whatever Instant is applicable for the relevant zone and date of ocurrence?
k
Kind of. This is for electricity pricing that tracks something called time of use rates. Certain time of use rates track the wholesale electricity market and thus those rates are date specific. That’s the easy use case represented by
OpenEndRange<Instant>
. Things get more complicated when you have “lower tech” time of use rates that recur each day, on weekdays and weekend, or maybe even something like every wednesday. Generally since electricity prices are highly localized we only ever want to represent this in local time because it’s not local to a customers device — it’s local to where the electricity is being distributed and consumed (a building). We can represent most of our pricing with
OpenEndRange<Instant>
but there are some old fashioned electricity meters that don’t connect to the internet in any fashion and thus use mechanical registers with hard coded local times.
r
And you don't know the time zone those meters exist in?
k
We do. But since they’re recurring it’s probably not great to expand those recurrences out into date specific ones, I think?
We originally tried pinning these local times to the current date and it didn’t work very well. Mainly because of the different groupings for weekends and weekdays for example.
Open to suggestions for how to model this data as I’m also proposing a backend API which suffers from this same problem.
Copy code
"""
A local, timezone agnostic time as is defined in ISO-8601.

See: <https://en.wikipedia.org/wiki/ISO_8601#Local_time_(unqualified)> for more details. 
"""
scalar LocalTime

"""
The cost charged per unit of usage of a given resource when applied in the time range 
[validFromInclusive, validToInclusive].

Since this rate is represented with a LocalTime, and a LocalTime of `00:00` denotes
the beginning of a day and not the end, extra care should be taken when dealing with 
end bounds that occur at midnight. Eg. [00:00, 00:00] technically represents an empty
range beginning and ending at midnight of the same day. Instead, we should treat a 
full day's range as [00:00, 23:59:59.9999999]. In this case that is semantically 
equivalent to midnight of the following day. 
"""
type LocalTimedRate {
  validFromInclusive: LocalTime!
  validToInclusive: LocalTime!
  rate: UnitRate!
}
d
An interesting use case, thanks! I would assume that these rates can never overlap, and there can't be gaps between them. Is this correct?
If I want to check if an event happened in local time between a certain range of local times, why would I care about DST?
Ok, here's another example of why you would care. If clocks jumped from 19:00 back to 18:00 and you check if a local time is in the range 1100 1830, then, in the span of three hours between 17:30 and 19:30, you get "yes, no, yes, no." There aren't many cases when this logic makes sense.
👍 1
k
Yup! Rates can't overlap.
d
Then the whole idea of using ranges seems suboptimal: you'd have a list of ranges like
[00:00; 04:00), [04:00; 09:30); [09:30; 13:30); [13:30, 18:00); [18:00; 24:00)
. But almost half of this information is useless. Why not look at the rates for the whole day instead?
Copy code
class RatesForTheDay {
  // the rate from midnight until the first change
  val initialRate: UnitRate
  // the list of changes to the rates throughout the day: the time the new rate starts and the new rate; sorted by the time
  transitions: List<Pair<LocalTime, UnitRate>>
}
This way, you wouldn't have the issues with ensuring the ranges cover the whole day and don't overlap; this works out of the box. You also won't need the notion of midnight.
👍 1
k
Let me think on that for a bit.
I see. So the end time are implicitly inferred by the start time of the next rate.
I actually kind of like it. It allows us to more easily represent ranges which wrap, too.
Eg. • Day Rate:
[05:30, 23:30)
• Night rate:
[23:30, 05:30)
<-- technically invalid range
I agree that representing this as any specific range type which allows the
in
operator is silly
I need to noodle on this more. Not sure if there's edge cases here.
r
You could potentially make the data structure easier to use for callers by removing the special case for the
initialRate
, and adding an invariant to check the structure always starts at midnight:
Copy code
class RatesForTheDay {
  val transitions: List<Pair<LocalTime, UnitRate>>
  init {
    require(transitions.firstOrNull()?.first == LocalTime(0, 0) { "Daily rates must start at midnight" }
  }
}
k
One of the requirements we have is that rates must not always start at midnight. These timings are a real example of that: • Day Rate:
[05:30, 23:30)
• Night rate:
[23:30, 05:30)
<-- technically invalid range
r
You could translate that to: 00:00 - 05:30 : night 05:30 - 23:30 : day 23:30 - : night But yeah, now the ranges are one step removed from how the business will define them.
k
Yup.
I think with the local timed rates what we’re really modeling is when registers flip, not the actual time of the rates.
I’m gonna spend some time thinking about this today but it won’t be until this afternoon ET unfortunately. Got a small fire at work rn 😅
🔥 1
r
So you just remove the invariant then. Same structure works I think.
d
In any case, even if the invariant held, it's not an obvious improvement. Before deciding how to simplify the life of callers, one would first need to have these callers and look at them. Compare:
Copy code
fun findRate(time: LocalTime): UnitRate {
  var result = initialRate
  for ((rate, transitionTime) in transitions) {
    if (time >= transitionTime) result = rate
  }
  return result
}
and
Copy code
fun findRate(time: LocalTime): UnitRate {
  var result: UnitRate? = null
  for ((rate, transitionTime) in transitions) {
    if (time >= transitionTime) result = rate
  }
  return result!! // we know `transitions` is non-empty and starts with 00:00
}
I think the first option is clearer. Don't you?
👍 1
r
Indeed
d
Hey, also, without the invariant, it seems like the first option works as is, with no changes! Nice.
r
Does it?
initialRate
assumes a start time of midnight, which -- if the rates are defined the way the business will define them -- isn't always true. It seems like the first rate needs a start time too.
d
initialRate
is the rate at midnight on that day, by definition. In
transitions
, you'd have
05:30
and
23:30
, no explicit
00:00
. In this framework, the
initialRate
is just what the day before this one ended with.
r
Yeah I'm aware but the point is this data structure is one step removed from how the business will define the rates, which is like this: https://kotlinlang.slack.com/archives/C01923PC6A0/p1696600031664689?thread_ts=1696370008.366449&cid=C01923PC6A0. That's not necessarily a bad thing -- the business definition could be stored in one way, and then translated into the runtime structure used for logic. But the extra translation is something to think about.
👍 1
s
I apologize for rising from the ashes, but I think there’s a little more elegant solution with only one modification. You may treat the list of transitions like glued and continuous circular ring. As a result: • you don’t need to explicitly set initial rate, • or to require midnight at the first transition, • and you get the structure at the same form as the business defines it. Like that:
Copy code
data class RatesForTheDay(
    val transitions: List<Pair<LocalTime, Int>>
) {
    init {
        require(transitions.isNotEmpty())
    }

    fun findRate(time: LocalTime): Int {
        val lastTransition = transitions.last()
        if (transitions.size == 1)
            return lastTransition.second
        for ((transitionTime, rate) in transitions.asReversed()) {
            if (transitionTime <= time)
                return rate
        }
        return lastTransition.second
    }

    // or one-line implementation
    fun findRate2(time: LocalTime): Int =
        (transitions.lastOrNull { it.first <= time } ?: transitions.last()).second
}
Examples:
Copy code
fun main() {
    val a = RatesForTheDay(
        listOf(
            LocalTime(5, 30) to 100,
            LocalTime(23, 30) to 200,
        )
    )

    val b = RatesForTheDay(
        listOf(
            LocalTime(5, 30) to 100, // set any transition time in case of single rate
        )
    )

    println("a[5,30] : " + a.findRate(LocalTime(5, 30))) // 100
    println("a[20,30]: " + a.findRate(LocalTime(20, 30))) // 100
    println("a[23,30]: " + a.findRate(LocalTime(23, 30))) // 200
    println("a[23,59]: " + a.findRate(LocalTime(23, 59, 59, 999_999_999))) // 200
    println("a[0,0]  : " + a.findRate(LocalTime(0, 0))) // 200 (from the last)

    println()
    println("b[5,30] : " + b.findRate(LocalTime(5, 30))) // 100
    println("b[20,30]: " + b.findRate(LocalTime(20, 30))) // 100
    println("b[23,30]: " + b.findRate(LocalTime(23, 30))) // 100
    println("b[23,59]: " + b.findRate(LocalTime(23, 59, 59, 999_999_999))) // 100
    println("b[0,0]  : " + b.findRate(LocalTime(0, 0))) // 100
}