Skip to content

Commit 87991c6

Browse files
committed
Add time zone detection and tests.
1 parent 4355062 commit 87991c6

File tree

12 files changed

+224
-45
lines changed

12 files changed

+224
-45
lines changed

plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/intern/ToSpecConverters.kt

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ fun Figure.toSpec(): MutableMap<String, Any> {
4242
return when (this) {
4343
is Plot -> this.toSpec()
4444
is SubPlotsFigure -> this.toSpec()
45-
// is GGBunch -> this.toSpec()
4645
else -> throw IllegalArgumentException("Unsupported figure type ${this::class.simpleName}")
4746
}
4847
}
@@ -111,7 +110,6 @@ fun Layer.toSpec(): MutableMap<String, Any> {
111110
spec[Option.PlotBase.DATA] = asPlotData(data)
112111
}
113112

114-
// val allMappings = (mapping + geom.mapping + stat.mapping).map
115113
val allMappings = mapping.map
116114
spec[Option.PlotBase.MAPPING] = asMappingData(allMappings)
117115

@@ -313,7 +311,8 @@ private fun createDataMeta(data: Map<*, *>?, mappingSpec: Map<String, Any>): Map
313311
}
314312
}
315313

316-
dataTypeByVar += inferType(data)
314+
dataTypeByVar += inferDTypes(data)
315+
val timeZoneByVar = detectTimeZones(data)
317316

318317
// fill series annotations
319318
val seriesAnnotations = mutableMapOf<String, MutableMap<String, Any>>()
@@ -324,6 +323,10 @@ private fun createDataMeta(data: Map<*, *>?, mappingSpec: Map<String, Any>): Map
324323
seriesAnnotation[SeriesAnnotation.TYPE] = dataType
325324
}
326325

