244 lines
8.0 KiB
Kotlin

@file:OptIn(FlowPreview::class)
package space.kscience.controls.compose
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.SolidColor
import io.github.koalaplot.core.line.LinePlot
import io.github.koalaplot.core.style.LineStyle
import io.github.koalaplot.core.xygraph.DefaultPoint
import io.github.koalaplot.core.xygraph.XYGraphScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyChangedMessage
import space.kscience.controls.api.propertyMessageFlow
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.values
import space.kscience.controls.manager.clock
import space.kscience.controls.misc.ValueWithTime
import space.kscience.controls.spec.DevicePropertySpec
import space.kscience.controls.spec.name
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.double
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
private val defaultMaxAge get() = 10.minutes
private val defaultMaxPoints get() = 800
private val defaultMinPoints get() = 400
private val defaultSampling get() = 1.seconds
internal fun <T> Flow<ValueWithTime<T>>.collectAndTrim(
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
clock: Clock = Clock.System,
): Flow<List<ValueWithTime<T>>> {
require(maxPoints > 2)
require(minPoints > 0)
require(maxPoints > minPoints)
val points = mutableListOf<ValueWithTime<T>>()
return transform { newPoint ->
points.add(newPoint)
val now = clock.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)
}
//return a protective copy
emit(ArrayList(points))
}
}
private val defaultLineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black))
@Composable
private fun <T> XYGraphScope<Instant, T>.PlotTimeSeries(
data: List<ValueWithTime<T>>,
lineStyle: LineStyle = defaultLineStyle,
) {
LinePlot(
data = data.map { DefaultPoint(it.time, it.value) },
lineStyle = lineStyle
)
}
/**
* Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] .
* @return a [Job] that handles the listener
*/
@Composable
public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
device: Device,
propertyName: String,
extractValue: Meta.() -> Double = { value?.double ?: Double.NaN },
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
sampling: Duration = defaultSampling,
lineStyle: LineStyle = defaultLineStyle,
) {
var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
LaunchedEffect(device, propertyName, maxAge, maxPoints, minPoints, sampling) {
device.propertyMessageFlow(propertyName)
.sample(sampling)
.map { ValueWithTime(it.value.extractValue(), it.time) }
.collectAndTrim(maxAge, maxPoints, minPoints, device.clock)
.onEach { points = it }
.launchIn(this)
}
PlotTimeSeries(points, lineStyle)
}
@Composable
public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
device: Device,
property: DevicePropertySpec<*, out Number>,
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
sampling: Duration = defaultSampling,
lineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black)),
): Unit = PlotDeviceProperty(
device = device,
propertyName = property.name,
extractValue = { property.converter.readOrNull(this)?.toDouble() ?: Double.NaN },
maxAge = maxAge,
maxPoints = maxPoints,
minPoints = minPoints,
sampling = sampling,
lineStyle = lineStyle
)
@Composable
public fun XYGraphScope<Instant, Double>.PlotNumberState(
context: Context,
state: DeviceState<Number>,
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
sampling: Duration = defaultSampling,
lineStyle: LineStyle = defaultLineStyle,
): Unit {
var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
LaunchedEffect(context, state, maxAge, maxPoints, minPoints, sampling) {
val clock = context.clock
state.valueFlow.sample(sampling)
.map { ValueWithTime(it.toDouble(), clock.now()) }
.collectAndTrim(maxAge, maxPoints, minPoints, clock)
.onEach { points = it }
.launchIn(this)
}
PlotTimeSeries(points, lineStyle)
}
@Composable
public fun XYGraphScope<Instant, Double>.PlotNumericState(
context: Context,
state: DeviceState<NumericalValue<*>>,
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
sampling: Duration = defaultSampling,
lineStyle: LineStyle = defaultLineStyle,
): Unit {
PlotNumberState(context, state.values(), maxAge, maxPoints, minPoints, sampling, lineStyle)
}
private fun List<Instant>.averageTime(): Instant {
val min = min()
val max = max()
val duration = max - min
return min + duration / 2
}
private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> {
val collector: ArrayDeque<T> = ArrayDeque<T>()
return channelFlow {
launch {
while (isActive) {
delay(duration)
send(ArrayList(collector))
collector.clear()
}
}
this@chunkedByPeriod.collect {
collector.add(it)
}
}
}
/**
* Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived.
*/
@Composable
public fun XYGraphScope<Instant, Double>.PlotAveragedDeviceProperty(
device: Device,
propertyName: String,
startValue: Double = 0.0,
extractValue: Meta.() -> Double = { value?.double ?: startValue },
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
averagingInterval: Duration = defaultSampling,
lineStyle: LineStyle = defaultLineStyle,
) {
var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
LaunchedEffect(device, propertyName, startValue, maxAge, maxPoints, minPoints, averagingInterval) {
val clock = device.clock
var lastValue = startValue
device.propertyMessageFlow(propertyName)
.chunkedByPeriod(averagingInterval)
.transform<List<PropertyChangedMessage>, ValueWithTime<Double>> { eventList ->
if (eventList.isEmpty()) {
ValueWithTime(lastValue, clock.now())
} else {
val time = eventList.map { it.time }.averageTime()
val value = eventList.map { extractValue(it.value) }.average()
ValueWithTime(value, time).also {
lastValue = value
}
}
}.collectAndTrim(maxAge, maxPoints, minPoints, clock)
.onEach { points = it }
.launchIn(this)
}
PlotTimeSeries(points, lineStyle)
}