Compare commits

...

2 Commits

36 changed files with 948 additions and 423 deletions

View File

@ -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

View File

@ -8,6 +8,9 @@ plugins {
allprojects {
group = "space.kscience"
version = "0.4.0-dev-4"
repositories{
google()
}
}
ksciencePublish {

View File

@ -11,6 +11,7 @@ kscience{
jvm()
js()
useCoroutines()
useSerialization()
commonMain {
api(projects.controlsCore)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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].
*/

View File

@ -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)

View File

@ -53,5 +53,5 @@ public fun StateContainer.doubleInRangeState(
initialValue: Double,
range: ClosedFloatingPointRange<Double>,
): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
registerState(StateNodeDescriptor(it))
registerElement(StateConstructorElement(it))
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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(

View File

@ -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() }
}
}

View File

@ -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)

View File

@ -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

View File

@ -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> {

View File

@ -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)}]"
}
/**

View File

@ -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,

View File

@ -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
}

View File

@ -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}" }
}
}

View File

@ -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()
}
}
}

View File

@ -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

View 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
}

View File

@ -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
}
}
}

View File

@ -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)"
}

View File

@ -0,0 +1,2 @@
package space.kscience.controls.compose

View File

@ -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)
}

View File

@ -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,
)
}

View File

@ -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)
}
}
}

View File

@ -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(

View File

@ -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)
}
}
})
}
}
}
}
}
}
}

View File

@ -0,0 +1,2 @@
package space.kscience.controls.demo.constructor

View File

@ -64,6 +64,7 @@ include(
":controls-storage",
":controls-storage:controls-xodus",
":controls-constructor",
":controls-visualisation-compose",
":controls-vision",
":controls-jupyter",
":magix",