Compare commits
2 Commits
05757aefdc
...
9edde7bdbd
Author | SHA1 | Date | |
---|---|---|---|
9edde7bdbd | |||
f72d7aa3fa |
@ -13,6 +13,7 @@
|
||||
- Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort`
|
||||
- `DeviceClient` now initializes property and action descriptors eagerly.
|
||||
- `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices.
|
||||
- Add some utility methods to ports. Synchronous port response could be now consumed as `Source`.
|
||||
|
||||
### Deprecated
|
||||
|
||||
|
@ -8,6 +8,9 @@ plugins {
|
||||
allprojects {
|
||||
group = "space.kscience"
|
||||
version = "0.4.0-dev-4"
|
||||
repositories{
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
ksciencePublish {
|
||||
|
@ -11,6 +11,7 @@ kscience{
|
||||
jvm()
|
||||
js()
|
||||
useCoroutines()
|
||||
useSerialization()
|
||||
commonMain {
|
||||
api(projects.controlsCore)
|
||||
}
|
||||
|
@ -2,9 +2,7 @@ package space.kscience.controls.constructor
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.*
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.manager.ClockManager
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
@ -14,34 +12,38 @@ import kotlin.time.Duration
|
||||
/**
|
||||
* A binding that is used to describe device functionality
|
||||
*/
|
||||
public sealed interface StateDescriptor
|
||||
public sealed interface ConstructorElement
|
||||
|
||||
/**
|
||||
* A binding that exposes device property as read-only state
|
||||
*/
|
||||
public class StatePropertyDescriptor<T>(
|
||||
public class PropertyConstructorElement<T>(
|
||||
public val device: Device,
|
||||
public val propertyName: String,
|
||||
public val state: DeviceState<T>,
|
||||
) : StateDescriptor
|
||||
) : ConstructorElement
|
||||
|
||||
/**
|
||||
* A binding for independent state like a timer
|
||||
*/
|
||||
public class StateNodeDescriptor<T>(
|
||||
public class StateConstructorElement<T>(
|
||||
public val state: DeviceState<T>,
|
||||
) : StateDescriptor
|
||||
) : ConstructorElement
|
||||
|
||||
public class StateConnectionDescriptor(
|
||||
public class ConnectionConstrucorElement(
|
||||
public val reads: Collection<DeviceState<*>>,
|
||||
public val writes: Collection<DeviceState<*>>,
|
||||
) : StateDescriptor
|
||||
) : ConstructorElement
|
||||
|
||||
public class ModelConstructorElement(
|
||||
public val model: ConstructorModel
|
||||
) : ConstructorElement
|
||||
|
||||
|
||||
public interface StateContainer : ContextAware, CoroutineScope {
|
||||
public val stateDescriptors: Set<StateDescriptor>
|
||||
public fun registerState(stateDescriptor: StateDescriptor)
|
||||
public fun unregisterState(stateDescriptor: StateDescriptor)
|
||||
public val constructorElements: Set<ConstructorElement>
|
||||
public fun registerElement(constructorElement: ConstructorElement)
|
||||
public fun unregisterElement(constructorElement: ConstructorElement)
|
||||
|
||||
|
||||
/**
|
||||
@ -50,16 +52,16 @@ public interface StateContainer : ContextAware, CoroutineScope {
|
||||
* Optionally provide [writes] - a set of states that this change affects.
|
||||
*/
|
||||
public fun <T> DeviceState<T>.onNext(
|
||||
vararg writes: DeviceState<*>,
|
||||
alsoReads: Collection<DeviceState<*>> = emptySet(),
|
||||
writes: Collection<DeviceState<*>> = emptySet(),
|
||||
reads: Collection<DeviceState<*>> = emptySet(),
|
||||
onChange: suspend (T) -> Unit,
|
||||
): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also {
|
||||
registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
|
||||
registerElement(ConnectionConstrucorElement(reads + this, writes))
|
||||
}
|
||||
|
||||
public fun <T> DeviceState<T>.onChange(
|
||||
vararg writes: DeviceState<*>,
|
||||
alsoReads: Collection<DeviceState<*>> = emptySet(),
|
||||
writes: Collection<DeviceState<*>> = emptySet(),
|
||||
reads: Collection<DeviceState<*>> = emptySet(),
|
||||
onChange: suspend (prev: T, next: T) -> Unit,
|
||||
): Job = valueFlow.runningFold(Pair(value, value)) { pair, next ->
|
||||
Pair(pair.second, next)
|
||||
@ -68,7 +70,7 @@ public interface StateContainer : ContextAware, CoroutineScope {
|
||||
onChange(pair.first, pair.second)
|
||||
}
|
||||
}.launchIn(this@StateContainer).also {
|
||||
registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
|
||||
registerElement(ConnectionConstrucorElement(reads + this, writes))
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,21 +78,19 @@ public interface StateContainer : ContextAware, CoroutineScope {
|
||||
* Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor]
|
||||
*/
|
||||
public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
|
||||
registerState(StateNodeDescriptor(state))
|
||||
registerElement(StateConstructorElement(state))
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a register a [MutableDeviceState] with a given [converter]
|
||||
*/
|
||||
public fun <T> StateContainer.mutableState(initialValue: T): MutableDeviceState<T> = state(
|
||||
public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = state(
|
||||
MutableDeviceState(initialValue)
|
||||
)
|
||||
|
||||
public fun <T : DeviceModel> StateContainer.model(model: T): T {
|
||||
model.stateDescriptors.forEach {
|
||||
registerState(it)
|
||||
}
|
||||
public fun <T : ConstructorModel> StateContainer.model(model: T): T {
|
||||
registerElement(ModelConstructorElement(model))
|
||||
return model
|
||||
}
|
||||
|
||||
@ -101,9 +101,20 @@ public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(c
|
||||
|
||||
|
||||
public fun <T, R> StateContainer.mapState(
|
||||
state: DeviceState<T>,
|
||||
origin: DeviceState<T>,
|
||||
transformation: (T) -> R,
|
||||
): DeviceStateWithDependencies<R> = state(DeviceState.map(state, transformation))
|
||||
): DeviceStateWithDependencies<R> = state(DeviceState.map(origin, transformation))
|
||||
|
||||
|
||||
public fun <T, R> StateContainer.flowState(
|
||||
origin: DeviceState<T>,
|
||||
initialValue: R,
|
||||
transformation: suspend FlowCollector<R>.(T) -> Unit
|
||||
): DeviceStateWithDependencies<R> {
|
||||
val state = MutableDeviceState(initialValue)
|
||||
origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this)
|
||||
return state(state.withDependencies(setOf(origin)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new state by combining two existing ones
|
||||
@ -122,13 +133,13 @@ public fun <T1, T2, R> StateContainer.combineState(
|
||||
* On resulting [Job] cancel the binding is unregistered
|
||||
*/
|
||||
public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
|
||||
val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
|
||||
registerState(descriptor)
|
||||
val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return sourceState.valueFlow.onEach {
|
||||
targetState.value = it
|
||||
}.launchIn(this).apply {
|
||||
invokeOnCompletion {
|
||||
unregisterState(descriptor)
|
||||
unregisterElement(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -144,19 +155,19 @@ public fun <T, R> StateContainer.transformTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
transformation: suspend (T) -> R,
|
||||
): Job {
|
||||
val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
|
||||
registerState(descriptor)
|
||||
val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return sourceState.valueFlow.onEach {
|
||||
targetState.value = transformation(it)
|
||||
}.launchIn(this).apply {
|
||||
invokeOnCompletion {
|
||||
unregisterState(descriptor)
|
||||
unregisterElement(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register [StateDescriptor] that combines values from [sourceState1] and [sourceState2] using [transformation].
|
||||
* Register [ConstructorElement] that combines values from [sourceState1] and [sourceState2] using [transformation].
|
||||
*
|
||||
* On resulting [Job] cancel the binding is unregistered
|
||||
*/
|
||||
@ -166,19 +177,19 @@ public fun <T1, T2, R> StateContainer.combineTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
transformation: suspend (T1, T2) -> R,
|
||||
): Job {
|
||||
val descriptor = StateConnectionDescriptor(setOf(sourceState1, sourceState2), setOf(targetState))
|
||||
registerState(descriptor)
|
||||
val descriptor = ConnectionConstrucorElement(setOf(sourceState1, sourceState2), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach {
|
||||
targetState.value = it
|
||||
}.launchIn(this).apply {
|
||||
invokeOnCompletion {
|
||||
unregisterState(descriptor)
|
||||
unregisterElement(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register [StateDescriptor] that combines values from [sourceStates] using [transformation].
|
||||
* Register [ConstructorElement] that combines values from [sourceStates] using [transformation].
|
||||
*
|
||||
* On resulting [Job] cancel the binding is unregistered
|
||||
*/
|
||||
@ -187,13 +198,13 @@ public inline fun <reified T, R> StateContainer.combineTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
noinline transformation: suspend (Array<T>) -> R,
|
||||
): Job {
|
||||
val descriptor = StateConnectionDescriptor(sourceStates, setOf(targetState))
|
||||
registerState(descriptor)
|
||||
val descriptor = ConnectionConstrucorElement(sourceStates, setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
|
||||
targetState.value = it
|
||||
}.launchIn(this).apply {
|
||||
invokeOnCompletion {
|
||||
unregisterState(descriptor)
|
||||
unregisterElement(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package space.kscience.controls.constructor
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.newCoroutineContext
|
||||
import space.kscience.dataforge.context.Context
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
public abstract class ConstructorModel(
|
||||
final override val context: Context,
|
||||
vararg dependencies: DeviceState<*>,
|
||||
) : StateContainer, CoroutineScope {
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob())
|
||||
|
||||
|
||||
private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf<ConstructorElement>().apply {
|
||||
dependencies.forEach {
|
||||
add(StateConstructorElement(it))
|
||||
}
|
||||
}
|
||||
|
||||
override val constructorElements: Set<ConstructorElement> get() = _constructorElements
|
||||
|
||||
override fun registerElement(constructorElement: ConstructorElement) {
|
||||
_constructorElements.add(constructorElement)
|
||||
}
|
||||
|
||||
override fun unregisterElement(constructorElement: ConstructorElement) {
|
||||
_constructorElements.remove(constructorElement)
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ package space.kscience.controls.constructor
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.spec.DevicePropertySpec
|
||||
import space.kscience.controls.spec.MutableDevicePropertySpec
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Factory
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
@ -22,15 +21,15 @@ public abstract class DeviceConstructor(
|
||||
context: Context,
|
||||
meta: Meta = Meta.EMPTY,
|
||||
) : DeviceGroup(context, meta), StateContainer {
|
||||
private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf()
|
||||
override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
|
||||
private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf()
|
||||
override val constructorElements: Set<ConstructorElement> get() = _constructorElements
|
||||
|
||||
override fun registerState(stateDescriptor: StateDescriptor) {
|
||||
_stateDescriptors.add(stateDescriptor)
|
||||
override fun registerElement(constructorElement: ConstructorElement) {
|
||||
_constructorElements.add(constructorElement)
|
||||
}
|
||||
|
||||
override fun unregisterState(stateDescriptor: StateDescriptor) {
|
||||
_stateDescriptors.remove(stateDescriptor)
|
||||
override fun unregisterElement(constructorElement: ConstructorElement) {
|
||||
_constructorElements.remove(constructorElement)
|
||||
}
|
||||
|
||||
override fun <T> registerProperty(
|
||||
@ -39,7 +38,7 @@ public abstract class DeviceConstructor(
|
||||
state: DeviceState<T>,
|
||||
) {
|
||||
super.registerProperty(converter, descriptor, state)
|
||||
registerState(StatePropertyDescriptor(this, descriptor.name, state))
|
||||
registerElement(PropertyConstructorElement(this, descriptor.name, state))
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +107,7 @@ public fun <T : Any> DeviceConstructor.property(
|
||||
)
|
||||
|
||||
/**
|
||||
* Register a mutable external state as a property
|
||||
* Create and register a mutable external state as a property
|
||||
*/
|
||||
public fun <T : Any> DeviceConstructor.mutableProperty(
|
||||
metaConverter: MetaConverter<T>,
|
||||
@ -141,22 +140,7 @@ public fun <T> DeviceConstructor.virtualProperty(
|
||||
nameOverride,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bind existing property provided by specification to this device
|
||||
*/
|
||||
public fun <T, D : Device> DeviceConstructor.deviceProperty(
|
||||
device: D,
|
||||
property: DevicePropertySpec<D, T>,
|
||||
initialValue: T,
|
||||
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> =
|
||||
property(property.converter, device.propertyAsState(property, initialValue))
|
||||
|
||||
/**
|
||||
* Bind existing property provided by specification to this device
|
||||
*/
|
||||
public fun <T, D : Device> DeviceConstructor.deviceProperty(
|
||||
device: D,
|
||||
property: MutableDevicePropertySpec<D, T>,
|
||||
initialValue: T,
|
||||
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> =
|
||||
property(property.converter, device.mutablePropertyAsState(property, initialValue))
|
||||
public fun <T, S : DeviceState<T>> DeviceConstructor.property(
|
||||
spec: DevicePropertySpec<*, T>,
|
||||
state: S,
|
||||
): Unit = registerProperty(spec.converter, spec.descriptor, state)
|
||||
|
@ -1,32 +0,0 @@
|
||||
package space.kscience.controls.constructor
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.newCoroutineContext
|
||||
import space.kscience.dataforge.context.Context
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
public abstract class DeviceModel(
|
||||
final override val context: Context,
|
||||
vararg dependencies: DeviceState<*>,
|
||||
) : StateContainer, CoroutineScope {
|
||||
|
||||
override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob())
|
||||
|
||||
|
||||
private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf<StateDescriptor>().apply {
|
||||
dependencies.forEach {
|
||||
add(StateNodeDescriptor(it))
|
||||
}
|
||||
}
|
||||
|
||||
override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
|
||||
|
||||
override fun registerState(stateDescriptor: StateDescriptor) {
|
||||
_stateDescriptors.add(stateDescriptor)
|
||||
}
|
||||
|
||||
override fun unregisterState(stateDescriptor: StateDescriptor) {
|
||||
_stateDescriptors.remove(stateDescriptor)
|
||||
}
|
||||
}
|
@ -48,6 +48,12 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
|
||||
public val dependencies: Collection<DeviceState<*>>
|
||||
}
|
||||
|
||||
public fun <T> DeviceState<T>.withDependencies(
|
||||
dependencies: Collection<DeviceState<*>>
|
||||
): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this {
|
||||
override val dependencies: Collection<DeviceState<*>> = dependencies
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper].
|
||||
*/
|
||||
|
@ -92,11 +92,11 @@ public suspend fun <T> Device.mutablePropertyAsState(
|
||||
return mutablePropertyAsState(propertyName, metaConverter, initialValue)
|
||||
}
|
||||
|
||||
public suspend fun <D : Device, T> D.mutablePropertyAsState(
|
||||
public suspend fun <D : Device, T> D.propertyAsState(
|
||||
propertySpec: MutableDevicePropertySpec<D, T>,
|
||||
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
|
||||
|
||||
public fun <D : Device, T> D.mutablePropertyAsState(
|
||||
public fun <D : Device, T> D.propertyAsState(
|
||||
propertySpec: MutableDevicePropertySpec<D, T>,
|
||||
initialValue: T,
|
||||
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
|
||||
|
@ -53,5 +53,5 @@ public fun StateContainer.doubleInRangeState(
|
||||
initialValue: Double,
|
||||
range: ClosedFloatingPointRange<Double>,
|
||||
): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
|
||||
registerState(StateNodeDescriptor(it))
|
||||
registerElement(StateConstructorElement(it))
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package space.kscience.controls.constructor.library
|
||||
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import space.kscience.controls.constructor.DeviceConstructor
|
||||
import space.kscience.controls.constructor.DeviceState
|
||||
import space.kscience.controls.constructor.DeviceStateWithDependencies
|
||||
import space.kscience.controls.constructor.flowState
|
||||
import space.kscience.dataforge.context.Context
|
||||
|
||||
/**
|
||||
* A device that converts one type of physical quantity to another type
|
||||
*/
|
||||
public class Converter<T, R>(
|
||||
context: Context,
|
||||
input: DeviceState<T>,
|
||||
initialValue: R,
|
||||
transform: suspend FlowCollector<R>.(T) -> Unit,
|
||||
) : DeviceConstructor(context) {
|
||||
public val output: DeviceStateWithDependencies<R> = flowState(input, initialValue, transform)
|
||||
}
|
@ -6,7 +6,7 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.constructor.MutableDeviceState
|
||||
import space.kscience.controls.constructor.mutablePropertyAsState
|
||||
import space.kscience.controls.constructor.propertyAsState
|
||||
import space.kscience.controls.manager.clock
|
||||
import space.kscience.controls.spec.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
@ -98,4 +98,4 @@ public class VirtualDrive(
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = mutablePropertyAsState(Drive.force)
|
||||
public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = propertyAsState(Drive.force)
|
||||
|
@ -1,15 +1,14 @@
|
||||
package space.kscience.controls.constructor.library
|
||||
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.constructor.DeviceConstructor
|
||||
import space.kscience.controls.constructor.DeviceState
|
||||
import space.kscience.controls.spec.DeviceBySpec
|
||||
import space.kscience.controls.constructor.property
|
||||
import space.kscience.controls.spec.DevicePropertySpec
|
||||
import space.kscience.controls.spec.DeviceSpec
|
||||
import space.kscience.controls.spec.booleanProperty
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Factory
|
||||
import space.kscience.dataforge.meta.MetaConverter
|
||||
|
||||
|
||||
/**
|
||||
@ -17,13 +16,10 @@ import space.kscience.dataforge.context.Factory
|
||||
*/
|
||||
public interface LimitSwitch : Device {
|
||||
|
||||
public val locked: Boolean
|
||||
public fun isLocked(): Boolean
|
||||
|
||||
public companion object : DeviceSpec<LimitSwitch>() {
|
||||
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
|
||||
public operator fun invoke(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
|
||||
VirtualLimitSwitch(context, lockedState)
|
||||
}
|
||||
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { isLocked() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,14 +28,10 @@ public interface LimitSwitch : Device {
|
||||
*/
|
||||
public class VirtualLimitSwitch(
|
||||
context: Context,
|
||||
public val lockedState: DeviceState<Boolean>,
|
||||
) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
|
||||
locked: DeviceState<Boolean>,
|
||||
) : DeviceConstructor(context), LimitSwitch {
|
||||
|
||||
override suspend fun onStart() {
|
||||
lockedState.valueFlow.onEach {
|
||||
propertyChanged(LimitSwitch.locked, it)
|
||||
}.launchIn(this)
|
||||
}
|
||||
public val locked: DeviceState<Boolean> by property(MetaConverter.boolean, locked)
|
||||
|
||||
override val locked: Boolean get() = lockedState.value
|
||||
override fun isLocked(): Boolean = locked.value
|
||||
}
|
@ -16,32 +16,22 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
|
||||
/**
|
||||
* Pid regulator parameters
|
||||
*/
|
||||
public interface PidParameters {
|
||||
public val kp: Double
|
||||
public val ki: Double
|
||||
public val kd: Double
|
||||
public val timeStep: Duration
|
||||
}
|
||||
|
||||
private data class PidParametersImpl(
|
||||
override val kp: Double,
|
||||
override val ki: Double,
|
||||
override val kd: Double,
|
||||
override val timeStep: Duration,
|
||||
) : PidParameters
|
||||
|
||||
public fun PidParameters(kp: Double, ki: Double, kd: Double, timeStep: Duration = 1.milliseconds): PidParameters =
|
||||
PidParametersImpl(kp, ki, kd, timeStep)
|
||||
|
||||
public data class PidParameters(
|
||||
val kp: Double,
|
||||
val ki: Double,
|
||||
val kd: Double,
|
||||
val timeStep: Duration = 1.milliseconds,
|
||||
)
|
||||
/**
|
||||
* A drive with PID regulator
|
||||
*/
|
||||
public class PidRegulator(
|
||||
public val drive: Drive,
|
||||
public val pidParameters: PidParameters,
|
||||
public var pidParameters: PidParameters, // TODO expose as property
|
||||
) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
|
||||
|
||||
private val clock = drive.context.clock
|
||||
@ -65,7 +55,7 @@ public class PidRegulator(
|
||||
delay(pidParameters.timeStep)
|
||||
mutex.withLock {
|
||||
val realTime = clock.now()
|
||||
val delta = target - position
|
||||
val delta = target - getPosition()
|
||||
val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
|
||||
integral += delta * dtSeconds
|
||||
val derivative = (drive.position - lastPosition) / dtSeconds
|
||||
@ -87,7 +77,7 @@ public class PidRegulator(
|
||||
drive.stop()
|
||||
}
|
||||
|
||||
override val position: Double get() = drive.position
|
||||
override suspend fun getPosition(): Double = drive.position
|
||||
}
|
||||
|
||||
public fun DeviceGroup.pid(
|
||||
|
@ -17,11 +17,11 @@ public interface Regulator : Device {
|
||||
/**
|
||||
* Current position value
|
||||
*/
|
||||
public val position: Double
|
||||
public suspend fun getPosition(): Double
|
||||
|
||||
public companion object : DeviceSpec<Regulator>() {
|
||||
public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
|
||||
|
||||
public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
|
||||
public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { getPosition() }
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package space.kscience.controls.constructor.units
|
||||
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
|
||||
/**
|
||||
* A value without identity coupled to units of measurements.
|
||||
*/
|
||||
@JvmInline
|
||||
public value class NumericalValue<T: UnitsOfMeasurement>(public val value: Double)
|
@ -0,0 +1,30 @@
|
||||
package space.kscience.controls.constructor.units
|
||||
|
||||
|
||||
public interface UnitsOfMeasurement
|
||||
|
||||
/**/
|
||||
|
||||
public interface UnitsOfLength : UnitsOfMeasurement
|
||||
|
||||
public data object Meters: UnitsOfLength
|
||||
|
||||
/**/
|
||||
|
||||
public interface UnitsOfTime: UnitsOfMeasurement
|
||||
|
||||
public data object Seconds: UnitsOfTime
|
||||
|
||||
/**/
|
||||
|
||||
public interface UnitsOfVelocity: UnitsOfMeasurement
|
||||
|
||||
public data object MetersPerSecond: UnitsOfVelocity
|
||||
|
||||
/**/
|
||||
|
||||
public interface UnitsAngularOfVelocity: UnitsOfMeasurement
|
||||
|
||||
public data object RadiansPerSecond: UnitsAngularOfVelocity
|
||||
|
||||
public data object DegreesPerSecond: UnitsAngularOfVelocity
|
@ -12,6 +12,7 @@ import kotlin.math.roundToLong
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private class CompressedTimeDispatcher(
|
||||
val clockManager: ClockManager,
|
||||
val dispatcher: CoroutineDispatcher,
|
||||
val compression: Double,
|
||||
) : CoroutineDispatcher(), Delay {
|
||||
@ -74,7 +75,7 @@ public class ClockManager : AbstractPlugin() {
|
||||
): CoroutineDispatcher = if (timeCompression == 1.0) {
|
||||
dispatcher
|
||||
} else {
|
||||
CompressedTimeDispatcher(dispatcher, timeCompression)
|
||||
CompressedTimeDispatcher(this, dispatcher, timeCompression)
|
||||
}
|
||||
|
||||
public companion object : PluginFactory<ClockManager> {
|
||||
|
@ -3,10 +3,7 @@ package space.kscience.controls.ports
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.io.Buffer
|
||||
import kotlinx.io.Source
|
||||
import space.kscience.controls.api.AsynchronousSocket
|
||||
import space.kscience.dataforge.context.*
|
||||
@ -26,15 +23,7 @@ public interface AsynchronousPort : ContextAware, AsynchronousSocket<ByteArray>
|
||||
* [scope] controls the consummation.
|
||||
* If the scope is canceled, the source stops producing.
|
||||
*/
|
||||
public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source {
|
||||
val buffer = Buffer()
|
||||
|
||||
subscribe().onEach {
|
||||
buffer.write(it)
|
||||
}.launchIn(scope)
|
||||
|
||||
return buffer
|
||||
}
|
||||
public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source = subscribe().consumeAsSource(scope)
|
||||
|
||||
|
||||
/**
|
||||
@ -51,13 +40,13 @@ public abstract class AbstractAsynchronousPort(
|
||||
CoroutineScope(
|
||||
coroutineContext +
|
||||
SupervisorJob(coroutineContext[Job]) +
|
||||
CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { throwable.stackTraceToString() } } +
|
||||
CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { "Asynchronous port error: " + throwable.stackTraceToString() } } +
|
||||
CoroutineName(toString())
|
||||
)
|
||||
}
|
||||
|
||||
private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int?:100)
|
||||
private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int?:100)
|
||||
private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int ?: 100)
|
||||
private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int ?: 100)
|
||||
|
||||
/**
|
||||
* Internal method to synchronously send data
|
||||
@ -100,7 +89,7 @@ public abstract class AbstractAsynchronousPort(
|
||||
* Send a data packet via the port
|
||||
*/
|
||||
override suspend fun send(data: ByteArray) {
|
||||
check(isOpen){"The port is not opened"}
|
||||
check(isOpen) { "The port is not opened" }
|
||||
outgoing.send(data)
|
||||
}
|
||||
|
||||
@ -117,7 +106,7 @@ public abstract class AbstractAsynchronousPort(
|
||||
sendJob?.cancel()
|
||||
}
|
||||
|
||||
override fun toString(): String = meta["name"].string?:"ChannelPort[${hashCode().toString(16)}]"
|
||||
override fun toString(): String = meta["name"].string ?: "ChannelPort[${hashCode().toString(16)}]"
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,11 +1,11 @@
|
||||
package space.kscience.controls.ports
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.io.Buffer
|
||||
import kotlinx.io.Source
|
||||
import kotlinx.io.readByteArray
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
@ -46,6 +46,24 @@ public interface SynchronousPort : ContextAware, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response to a given message using [Source] abstraction
|
||||
*/
|
||||
public suspend fun <R> SynchronousPort.respondAsSource(
|
||||
request: ByteArray,
|
||||
transform: suspend Source.() -> R,
|
||||
): R = respond(request) {
|
||||
//suspend until the response is fully read
|
||||
coroutineScope {
|
||||
val buffer = Buffer()
|
||||
val collectJob = onEach { buffer.write(it) }.launchIn(this)
|
||||
val res = transform(buffer)
|
||||
//cancel collection when the result is achieved
|
||||
collectJob.cancel()
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
private class SynchronousOverAsynchronousPort(
|
||||
val port: AsynchronousPort,
|
||||
val mutex: Mutex,
|
||||
|
@ -1,5 +1,24 @@
|
||||
package space.kscience.controls.ports
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.io.Buffer
|
||||
import kotlinx.io.Source
|
||||
import space.kscience.dataforge.io.Binary
|
||||
|
||||
public fun Binary.readShort(position: Int): Short = read(position) { readShort() }
|
||||
|
||||
/**
|
||||
* Consume given flow of [ByteArray] as [Source]. The subscription is canceled when [scope] is closed.
|
||||
*/
|
||||
public fun Flow<ByteArray>.consumeAsSource(scope: CoroutineScope): Source {
|
||||
val buffer = Buffer()
|
||||
//subscription is canceled when the scope is canceled
|
||||
onEach {
|
||||
buffer.write(it)
|
||||
}.launchIn(scope)
|
||||
|
||||
return buffer
|
||||
}
|
@ -1,10 +1,7 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.metaDescriptor
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.MetaConverter
|
||||
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
|
||||
@ -159,7 +156,6 @@ public abstract class DeviceSpec<D : Device> {
|
||||
deviceAction
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -196,3 +192,16 @@ public fun <D : Device> DeviceSpec<D>.metaAction(
|
||||
execute(it)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Throw an exception if device does not have all properties and actions defined by this specification
|
||||
*/
|
||||
public fun DeviceSpec<*>.validate(device: Device) {
|
||||
properties.map { it.value.descriptor }.forEach { specProperty ->
|
||||
check(specProperty in device.propertyDescriptors) { "Property ${specProperty.name} not registered in ${device.id}" }
|
||||
}
|
||||
|
||||
actions.map { it.value.descriptor }.forEach { specAction ->
|
||||
check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" }
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,8 @@ import kotlin.test.assertEquals
|
||||
class MagixLoopTest {
|
||||
|
||||
@Test
|
||||
fun deviceHub() = runTest {
|
||||
fun realDeviceHub() = runTest {
|
||||
withContext(Dispatchers.Default) {
|
||||
val context = Context {
|
||||
plugin(DeviceManager)
|
||||
}
|
||||
@ -30,10 +31,6 @@ class MagixLoopTest {
|
||||
|
||||
val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
|
||||
|
||||
// deviceEndpoint.subscribe().onEach {
|
||||
// println(it)
|
||||
// }.launchIn(this)
|
||||
|
||||
deviceManager.launchMagixService(deviceEndpoint, "device")
|
||||
|
||||
launch {
|
||||
@ -48,13 +45,11 @@ class MagixLoopTest {
|
||||
val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
|
||||
|
||||
assertEquals(0, remoteHub.devices.size)
|
||||
|
||||
delay(60)
|
||||
//switch context to use actual delay
|
||||
withContext(Dispatchers.Default) {
|
||||
clientEndpoint.requestDeviceUpdate("client", "device")
|
||||
delay(60)
|
||||
assertEquals(10, remoteHub.devices.size)
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime(
|
||||
converter: MetaConverter<T>,
|
||||
magAge: Double = 500.0
|
||||
): Pair<T, DateTime> {
|
||||
val data = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await()
|
||||
val data: DataValue = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await()
|
||||
val time = data.serverTime ?: error("No server time provided")
|
||||
val meta: Meta = when (val content = data.value.value) {
|
||||
is T -> return content to time
|
||||
|
45
controls-visualisation-compose/build.gradle.kts
Normal file
45
controls-visualisation-compose/build.gradle.kts
Normal file
@ -0,0 +1,45 @@
|
||||
import org.jetbrains.compose.ExperimentalComposeLibrary
|
||||
|
||||
plugins {
|
||||
id("space.kscience.gradle.mpp")
|
||||
alias(spclibs.plugins.compose)
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
description = """
|
||||
Visualisation extension using compose-multiplatform
|
||||
""".trimIndent()
|
||||
|
||||
kscience {
|
||||
jvm()
|
||||
useKtor()
|
||||
useSerialization()
|
||||
useContextReceivers()
|
||||
commonMain {
|
||||
api(projects.controlsConstructor)
|
||||
api("io.github.koalaplot:koalaplot-core:0.6.0")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(compose.foundation)
|
||||
api(compose.material3)
|
||||
@OptIn(ExperimentalComposeLibrary::class)
|
||||
api(compose.desktop.components.splitPane)
|
||||
}
|
||||
}
|
||||
// jvmMain {
|
||||
// dependencies {
|
||||
// implementation(compose.desktop.currentOs)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
readme {
|
||||
maturity = space.kscience.gradle.Maturity.PROTOTYPE
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package space.kscience.controls.compose
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.koalaplot.core.xygraph.AxisModel
|
||||
import io.github.koalaplot.core.xygraph.TickValues
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.math.floor
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.times
|
||||
|
||||
public class TimeAxisModel(
|
||||
override val minimumMajorTickSpacing: Dp = 50.dp,
|
||||
private val rangeProvider: () -> ClosedRange<Instant>,
|
||||
) : AxisModel<Instant> {
|
||||
|
||||
override fun computeTickValues(axisLength: Dp): TickValues<Instant> {
|
||||
val currentRange = rangeProvider()
|
||||
val rangeLength = currentRange.endInclusive - currentRange.start
|
||||
val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt()
|
||||
val numMinorTicks = numTicks * 2
|
||||
return object : TickValues<Instant> {
|
||||
override val majorTickValues: List<Instant> = List(numTicks) {
|
||||
currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength
|
||||
}
|
||||
|
||||
override val minorTickValues: List<Instant> = List(numMinorTicks) {
|
||||
currentRange.start + it.toDouble() / (numMinorTicks - 1) * rangeLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun computeOffset(point: Instant): Float {
|
||||
val currentRange = rangeProvider()
|
||||
return ((point - currentRange.start) / (currentRange.endInclusive - currentRange.start)).toFloat()
|
||||
}
|
||||
|
||||
public companion object {
|
||||
public fun recent(duration: Duration, clock: Clock = Clock.System): TimeAxisModel = TimeAxisModel {
|
||||
val now = clock.now()
|
||||
(now - duration)..now
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package space.kscience.controls.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import space.kscience.controls.constructor.DeviceState
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
|
||||
/**
|
||||
* Represent this [DeviceState] as Compose multiplatform [State]
|
||||
*/
|
||||
@Composable
|
||||
public fun <T> DeviceState<T>.asComposeState(
|
||||
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
): State<T> = valueFlow.collectAsState(value, coroutineContext)
|
||||
|
||||
|
||||
/**
|
||||
* Represent this Compose [State] as [DeviceState]
|
||||
*/
|
||||
public fun <T> State<T>.asDeviceState(): DeviceState<T> = object : DeviceState<T> {
|
||||
override val value: T get() = this@asDeviceState.value
|
||||
|
||||
override val valueFlow: Flow<T> get() = snapshotFlow { this@asDeviceState.value }
|
||||
|
||||
override fun toString(): String = "ComposeState(value=$value)"
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
package space.kscience.controls.compose
|
||||
|
@ -0,0 +1,230 @@
|
||||
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.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
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.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<out 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)
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package space.kscience.controls.compose
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.SliderColors
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import space.kscience.controls.constructor.DeviceState
|
||||
import space.kscience.controls.constructor.MutableDeviceState
|
||||
|
||||
@Composable
|
||||
public fun Slider(
|
||||
deviceState: MutableDeviceState<Number>,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
) {
|
||||
androidx.compose.material3.Slider(
|
||||
value = deviceState.value.toFloat(),
|
||||
onValueChange = { deviceState.value = it },
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun SliderIndicator(
|
||||
deviceState: DeviceState<Number>,
|
||||
modifier: Modifier = Modifier,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
) {
|
||||
androidx.compose.material3.Slider(
|
||||
value = deviceState.value.toFloat(),
|
||||
onValueChange = { /*do nothing*/ },
|
||||
modifier = modifier,
|
||||
enabled = false,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import org.jetbrains.compose.ExperimentalComposeLibrary
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
|
||||
|
||||
@ -8,14 +7,13 @@ plugins {
|
||||
}
|
||||
|
||||
kscience {
|
||||
jvm {
|
||||
withJava()
|
||||
}
|
||||
jvm()
|
||||
useKtor()
|
||||
useSerialization()
|
||||
useContextReceivers()
|
||||
commonMain {
|
||||
implementation(projects.controlsVision)
|
||||
implementation(projects.controlsVisualisationCompose)
|
||||
// implementation(projects.controlsVision)
|
||||
implementation(projects.controlsConstructor)
|
||||
// implementation("io.github.koalaplot:koalaplot-core:0.6.0")
|
||||
}
|
||||
@ -30,8 +28,6 @@ kotlin {
|
||||
jvmMain {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
@OptIn(ExperimentalComposeLibrary::class)
|
||||
implementation(compose.desktop.components.splitPane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,8 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -13,10 +14,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import kotlinx.serialization.Serializable
|
||||
import space.kscience.controls.compose.asComposeState
|
||||
import space.kscience.controls.constructor.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@ -29,16 +29,11 @@ data class XY(val x: Double, val y: Double) {
|
||||
}
|
||||
}
|
||||
|
||||
val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2))
|
||||
|
||||
operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
|
||||
operator fun XY.times(c: Double): XY = XY(x * c, y * c)
|
||||
operator fun XY.div(c: Double): XY = XY(x / c, y / c)
|
||||
//
|
||||
//class XYPosition(context: Context, x0: Double, y0: Double) : DeviceModel(context) {
|
||||
// val x: MutableDeviceState<Double> = mutableState(x0)
|
||||
// val y: MutableDeviceState<Double> = mutableState(y0)
|
||||
//
|
||||
// val xy = combineState(x, y) { x, y -> XY(x, y) }
|
||||
//}
|
||||
|
||||
class Spring(
|
||||
context: Context,
|
||||
@ -46,27 +41,25 @@ class Spring(
|
||||
val l0: Double,
|
||||
val begin: DeviceState<XY>,
|
||||
val end: DeviceState<XY>,
|
||||
) : DeviceConstructor(context) {
|
||||
|
||||
val length = combineState(begin, end) { begin, end ->
|
||||
sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
|
||||
}
|
||||
|
||||
val tension: DeviceState<Double> = mapState(length) { l ->
|
||||
val delta = l - l0
|
||||
k * delta
|
||||
}
|
||||
) : ConstructorModel(context) {
|
||||
|
||||
/**
|
||||
* direction from start to end
|
||||
* vector from start to end
|
||||
*/
|
||||
val direction = combineState(begin, end) { begin, end ->
|
||||
val direction = combineState(begin, end) { begin: XY, end: XY ->
|
||||
val dx = end.x - begin.x
|
||||
val dy = end.y - begin.y
|
||||
val l = sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
|
||||
val l = sqrt(dx.pow(2) + dy.pow(2))
|
||||
XY(dx / l, dy / l)
|
||||
}
|
||||
|
||||
val tension: DeviceState<Double> = combineState(begin, end) { begin: XY, end: XY ->
|
||||
val dx = end.x - begin.x
|
||||
val dy = end.y - begin.y
|
||||
k * sqrt(dx.pow(2) + dy.pow(2))
|
||||
}
|
||||
|
||||
|
||||
val beginForce = combineState(direction, tension) { direction: XY, tension: Double ->
|
||||
direction * (tension)
|
||||
}
|
||||
@ -83,13 +76,15 @@ class MaterialPoint(
|
||||
val force: DeviceState<XY>,
|
||||
val position: MutableDeviceState<XY>,
|
||||
val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
|
||||
) : DeviceModel(context, force) {
|
||||
) : ConstructorModel(context, force, position, velocity) {
|
||||
|
||||
private val timer: TimerState = timer(2.milliseconds)
|
||||
|
||||
//TODO synchronize force change
|
||||
|
||||
private val movement = timer.onChange(
|
||||
position, velocity,
|
||||
alsoReads = setOf(force, velocity, position)
|
||||
writes = setOf(position, velocity),
|
||||
reads = setOf(force, velocity, position)
|
||||
) { prev, next ->
|
||||
val dt = (next - prev).toDouble(DurationUnit.SECONDS)
|
||||
val a = force.value / mass
|
||||
@ -105,31 +100,31 @@ class BodyOnSprings(
|
||||
k: Double,
|
||||
startPosition: XY,
|
||||
l0: Double = 1.0,
|
||||
val xLeft: Double = 0.0,
|
||||
val xRight: Double = 2.0,
|
||||
val yBottom: Double = 0.0,
|
||||
val yTop: Double = 2.0,
|
||||
val xLeft: Double = -1.0,
|
||||
val xRight: Double = 1.0,
|
||||
val yBottom: Double = -1.0,
|
||||
val yTop: Double = 1.0,
|
||||
) : DeviceConstructor(context) {
|
||||
|
||||
val width = xRight - xLeft
|
||||
val height = yTop - yBottom
|
||||
|
||||
val position = mutableState(startPosition)
|
||||
val position = stateOf(startPosition)
|
||||
|
||||
private val leftAnchor = mutableState(XY(xLeft, yTop + yBottom / 2))
|
||||
private val leftAnchor = stateOf(XY(xLeft, (yTop + yBottom) / 2))
|
||||
|
||||
val leftSpring by device(
|
||||
val leftSpring = model(
|
||||
Spring(context, k, l0, leftAnchor, position)
|
||||
)
|
||||
|
||||
private val rightAnchor = mutableState(XY(xRight, yTop + yBottom / 2))
|
||||
private val rightAnchor = stateOf(XY(xRight, (yTop + yBottom) / 2))
|
||||
|
||||
val rightSpring by device(
|
||||
val rightSpring = model(
|
||||
Spring(context, k, l0, rightAnchor, position)
|
||||
)
|
||||
|
||||
val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, rignt ->
|
||||
left + rignt
|
||||
val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, right ->
|
||||
left + right
|
||||
}
|
||||
|
||||
|
||||
@ -138,18 +133,13 @@ class BodyOnSprings(
|
||||
context = context,
|
||||
mass = mass,
|
||||
force = force,
|
||||
position = position
|
||||
position = position,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> DeviceState<T>.collect(
|
||||
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
): State<T> = valueFlow.collectAsState(value, coroutineContext)
|
||||
|
||||
fun main() = application {
|
||||
val initialState = XY(1.1, 1.1)
|
||||
val initialState = XY(0.1, 0.2)
|
||||
|
||||
Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
|
||||
MaterialTheme {
|
||||
@ -161,12 +151,20 @@ fun main() = application {
|
||||
BodyOnSprings(context, 100.0, 1000.0, initialState)
|
||||
}
|
||||
|
||||
val position: XY by model.body.position.collect()
|
||||
//TODO add ability to freeze model
|
||||
|
||||
// LaunchedEffect(Unit){
|
||||
// model.position.valueFlow.onEach {
|
||||
// model.position.value = it.copy(y = model.position.value.y.coerceIn(-1.0..1.0))
|
||||
// }.collect()
|
||||
// }
|
||||
|
||||
val position: XY by model.body.position.asComposeState()
|
||||
Box(Modifier.size(400.dp)) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
fun XY.toOffset() = Offset(
|
||||
(x / model.width * size.width).toFloat(),
|
||||
(y / model.height * size.height).toFloat()
|
||||
center.x + (x / model.width * size.width).toFloat(),
|
||||
center.y - (y / model.height * size.height).toFloat()
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
|
@ -1,38 +1,40 @@
|
||||
package space.kscience.controls.demo.constructor
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import space.kscience.controls.constructor.DeviceConstructor
|
||||
import space.kscience.controls.constructor.DoubleInRangeState
|
||||
import space.kscience.controls.constructor.device
|
||||
import space.kscience.controls.constructor.deviceProperty
|
||||
import io.github.koalaplot.core.ChartLayout
|
||||
import io.github.koalaplot.core.legend.FlowLegend
|
||||
import io.github.koalaplot.core.style.LineStyle
|
||||
import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi
|
||||
import io.github.koalaplot.core.util.toString
|
||||
import io.github.koalaplot.core.xygraph.XYGraph
|
||||
import io.github.koalaplot.core.xygraph.rememberDoubleLinearAxisModel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.datetime.Instant
|
||||
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
|
||||
import org.jetbrains.compose.splitpane.HorizontalSplitPane
|
||||
import space.kscience.controls.compose.PlotDeviceProperty
|
||||
import space.kscience.controls.compose.PlotNumberState
|
||||
import space.kscience.controls.compose.TimeAxisModel
|
||||
import space.kscience.controls.constructor.*
|
||||
import space.kscience.controls.constructor.library.*
|
||||
import space.kscience.controls.manager.ClockManager
|
||||
import space.kscience.controls.manager.DeviceManager
|
||||
import space.kscience.controls.manager.clock
|
||||
import space.kscience.controls.manager.install
|
||||
import space.kscience.controls.spec.doRecurring
|
||||
import space.kscience.controls.spec.name
|
||||
import space.kscience.controls.vision.plot
|
||||
import space.kscience.controls.vision.plotDeviceProperty
|
||||
import space.kscience.controls.vision.plotNumberState
|
||||
import space.kscience.controls.vision.showDashboard
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.plotly.models.ScatterMode
|
||||
import space.kscience.visionforge.plotly.PlotlyPlugin
|
||||
import java.awt.Dimension
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
import kotlin.time.Duration
|
||||
@ -49,15 +51,15 @@ class LinearDrive(
|
||||
meta: Meta = Meta.EMPTY,
|
||||
) : DeviceConstructor(drive.context, meta) {
|
||||
|
||||
val drive: Drive by device(drive)
|
||||
val drive by device(drive)
|
||||
val pid by device(PidRegulator(drive, pidParameters))
|
||||
|
||||
val start by device(start)
|
||||
val end by device(end)
|
||||
|
||||
val position by deviceProperty(drive, Drive.position, Double.NaN)
|
||||
val position = drive.propertyAsState(Drive.position, Double.NaN)
|
||||
|
||||
val target by deviceProperty(pid, Regulator.target, 0.0)
|
||||
val target = pid.propertyAsState(Regulator.target, 0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -77,100 +79,85 @@ fun LinearDrive(
|
||||
meta = meta
|
||||
)
|
||||
|
||||
class Modulator(
|
||||
context: Context,
|
||||
target: MutableDeviceState<Double>,
|
||||
var freq: Double = 0.1,
|
||||
var timeStep: Duration = 5.milliseconds,
|
||||
) : DeviceConstructor(context) {
|
||||
private val clockStart = clock.now()
|
||||
|
||||
val timer = timer(10.milliseconds)
|
||||
|
||||
private val modulation = timer.onNext {
|
||||
val timeFromStart = clock.now() - clockStart
|
||||
val t = timeFromStart.toDouble(DurationUnit.SECONDS)
|
||||
target.value = 5 * sin(2.0 * PI * freq * t) +
|
||||
sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val maxAge = 10.seconds
|
||||
|
||||
@OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class)
|
||||
fun main() = application {
|
||||
val context = Context {
|
||||
val context = remember {
|
||||
Context {
|
||||
plugin(DeviceManager)
|
||||
plugin(PlotlyPlugin)
|
||||
plugin(ClockManager)
|
||||
}
|
||||
|
||||
class MutablePidParameters(
|
||||
kp: Double,
|
||||
ki: Double,
|
||||
kd: Double,
|
||||
timeStep: Duration,
|
||||
) : PidParameters {
|
||||
override var kp by mutableStateOf(kp)
|
||||
override var ki by mutableStateOf(ki)
|
||||
override var kd by mutableStateOf(kd)
|
||||
override var timeStep by mutableStateOf(timeStep)
|
||||
}
|
||||
|
||||
val pidParameters = remember {
|
||||
MutablePidParameters(
|
||||
kp = 2.5,
|
||||
ki = 0.0,
|
||||
kd = -0.1,
|
||||
timeStep = 0.005.seconds
|
||||
)
|
||||
val clock = remember { context.clock }
|
||||
|
||||
|
||||
var pidParameters by remember {
|
||||
mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds))
|
||||
}
|
||||
|
||||
val state = DoubleInRangeState(0.0, -6.0..6.0)
|
||||
val state = remember { DoubleInRangeState(0.0, -6.0..6.0) }
|
||||
|
||||
val linearDrive = context.install(
|
||||
val linearDrive = remember {
|
||||
context.install(
|
||||
"linearDrive",
|
||||
LinearDrive(context, state, 0.05, pidParameters)
|
||||
)
|
||||
|
||||
val clockStart = context.clock.now()
|
||||
linearDrive.doRecurring(10.milliseconds) {
|
||||
val timeFromStart = clock.now() - clockStart
|
||||
val t = timeFromStart.toDouble(DurationUnit.SECONDS)
|
||||
val freq = 0.1
|
||||
target.value = 5 * sin(2.0 * PI * freq * t) +
|
||||
sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
|
||||
}
|
||||
|
||||
|
||||
val maxAge = 10.seconds
|
||||
|
||||
context.showDashboard {
|
||||
plot {
|
||||
plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) {
|
||||
name = "real position"
|
||||
}
|
||||
plotDeviceProperty(linearDrive.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) {
|
||||
name = "read position"
|
||||
}
|
||||
|
||||
plotDeviceProperty(linearDrive.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) {
|
||||
name = "target"
|
||||
}
|
||||
}
|
||||
|
||||
plot {
|
||||
plotDeviceProperty(
|
||||
linearDrive.start,
|
||||
LimitSwitch.locked.name,
|
||||
maxAge = maxAge,
|
||||
sampling = 50.milliseconds
|
||||
) {
|
||||
name = "start measured"
|
||||
mode = ScatterMode.markers
|
||||
}
|
||||
plotDeviceProperty(linearDrive.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
|
||||
name = "end measured"
|
||||
mode = ScatterMode.markers
|
||||
}
|
||||
val modulator = remember {
|
||||
context.install(
|
||||
"modulator",
|
||||
Modulator(context, linearDrive.target)
|
||||
)
|
||||
}
|
||||
|
||||
//bind pid parameters
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow {
|
||||
pidParameters
|
||||
}.onEach {
|
||||
linearDrive.pid.pidParameters = pidParameters
|
||||
}.collect()
|
||||
}
|
||||
|
||||
Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
|
||||
window.minimumSize = Dimension(800, 400)
|
||||
MaterialTheme {
|
||||
Column {
|
||||
HorizontalSplitPane {
|
||||
first(400.dp) {
|
||||
Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) {
|
||||
Row {
|
||||
Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
|
||||
TextField(
|
||||
String.format("%.2f", pidParameters.kp),
|
||||
{ pidParameters.kp = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(kp = it.toDouble()) },
|
||||
Modifier.width(100.dp),
|
||||
enabled = false
|
||||
)
|
||||
Slider(
|
||||
pidParameters.kp.toFloat(),
|
||||
{ pidParameters.kp = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(kp = it.toDouble()) },
|
||||
valueRange = 0f..20f,
|
||||
steps = 100
|
||||
)
|
||||
@ -179,14 +166,14 @@ fun main() = application {
|
||||
Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
|
||||
TextField(
|
||||
String.format("%.2f", pidParameters.ki),
|
||||
{ pidParameters.ki = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(ki = it.toDouble()) },
|
||||
Modifier.width(100.dp),
|
||||
enabled = false
|
||||
)
|
||||
|
||||
Slider(
|
||||
pidParameters.ki.toFloat(),
|
||||
{ pidParameters.ki = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(ki = it.toDouble()) },
|
||||
valueRange = -10f..10f,
|
||||
steps = 100
|
||||
)
|
||||
@ -195,14 +182,14 @@ fun main() = application {
|
||||
Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
|
||||
TextField(
|
||||
String.format("%.2f", pidParameters.kd),
|
||||
{ pidParameters.kd = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(kd = it.toDouble()) },
|
||||
Modifier.width(100.dp),
|
||||
enabled = false
|
||||
)
|
||||
|
||||
Slider(
|
||||
pidParameters.kd.toFloat(),
|
||||
{ pidParameters.kd = it.toDouble() },
|
||||
{ pidParameters = pidParameters.copy(kd = it.toDouble()) },
|
||||
valueRange = -10f..10f,
|
||||
steps = 100
|
||||
)
|
||||
@ -212,31 +199,88 @@ fun main() = application {
|
||||
Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
|
||||
TextField(
|
||||
pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
|
||||
{ pidParameters.timeStep = it.toDouble().milliseconds },
|
||||
{ pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
|
||||
Modifier.width(100.dp),
|
||||
enabled = false
|
||||
)
|
||||
|
||||
Slider(
|
||||
pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
|
||||
{ pidParameters.timeStep = it.toDouble().milliseconds },
|
||||
{ pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
|
||||
valueRange = 0f..100f,
|
||||
steps = 100
|
||||
)
|
||||
}
|
||||
Row {
|
||||
Button({
|
||||
pidParameters.run {
|
||||
kp = 2.5
|
||||
ki = 0.0
|
||||
kd = -0.1
|
||||
pidParameters = PidParameters(
|
||||
kp = 2.5,
|
||||
ki = 0.0,
|
||||
kd = -0.1,
|
||||
timeStep = 0.005.seconds
|
||||
}
|
||||
)
|
||||
}) {
|
||||
Text("Reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
second(400.dp) {
|
||||
ChartLayout {
|
||||
XYGraph<Instant, Double>(
|
||||
xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) },
|
||||
yAxisModel = rememberDoubleLinearAxisModel(state.range),
|
||||
xAxisTitle = { Text("Time in seconds relative to current") },
|
||||
xAxisLabels = { it: Instant ->
|
||||
androidx.compose.material3.Text(
|
||||
(clock.now() - it).toDouble(
|
||||
DurationUnit.SECONDS
|
||||
).toString(2)
|
||||
)
|
||||
},
|
||||
yAxisLabels = { it: Double -> Text(it.toString(2)) }
|
||||
) {
|
||||
PlotNumberState(
|
||||
context = context,
|
||||
state = state,
|
||||
maxAge = maxAge,
|
||||
sampling = 50.milliseconds,
|
||||
lineStyle = LineStyle(SolidColor(Color.Blue))
|
||||
)
|
||||
PlotDeviceProperty(
|
||||
linearDrive.pid,
|
||||
Regulator.position,
|
||||
maxAge = maxAge,
|
||||
sampling = 50.milliseconds,
|
||||
)
|
||||
PlotDeviceProperty(
|
||||
linearDrive.pid,
|
||||
Regulator.target,
|
||||
maxAge = maxAge,
|
||||
sampling = 50.milliseconds,
|
||||
lineStyle = LineStyle(SolidColor(Color.Red))
|
||||
)
|
||||
}
|
||||
Surface {
|
||||
FlowLegend(3, label = {
|
||||
when (it) {
|
||||
0 -> {
|
||||
Text("Body position", color = Color.Blue)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
Text("Regulator position", color = Color.Black)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
Text("Regulator target", color = Color.Red)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
2
demo/constructor/src/jvmMain/kotlin/Plotter.kt
Normal file
2
demo/constructor/src/jvmMain/kotlin/Plotter.kt
Normal file
@ -0,0 +1,2 @@
|
||||
package space.kscience.controls.demo.constructor
|
||||
|
@ -64,6 +64,7 @@ include(
|
||||
":controls-storage",
|
||||
":controls-storage:controls-xodus",
|
||||
":controls-constructor",
|
||||
":controls-visualisation-compose",
|
||||
":controls-vision",
|
||||
":controls-jupyter",
|
||||
":magix",
|
||||
|
Loading…
Reference in New Issue
Block a user