kevin.cianfarini
10/03/2023, 9:53 PM24: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.
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.kevin.cianfarini
10/03/2023, 10:02 PMErik Christensen
10/03/2023, 10:58 PMkevin.cianfarini
10/03/2023, 10:58 PMLocalTime(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!kevin.cianfarini
10/03/2023, 10:58 PMkevin.cianfarini
10/03/2023, 10:59 PMErik Christensen
10/03/2023, 11:00 PMkevin.cianfarini
10/03/2023, 11:01 PMkevin.cianfarini
10/03/2023, 11:01 PMrocketraman
10/04/2023, 7:04 PMLocalTime(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
.rocketraman
10/04/2023, 7:06 PMrocketraman
10/04/2023, 7:07 PMrocketraman
10/04/2023, 7:10 PMLocalTime.EOD
val in the library so users's can just use <..LocalTime.EOD
.rocketraman
10/04/2023, 7:11 PMkevin.cianfarini
10/04/2023, 7:12 PMLocalTime(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
LocalTime(hour = 0, minute = 0) .. LocalTime(hour = 23, minute = 59, second = 59, nanosecond = 999_999_998)
kevin.cianfarini
10/04/2023, 7:12 PMkevin.cianfarini
10/04/2023, 7:14 PMrocketraman
10/05/2023, 3:46 AM24: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.kevin.cianfarini
10/05/2023, 3:55 AMDmitry Khalanskiy [JB]
10/05/2023, 8:35 AMhour = 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?rocketraman
10/05/2023, 7:18 PMkevin.cianfarini
10/05/2023, 7:20 PMOpenEndRange<Instant>
and that works wonderfully.rocketraman
10/05/2023, 7:23 PMkevin.cianfarini
10/05/2023, 7:26 PMOpenEndRange<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.rocketraman
10/05/2023, 7:27 PMkevin.cianfarini
10/05/2023, 7:28 PMkevin.cianfarini
10/05/2023, 7:29 PMkevin.cianfarini
10/05/2023, 7:31 PM"""
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!
}
Dmitry Khalanskiy [JB]
10/06/2023, 6:54 AMIf 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.
kevin.cianfarini
10/06/2023, 12:02 PMDmitry Khalanskiy [JB]
10/06/2023, 12:08 PM[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?
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.kevin.cianfarini
10/06/2023, 12:09 PMkevin.cianfarini
10/06/2023, 1:06 PMkevin.cianfarini
10/06/2023, 1:07 PMkevin.cianfarini
10/06/2023, 1:08 PM[05:30, 23:30)
• Night rate: [23:30, 05:30)
<-- technically invalid rangekevin.cianfarini
10/06/2023, 1:08 PMin
operator is sillykevin.cianfarini
10/06/2023, 1:11 PMrocketraman
10/06/2023, 1:45 PMinitialRate
, and adding an invariant to check the structure always starts at midnight:
class RatesForTheDay {
val transitions: List<Pair<LocalTime, UnitRate>>
init {
require(transitions.firstOrNull()?.first == LocalTime(0, 0) { "Daily rates must start at midnight" }
}
}
kevin.cianfarini
10/06/2023, 1:47 PM[05:30, 23:30)
• Night rate: [23:30, 05:30)
<-- technically invalid rangerocketraman
10/06/2023, 1:49 PMkevin.cianfarini
10/06/2023, 1:49 PMkevin.cianfarini
10/06/2023, 1:49 PMkevin.cianfarini
10/06/2023, 1:50 PMrocketraman
10/06/2023, 1:51 PMDmitry Khalanskiy [JB]
10/06/2023, 1:52 PMfun findRate(time: LocalTime): UnitRate {
var result = initialRate
for ((rate, transitionTime) in transitions) {
if (time >= transitionTime) result = rate
}
return result
}
and
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?rocketraman
10/06/2023, 1:53 PMDmitry Khalanskiy [JB]
10/06/2023, 1:54 PMrocketraman
10/06/2023, 1:57 PMinitialRate
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.Dmitry Khalanskiy [JB]
10/06/2023, 2:00 PMinitialRate
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.rocketraman
10/06/2023, 2:02 PMspleenjack
10/13/2023, 9:50 AMdata 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:
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
}