326+
if (varName in timeZoneByVar) {
327+
seriesAnnotation[SeriesAnnotation.TIME_ZONE] = timeZoneByVar.getValue(varName)
328+
}
329+
327330
if (varName in mappingMetaByVar) {
328331
val levels = mappingMetaByVar[varName]?.values?.mapNotNull(MappingMeta::levels)?.lastOrNull()
329332
if (levels != null) {
@@ -392,21 +395,16 @@ private fun createDataMeta(data: Map<*, *>?, mappingSpec: Map<String, Any>): Map
392395
return spatialDataMeta + dataMeta
393396
}
394397

395-
private fun inferType(data: Any?): Map<String, String> {
396-
if (data == null) {
397-
return emptyMap()
398-
}
399-
398+
private fun inferDTypes(data: Any?): Map<String, String> {
400399
return if (data is Map<*, *>) {
401-
data
402-
.entries
403-
.associate { (key, values) -> key.toString() to inferSeriesType(values) }
400+
data.entries
401+
.associate { (key, values) -> key.toString() to inferSeriesDType(values) }
404402
} else {
405403
emptyMap()
406404
}
407405
}
408406

409-
private fun inferSeriesType(data: Any?): String {
407+
private fun inferSeriesDType(data: Any?): String {
410408
if (data == null) {
411409
return SeriesAnnotation.Types.UNKNOWN
412410
}
@@ -430,26 +428,63 @@ private fun inferSeriesType(data: Any?): String {
430428
val value = l.first()
431429

432430
return if (JvmStandardizing.isJvm(value)) {
433-
JvmStandardizing.typeAnnotation(value)
431+
JvmStandardizing.getTypeAnnotation(value)
434432
} else {
435-
when (value) {
436-
is Byte -> SeriesAnnotation.Types.INTEGER
437-
is Short -> SeriesAnnotation.Types.INTEGER
438-
is Int -> SeriesAnnotation.Types.INTEGER
439-
is Long -> SeriesAnnotation.Types.INTEGER
440-
is Double -> SeriesAnnotation.Types.FLOATING
441-
is Float -> SeriesAnnotation.Types.FLOATING
442-
is String -> SeriesAnnotation.Types.STRING
443-
is Boolean -> SeriesAnnotation.Types.BOOLEAN
444-
is kotlinx.datetime.Instant -> SeriesAnnotation.Types.DATE_TIME
445-
is kotlinx.datetime.LocalDate -> SeriesAnnotation.Types.DATE
446-
is kotlinx.datetime.LocalTime -> SeriesAnnotation.Types.TIME
447-
is kotlinx.datetime.LocalDateTime -> SeriesAnnotation.Types.DATE_TIME
433+
when {
434+
value is String -> SeriesAnnotation.Types.STRING
435+
value is Boolean -> SeriesAnnotation.Types.BOOLEAN
436+
437+
// Primitive numeric types: using `::class` comparisons instead of `is` checks
438+
// due to Kotlin/JS type coercion issues
439+
value::class == Byte::class -> SeriesAnnotation.Types.INTEGER
440+
value::class == Short::class -> SeriesAnnotation.Types.INTEGER
441+
value::class == Int::class -> SeriesAnnotation.Types.INTEGER
442+
value::class == Long::class -> SeriesAnnotation.Types.INTEGER
443+
value::class == Double::class -> SeriesAnnotation.Types.FLOATING
444+
value::class == Float::class -> SeriesAnnotation.Types.FLOATING
445+
446+
value is kotlinx.datetime.Instant -> SeriesAnnotation.Types.DATE_TIME
447+
value is kotlinx.datetime.LocalDate -> SeriesAnnotation.Types.DATE
448+
value is kotlinx.datetime.LocalTime -> SeriesAnnotation.Types.TIME
449+
value is kotlinx.datetime.LocalDateTime -> SeriesAnnotation.Types.DATE_TIME
450+
448451
else -> SeriesAnnotation.Types.UNKNOWN
449452
}
450453
}
451454
}
452455

456+
private fun detectTimeZones(data: Any?): Map<String, String> {
457+
return if (data is Map<*, *>) {
458+
@Suppress("UNCHECKED_CAST")
459+
data.entries
460+
.associate { (key, values) ->
461+
key.toString() to detectSeriesTimeZoneID(values)
462+
}.filterNonNullValues() as Map<String, String>
463+
} else {
464+
emptyMap()
465+
}
466+
}
467+
468+
private fun detectSeriesTimeZoneID(data: Any?): String? {
469+
val data = if (isListy(data)) {
470+
asList(data!!).filterNotNull()
471+
} else {
472+
null
473+
}
474+
475+
if (data == null || data.isEmpty()) return null
476+
477+
val value = data.first()
478+
479+
return if (JvmStandardizing.isJvm(value)) {
480+
JvmStandardizing.getTimeZoneAnnotation(value)
481+
} else {
482+
//
483+
null
484+
}
485+
}
486+
487+
453488
private fun createGeoDataframeAnnotation(data: SpatialDataset): Map<String, Any> {
454489
require(data.geometryFormat == GeometryFormat.GEOJSON) { "Only GEOJSON geometry format is supported." }
455490
return mapOf(

plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/intern/standardizing/JvmStandardizing.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package org.jetbrains.letsPlot.intern.standardizing
77

88
expect object JvmStandardizing {
99
fun isJvm(o: Any): Boolean
10-
fun typeAnnotation(o: Any): String
10+
fun getTypeAnnotation(o: Any): String
11+
fun getTimeZoneAnnotation(o: Any): String?
1112
fun standardize(o: Any): Any
1213
}

plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/intern/standardizing/SeriesStandardizing.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
package org.jetbrains.letsPlot.intern.standardizing
77

88
internal object SeriesStandardizing {
9-
@Suppress("SpellCheckingInspection")
109
fun isListy(rawValue: Any?) = when (rawValue) {
1110
is List<*> -> true
1211
is Iterable<*> -> true
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) 2021. JetBrains s.r.o.
3+
* Use of this source code is governed by the MIT license that can be found in the LICENSE file.
4+
*/
5+
6+
package org.jetbrains.letsPlot.intern
7+
8+
import org.jetbrains.letsPlot.commons.testing.assertContentEquals
9+
import org.jetbrains.letsPlot.geom.geomPoint
10+
import org.jetbrains.letsPlot.letsPlot
11+
import kotlin.test.Test
12+
13+
import kotlinx.datetime.Instant as KInstant
14+
import kotlinx.datetime.LocalDate as KLocalDate
15+
import kotlinx.datetime.LocalDateTime as KLocalDateTime
16+
import kotlinx.datetime.LocalTime as KLocalTime
17+
18+
19+
@Suppress("ClassName")
20+
class SeriesAnnotation_TypeAndTZTest {
21+
22+
@Test
23+
fun infer_type_and_timezone() {
24+
val testData = (
25+
26+
mapOf(
27+
"Byte" to 1.toByte(),
28+
"Short" to 2.toShort(),
29+
"Int" to 2,
30+
"Long" to 4L,
31+
"Float" to 5.1f,
32+
"Double" to 6.2,
33+
"Boolean" to true,
34+
"String" to "text",
35+
36+
// Unknown type: won't be added to series annotations.
37+
"Null" to null,
38+
"Object" to State.Idle,
39+
40+
// kotlinx.datetime API
41+
"kotlinx.Instant" to KInstant.fromEpochMilliseconds(1672576245000L), // 2023-01-01T12:30:45Z
42+
"kotlinx.LocalDate" to KLocalDate(2023, 1, 1), // 2023-01-01
43+
"kotlinx.LocalTime" to KLocalTime(12, 30, 45), // 12:30:45
44+
"kotlinx.LocalDateTime" to KLocalDateTime(2023, 1, 1, 12, 30, 45), // 2023-01-01T12:30:45
45+
) + SeriesAnnotationDataTypeTestJvmValues.getTestValues()
46+
47+
).mapValues { (_, value) -> listOf(value) }
48+
49+
50+
val expectedSeriesAnnotations = listOf(
51+
52+
mapOf("column" to "Byte", "type" to "int"),
53+
mapOf("column" to "Short", "type" to "int"),
54+
mapOf("column" to "Int", "type" to "int"),
55+
mapOf("column" to "Long", "type" to "int"),
56+
mapOf("column" to "Float", "type" to "float"),
57+
mapOf("column" to "Double", "type" to "float"),
58+
mapOf("column" to "Boolean", "type" to "bool"),
59+
mapOf("column" to "String", "type" to "str"),
60+
61+
// Unknown type: won't be added to series annotations:
62+
// - Null
63+
// - Object
64+
65+
// None of kotlinx.datetime objects store time zone information.
66+
mapOf("column" to "kotlinx.Instant", "type" to "datetime"),
67+
mapOf("column" to "kotlinx.LocalDate", "type" to "date"),
68+
mapOf("column" to "kotlinx.LocalTime", "type" to "time"),
69+
mapOf("column" to "kotlinx.LocalDateTime", "type" to "datetime"),
70+
71+
) + SeriesAnnotationDataTypeTestJvmValues.getExpectedValues()
72+
73+
74+
val plot = letsPlot(testData) + geomPoint()
75+
val seriesAnnotations = (plot.toSpec()["data_meta"] as? Map<*, *>)?.get("series_annotations")
76+
assertContentEquals(expectedSeriesAnnotations, seriesAnnotations as List<*>)
77+
}
78+
}
79+
80+
@Suppress("unused")
81+
private enum class State { Idle, Working }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.jetbrains.letsPlot.intern
2+
3+
expect object SeriesAnnotationDataTypeTestJvmValues {
4+
fun getTestValues(): Map<String, Any>
5+
fun getExpectedValues(): List<Map<String, String>>
6+
}

plot-api/src/jsMain/kotlin/org/jetbrains/letsPlot/intern/standardizing/JvmStandardizing.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ actual object JvmStandardizing {
1010
return false
1111
}
1212

13-
actual fun typeAnnotation(o: Any): String {
13+
actual fun getTypeAnnotation(o: Any): String {
14+
throw IllegalStateException("Not supported in Kotlin/JS")
15+
}
16+
17+
actual fun getTimeZoneAnnotation(o: Any): String? {
1418
throw IllegalStateException("Not supported in Kotlin/JS")
1519
}
1620

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package org.jetbrains.letsPlot.intern
2+
3+
actual object SeriesAnnotationDataTypeTestJvmValues {
4+
actual fun getTestValues(): Map<String, Any> = emptyMap()
5+
6+
actual fun getExpectedValues(): List<Map<String, String>> = emptyList()
7+
}

plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/intern/standardizing/JvmStandardizing.kt

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,22 @@ private val AWT_PRESENT: Boolean = try {
1717
}
1818

1919
actual object JvmStandardizing {
20-
actual fun typeAnnotation(o: Any): String {
20+
actual fun isJvm(o: Any): Boolean {
21+
if (AWT_PRESENT && o is java.awt.Color) return true
22+
23+
return when (o) {
24+
is Date -> true
25+
is Instant -> true
26+
is ZonedDateTime -> true
27+
is OffsetDateTime -> true
28+
is LocalDate -> true
29+
is LocalTime -> true
30+
is LocalDateTime -> true
31+
else -> false
32+
}
33+
}
34+
35+
actual fun getTypeAnnotation(o: Any): String {
2136
return when (o) {
2237
is Date -> SeriesAnnotation.Types.DATE_TIME
2338
is Instant -> SeriesAnnotation.Types.DATE_TIME
@@ -30,18 +45,11 @@ actual object JvmStandardizing {
3045
}
3146
}
3247

33-
actual fun isJvm(o: Any): Boolean {
34-
if (AWT_PRESENT && o is java.awt.Color) return true
35-
48+
actual fun getTimeZoneAnnotation(o: Any): String? {
3649
return when (o) {
37-
is Date -> true
38-
is Instant -> true
39-
is ZonedDateTime -> true
40-
is OffsetDateTime -> true
41-
is LocalDate -> true
42-
is LocalTime -> true
43-
is LocalDateTime -> true
44-
else -> false
50+
is ZonedDateTime -> if (o.zone.id == "Z") "UTC" else o.zone.id
51+
is OffsetDateTime -> if (o.offset.id == "Z") "UTC" else "UTC" + o.offset.id
52+
else -> null
4553
}
4654
}
4755

plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/DataSeriesTimeStandardizeTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class DataSeriesTimeStandardizeTest(
6060
val instants = listOf<Instant?>(zonedDateTime0.toInstant(), zonedDateTime1.toInstant(), null)
6161
val zonedDateTimes = listOf<ZonedDateTime?>(zonedDateTime0, zonedDateTime1, null)
6262

63-
// Expected after standardisation
63+
// Expected after standardization
6464
val expectedList = listOf<Double?>(1591815480000.0, 1591815840000.0, null)
6565
val expectedMap = mapOf(
6666
"dateTimes" to expectedList,
@@ -69,7 +69,7 @@ class DataSeriesTimeStandardizeTest(
6969
)
7070

7171
// Test data
72-
return listOf<Array<Any>>(
72+
return listOf(
7373
arrayOf(
7474
// lists
7575
mapOf(

plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/SeriesAnnotationTest.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,12 @@ class SeriesAnnotationTest {
8686

8787
seriesAnnotation(column = "java-instant-column", type = Types.DATE_TIME),
8888
seriesAnnotation(column = "java-date-column", type = Types.DATE_TIME),
89-
seriesAnnotation(column = "java-zoned-datetime-column", type = Types.DATE_TIME),
90-
seriesAnnotation(column = "java-offset-datetime-column", type = Types.DATE_TIME),
89+
seriesAnnotation(column = "java-zoned-datetime-column", type = Types.DATE_TIME, timeZoneId = "UTC"),
90+
seriesAnnotation(
91+
column = "java-offset-datetime-column",
92+
type = Types.DATE_TIME,
93+
timeZoneId = "UTC"
94+
),
9195
seriesAnnotation(column = "java-local-date-column", type = Types.DATE),
9296
seriesAnnotation(column = "java-local-time-column", type = Types.TIME),
9397
seriesAnnotation(column = "java-local-datetime-column", type = Types.DATE_TIME),

0 commit comments

Comments
 (0)