diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt new file mode 100644 index 0000000..ce651e4 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt @@ -0,0 +1,69 @@ +package space.kscience.controls.misc + +import io.ktor.utils.io.core.Input +import io.ktor.utils.io.core.Output +import kotlinx.datetime.Instant +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.transformations.MetaConverter +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +/** + * A value coupled to a time it was obtained at + */ +public data class ValueWithTime(val value: T, val time: Instant) { + public companion object { + /** + * Create a [ValueWithTime] format for given value value [IOFormat] + */ + public fun ioFormat( + valueFormat: IOFormat, + ): IOFormat> = ValueWithTimeIOFormat(valueFormat) + + /** + * Create a [MetaConverter] with time for given value [MetaConverter] + */ + public fun metaConverter( + valueConverter: MetaConverter, + ): MetaConverter> = ValueWithTimeMetaConverter(valueConverter) + + + public const val META_TIME_KEY: String = "time" + public const val META_VALUE_KEY: String = "value" + } +} + +private class ValueWithTimeIOFormat(val valueFormat: IOFormat) : IOFormat> { + override val type: KType get() = typeOf>() + + override fun readObject(input: Input): ValueWithTime { + val timestamp = InstantIOFormat.readObject(input) + val value = valueFormat.readObject(input) + return ValueWithTime(value, timestamp) + } + + override fun writeObject(output: Output, obj: ValueWithTime) { + InstantIOFormat.writeObject(output, obj.time) + valueFormat.writeObject(output, obj.value) + } + +} + +private class ValueWithTimeMetaConverter( + val valueConverter: MetaConverter, +) : MetaConverter> { + override fun metaToObject( + meta: Meta, + ): ValueWithTime? = valueConverter.metaToObject(meta[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let { + ValueWithTime(it, meta[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST) + } + + override fun objectToMeta(obj: ValueWithTime): Meta = Meta { + ValueWithTime.META_TIME_KEY put obj.time.toMeta() + ValueWithTime.META_VALUE_KEY put valueConverter.objectToMeta(obj.value) + } +} + +public fun MetaConverter.withTime(): MetaConverter> = ValueWithTimeMetaConverter(this) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt new file mode 100644 index 0000000..aef7401 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt @@ -0,0 +1,40 @@ +package space.kscience.controls.misc + +import io.ktor.utils.io.core.* +import kotlinx.datetime.Instant +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.io.IOFormatFactory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.string +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import kotlin.reflect.KType +import kotlin.reflect.typeOf + + +/** + * An [IOFormat] for [Instant] + */ +public object InstantIOFormat : IOFormat, IOFormatFactory { + override fun build(context: Context, meta: Meta): IOFormat = this + + override val name: Name = "instant".asName() + + override val type: KType get() = typeOf() + + override fun writeObject(output: Output, obj: Instant) { + output.writeLong(obj.epochSeconds) + output.writeInt(obj.nanosecondsOfSecond) + } + + override fun readObject(input: Input): Instant { + val seconds = input.readLong() + val nanoseconds = input.readInt() + return Instant.fromEpochSeconds(seconds, nanoseconds) + } +} + +public fun Instant.toMeta(): Meta = Meta(toString()) + +public val Meta.instant: Instant? get() = value?.string?.let { Instant.parse(it) } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt deleted file mode 100644 index 11683d9..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt +++ /dev/null @@ -1,18 +0,0 @@ -package space.kscience.controls.misc - -import kotlinx.datetime.Instant -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.long - -// TODO move to core - -public fun Instant.toMeta(): Meta = Meta { - "seconds" put epochSeconds - "nanos" put nanosecondsOfSecond -} - -public fun Meta.instant(): Instant = value?.long?.let { Instant.fromEpochMilliseconds(it) } ?: Instant.fromEpochSeconds( - get("seconds")?.long ?: 0L, - get("nanos")?.long ?: 0L, -) \ No newline at end of file diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt index 574343e..0442e31 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt @@ -147,7 +147,7 @@ internal class MetaStructureCodec( "Float" -> member.value?.numberOrNull?.toFloat() "Double" -> member.value?.numberOrNull?.toDouble() "String" -> member.string - "DateTime" -> DateTime(member.instant().toJavaInstant()) + "DateTime" -> DateTime(member.instant.toJavaInstant()) "Guid" -> member.string?.let { UUID.fromString(it) } "ByteString" -> member.value?.list?.let { list -> ByteString(list.map { it.number.toByte() }.toByteArray()) diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt index 14899d8..ce85483 100644 --- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt +++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt @@ -4,18 +4,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import space.kscience.controls.api.Device import space.kscience.controls.api.propertyMessageFlow import space.kscience.controls.constructor.DeviceState import space.kscience.controls.manager.clock +import space.kscience.controls.misc.ValueWithTime import space.kscience.dataforge.context.Context import space.kscience.dataforge.meta.* import space.kscience.plotly.Plot import space.kscience.plotly.bar import space.kscience.plotly.models.Bar import space.kscience.plotly.models.Scatter +import space.kscience.plotly.models.Trace import space.kscience.plotly.models.TraceValues import space.kscience.plotly.scatter +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours private var TraceValues.values: List get() = value?.list ?: emptyList() @@ -23,48 +32,126 @@ private var TraceValues.values: List value = ListValue(newValues) } + +private var TraceValues.times: List + get() = value?.list?.map { Instant.parse(it.string) } ?: emptyList() + set(newValues) { + value = ListValue(newValues.map { it.toString().asValue() }) + } + + +private class TimeData(private var points: MutableList> = mutableListOf()) { + private val mutex = Mutex() + + suspend fun append(time: Instant, value: Value) = mutex.withLock { + points.add(ValueWithTime(value, time)) + } + + suspend fun trim(maxAge: Duration, maxPoints: Int = 800, minPoints: Int = 400) { + require(maxPoints > 2) + require(minPoints > 0) + require(maxPoints > minPoints) + val now = Clock.System.now() + // filter old points + points.removeAll { now - it.time > maxAge } + + if (points.size > maxPoints) { + val durationBetweenPoints = maxAge / minPoints + val markedForRemoval = buildList> { + var lastTime: Instant? = null + points.forEach { point -> + if (lastTime?.let { point.time - it < durationBetweenPoints } == true) { + add(point) + } else { + lastTime = point.time + } + } + } + points.removeAll(markedForRemoval) + } + } + + suspend fun fillPlot(x: TraceValues, y: TraceValues) = mutex.withLock { + x.strings = points.map { it.time.toString() } + y.values = points.map { it.value } + } +} + /** - * Add a trace that shows a [Device] property change over time. Show only latest [pointsNumber] . + * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] . * @return a [Job] that handles the listener */ public fun Plot.plotDeviceProperty( device: Device, propertyName: String, extractValue: Meta.() -> Value = { value ?: Null }, - pointsNumber: Int = 400, + maxAge: Duration = 1.hours, + maxPoints: Int = 800, + minPoints: Int = 400, coroutineScope: CoroutineScope = device.context, configuration: Scatter.() -> Unit = {}, ): Job = scatter(configuration).run { val clock = device.context.clock - device.propertyMessageFlow(propertyName).onEach { message -> - x.strings = (x.strings + (message.time ?: clock.now()).toString()).takeLast(pointsNumber) - y.values = (y.values + message.value.extractValue()).takeLast(pointsNumber) + val data = TimeData() + device.propertyMessageFlow(propertyName).transform { + data.append(it.time ?: clock.now(), it.value.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + emit(data) + }.onEach { + it.fillPlot(x, y) }.launchIn(coroutineScope) } +private fun Trace.updateFromState( + context: Context, + state: DeviceState, + extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: space.kscience.dataforge.meta.Null }, + maxAge: Duration = 1.hours, + maxPoints: Int = 800, + minPoints: Int = 400, +): Job{ + val clock = context.clock + val data = TimeData() + return state.valueFlow.transform { + data.append(clock.now(), it.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + }.onEach { + it.fillPlot(x, y) + }.launchIn(context) +} + +public fun Plot.plotDeviceState( + context: Context, + state: DeviceState, + extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: Null }, + maxAge: Duration = 1.hours, + maxPoints: Int = 800, + minPoints: Int = 400, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints) +} + public fun Plot.plotNumberState( context: Context, state: DeviceState, - pointsNumber: Int = 400, + maxAge: Duration = 1.hours, + maxPoints: Int = 800, + minPoints: Int = 400, configuration: Scatter.() -> Unit = {}, ): Job = scatter(configuration).run { - val clock = context.clock - state.valueFlow.onEach { - x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber) - y.numbers = (y.numbers + it).takeLast(pointsNumber) - }.launchIn(context) + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints) } + public fun Plot.plotBooleanState( context: Context, state: DeviceState, - pointsNumber: Int = 400, + maxAge: Duration = 1.hours, + maxPoints: Int = 800, + minPoints: Int = 400, configuration: Bar.() -> Unit = {}, -): Job = bar(configuration).run { - val clock = context.clock - state.valueFlow.onEach { - x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber) - y.values = (y.values + it.asValue()).takeLast(pointsNumber) - }.launchIn(context) +): Job = bar(configuration).run { + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints) } \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt index 7a88b7f..079499d 100644 --- a/demo/constructor/src/jvmMain/kotlin/main.kt +++ b/demo/constructor/src/jvmMain/kotlin/main.kt @@ -51,32 +51,33 @@ public fun main() { val t = timeFromStart.toDouble(DurationUnit.SECONDS) val freq = 0.1 val target = 5 * sin(2.0 * PI * freq * t) + - sin(2 * PI * 21 * freq * t + 0.1 * (timeFromStart / pidParameters.timeStep)) + sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep)) pid.write(Regulator.target, target) } } + val maxAge = 10.seconds context.showDashboard { plot { - plotNumberState(context, state) { + plotNumberState(context, state, maxAge = maxAge) { name = "real position" } - plotDeviceProperty(device["pid"], Regulator.position.name) { + plotDeviceProperty(device["pid"], Regulator.position.name, maxAge = maxAge) { name = "read position" } - plotDeviceProperty(device["pid"], Regulator.target.name) { + plotDeviceProperty(device["pid"], Regulator.target.name, maxAge = maxAge) { name = "target" } } plot { - plotDeviceProperty(device["start"], LimitSwitch.locked.name) { + plotDeviceProperty(device["start"], LimitSwitch.locked.name, maxAge = maxAge) { name = "start measured" mode = ScatterMode.markers } - plotDeviceProperty(device["end"], LimitSwitch.locked.name) { + plotDeviceProperty(device["end"], LimitSwitch.locked.name, maxAge = maxAge) { name = "end measured" mode = ScatterMode.markers }