Major constructor refactoring

This commit is contained in:
Alexander Nozik 2024-05-29 22:20:22 +03:00
parent f72d7aa3fa
commit 9edde7bdbd
32 changed files with 900 additions and 402 deletions

View File

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

View File

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

View File

@ -2,9 +2,7 @@ package space.kscience.controls.constructor
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.manager.ClockManager import space.kscience.controls.manager.ClockManager
import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.ContextAware
@ -14,34 +12,38 @@ import kotlin.time.Duration
/** /**
* A binding that is used to describe device functionality * 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 * A binding that exposes device property as read-only state
*/ */
public class StatePropertyDescriptor<T>( public class PropertyConstructorElement<T>(
public val device: Device, public val device: Device,
public val propertyName: String, public val propertyName: String,
public val state: DeviceState<T>, public val state: DeviceState<T>,
) : StateDescriptor ) : ConstructorElement
/** /**
* A binding for independent state like a timer * A binding for independent state like a timer
*/ */
public class StateNodeDescriptor<T>( public class StateConstructorElement<T>(
public val state: DeviceState<T>, public val state: DeviceState<T>,
) : StateDescriptor ) : ConstructorElement
public class StateConnectionDescriptor( public class ConnectionConstrucorElement(
public val reads: Collection<DeviceState<*>>, public val reads: Collection<DeviceState<*>>,
public val writes: Collection<DeviceState<*>>, public val writes: Collection<DeviceState<*>>,
) : StateDescriptor ) : ConstructorElement
public class ModelConstructorElement(
public val model: ConstructorModel
) : ConstructorElement
public interface StateContainer : ContextAware, CoroutineScope { public interface StateContainer : ContextAware, CoroutineScope {
public val stateDescriptors: Set<StateDescriptor> public val constructorElements: Set<ConstructorElement>
public fun registerState(stateDescriptor: StateDescriptor) public fun registerElement(constructorElement: ConstructorElement)
public fun unregisterState(stateDescriptor: StateDescriptor) 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. * Optionally provide [writes] - a set of states that this change affects.
*/ */
public fun <T> DeviceState<T>.onNext( public fun <T> DeviceState<T>.onNext(
vararg writes: DeviceState<*>, writes: Collection<DeviceState<*>> = emptySet(),
alsoReads: Collection<DeviceState<*>> = emptySet(), reads: Collection<DeviceState<*>> = emptySet(),
onChange: suspend (T) -> Unit, onChange: suspend (T) -> Unit,
): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also { ): 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( public fun <T> DeviceState<T>.onChange(
vararg writes: DeviceState<*>, writes: Collection<DeviceState<*>> = emptySet(),
alsoReads: Collection<DeviceState<*>> = emptySet(), reads: Collection<DeviceState<*>> = emptySet(),
onChange: suspend (prev: T, next: T) -> Unit, onChange: suspend (prev: T, next: T) -> Unit,
): Job = valueFlow.runningFold(Pair(value, value)) { pair, next -> ): Job = valueFlow.runningFold(Pair(value, value)) { pair, next ->
Pair(pair.second, next) Pair(pair.second, next)
@ -68,7 +70,7 @@ public interface StateContainer : ContextAware, CoroutineScope {
onChange(pair.first, pair.second) onChange(pair.first, pair.second)
} }
}.launchIn(this@StateContainer).also { }.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] * 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 { public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
registerState(StateNodeDescriptor(state)) registerElement(StateConstructorElement(state))
return state return state
} }
/** /**
* Create a register a [MutableDeviceState] with a given [converter] * 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) MutableDeviceState(initialValue)
) )
public fun <T : DeviceModel> StateContainer.model(model: T): T { public fun <T : ConstructorModel> StateContainer.model(model: T): T {
model.stateDescriptors.forEach { registerElement(ModelConstructorElement(model))
registerState(it)
}
return model return model
} }
@ -101,9 +101,20 @@ public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(c
public fun <T, R> StateContainer.mapState( public fun <T, R> StateContainer.mapState(
state: DeviceState<T>, origin: DeviceState<T>,
transformation: (T) -> R, 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 * 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 * On resulting [Job] cancel the binding is unregistered
*/ */
public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job { public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState)) val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
registerState(descriptor) registerElement(descriptor)
return sourceState.valueFlow.onEach { return sourceState.valueFlow.onEach {
targetState.value = it targetState.value = it
}.launchIn(this).apply { }.launchIn(this).apply {
invokeOnCompletion { invokeOnCompletion {
unregisterState(descriptor) unregisterElement(descriptor)
} }
} }
} }
@ -144,19 +155,19 @@ public fun <T, R> StateContainer.transformTo(
targetState: MutableDeviceState<R>, targetState: MutableDeviceState<R>,
transformation: suspend (T) -> R, transformation: suspend (T) -> R,
): Job { ): Job {
val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState)) val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
registerState(descriptor) registerElement(descriptor)
return sourceState.valueFlow.onEach { return sourceState.valueFlow.onEach {
targetState.value = transformation(it) targetState.value = transformation(it)
}.launchIn(this).apply { }.launchIn(this).apply {
invokeOnCompletion { 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 * On resulting [Job] cancel the binding is unregistered
*/ */
@ -166,19 +177,19 @@ public fun <T1, T2, R> StateContainer.combineTo(
targetState: MutableDeviceState<R>, targetState: MutableDeviceState<R>,
transformation: suspend (T1, T2) -> R, transformation: suspend (T1, T2) -> R,
): Job { ): Job {
val descriptor = StateConnectionDescriptor(setOf(sourceState1, sourceState2), setOf(targetState)) val descriptor = ConnectionConstrucorElement(setOf(sourceState1, sourceState2), setOf(targetState))
registerState(descriptor) registerElement(descriptor)
return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach { return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach {
targetState.value = it targetState.value = it
}.launchIn(this).apply { }.launchIn(this).apply {
invokeOnCompletion { 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 * On resulting [Job] cancel the binding is unregistered
*/ */
@ -187,13 +198,13 @@ public inline fun <reified T, R> StateContainer.combineTo(
targetState: MutableDeviceState<R>, targetState: MutableDeviceState<R>,
noinline transformation: suspend (Array<T>) -> R, noinline transformation: suspend (Array<T>) -> R,
): Job { ): Job {
val descriptor = StateConnectionDescriptor(sourceStates, setOf(targetState)) val descriptor = ConnectionConstrucorElement(sourceStates, setOf(targetState))
registerState(descriptor) registerElement(descriptor)
return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach { return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
targetState.value = it targetState.value = it
}.launchIn(this).apply { }.launchIn(this).apply {
invokeOnCompletion { 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.Device
import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.spec.DevicePropertySpec import space.kscience.controls.spec.DevicePropertySpec
import space.kscience.controls.spec.MutableDevicePropertySpec
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.Factory import space.kscience.dataforge.context.Factory
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
@ -22,15 +21,15 @@ public abstract class DeviceConstructor(
context: Context, context: Context,
meta: Meta = Meta.EMPTY, meta: Meta = Meta.EMPTY,
) : DeviceGroup(context, meta), StateContainer { ) : DeviceGroup(context, meta), StateContainer {
private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf() private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf()
override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors override val constructorElements: Set<ConstructorElement> get() = _constructorElements
override fun registerState(stateDescriptor: StateDescriptor) { override fun registerElement(constructorElement: ConstructorElement) {
_stateDescriptors.add(stateDescriptor) _constructorElements.add(constructorElement)
} }
override fun unregisterState(stateDescriptor: StateDescriptor) { override fun unregisterElement(constructorElement: ConstructorElement) {
_stateDescriptors.remove(stateDescriptor) _constructorElements.remove(constructorElement)
} }
override fun <T> registerProperty( override fun <T> registerProperty(
@ -39,7 +38,7 @@ public abstract class DeviceConstructor(
state: DeviceState<T>, state: DeviceState<T>,
) { ) {
super.registerProperty(converter, descriptor, state) 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( public fun <T : Any> DeviceConstructor.mutableProperty(
metaConverter: MetaConverter<T>, metaConverter: MetaConverter<T>,
@ -141,22 +140,7 @@ public fun <T> DeviceConstructor.virtualProperty(
nameOverride, nameOverride,
) )
/** public fun <T, S : DeviceState<T>> DeviceConstructor.property(
* Bind existing property provided by specification to this device spec: DevicePropertySpec<*, T>,
*/ state: S,
public fun <T, D : Device> DeviceConstructor.deviceProperty( ): Unit = registerProperty(spec.converter, spec.descriptor, state)
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))

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 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]. * 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) 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>, propertySpec: MutableDevicePropertySpec<D, T>,
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter) ): 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>, propertySpec: MutableDevicePropertySpec<D, T>,
initialValue: T, initialValue: T,
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue) ): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)

View File

@ -53,5 +53,5 @@ public fun StateContainer.doubleInRangeState(
initialValue: Double, initialValue: Double,
range: ClosedFloatingPointRange<Double>, range: ClosedFloatingPointRange<Double>,
): DoubleInRangeState = DoubleInRangeState(initialValue, range).also { ): 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 kotlinx.coroutines.launch
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.constructor.MutableDeviceState 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.manager.clock
import space.kscience.controls.spec.* import space.kscience.controls.spec.*
import space.kscience.dataforge.context.Context 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 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.api.Device
import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.DeviceState 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.DevicePropertySpec
import space.kscience.controls.spec.DeviceSpec import space.kscience.controls.spec.DeviceSpec
import space.kscience.controls.spec.booleanProperty import space.kscience.controls.spec.booleanProperty
import space.kscience.dataforge.context.Context 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 interface LimitSwitch : Device {
public val locked: Boolean public fun isLocked(): Boolean
public companion object : DeviceSpec<LimitSwitch>() { public companion object : DeviceSpec<LimitSwitch>() {
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked } public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { isLocked() }
public operator fun invoke(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
VirtualLimitSwitch(context, lockedState)
}
} }
} }
@ -32,14 +28,10 @@ public interface LimitSwitch : Device {
*/ */
public class VirtualLimitSwitch( public class VirtualLimitSwitch(
context: Context, context: Context,
public val lockedState: DeviceState<Boolean>, locked: DeviceState<Boolean>,
) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch { ) : DeviceConstructor(context), LimitSwitch {
override suspend fun onStart() { public val locked: DeviceState<Boolean> by property(MetaConverter.boolean, locked)
lockedState.valueFlow.onEach {
propertyChanged(LimitSwitch.locked, it)
}.launchIn(this)
}
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.Duration.Companion.milliseconds
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
/** /**
* Pid regulator parameters * Pid regulator parameters
*/ */
public interface PidParameters { public data class PidParameters(
public val kp: Double val kp: Double,
public val ki: Double val ki: Double,
public val kd: Double val kd: Double,
public val timeStep: Duration val timeStep: Duration = 1.milliseconds,
} )
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)
/** /**
* A drive with PID regulator * A drive with PID regulator
*/ */
public class PidRegulator( public class PidRegulator(
public val drive: Drive, public val drive: Drive,
public val pidParameters: PidParameters, public var pidParameters: PidParameters, // TODO expose as property
) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator { ) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
private val clock = drive.context.clock private val clock = drive.context.clock
@ -65,7 +55,7 @@ public class PidRegulator(
delay(pidParameters.timeStep) delay(pidParameters.timeStep)
mutex.withLock { mutex.withLock {
val realTime = clock.now() val realTime = clock.now()
val delta = target - position val delta = target - getPosition()
val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS) val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
integral += delta * dtSeconds integral += delta * dtSeconds
val derivative = (drive.position - lastPosition) / dtSeconds val derivative = (drive.position - lastPosition) / dtSeconds
@ -87,7 +77,7 @@ public class PidRegulator(
drive.stop() drive.stop()
} }
override val position: Double get() = drive.position override suspend fun getPosition(): Double = drive.position
} }
public fun DeviceGroup.pid( public fun DeviceGroup.pid(

View File

@ -17,11 +17,11 @@ public interface Regulator : Device {
/** /**
* Current position value * Current position value
*/ */
public val position: Double public suspend fun getPosition(): Double
public companion object : DeviceSpec<Regulator>() { public companion object : DeviceSpec<Regulator>() {
public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target) 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) @OptIn(InternalCoroutinesApi::class)
private class CompressedTimeDispatcher( private class CompressedTimeDispatcher(
val clockManager: ClockManager,
val dispatcher: CoroutineDispatcher, val dispatcher: CoroutineDispatcher,
val compression: Double, val compression: Double,
) : CoroutineDispatcher(), Delay { ) : CoroutineDispatcher(), Delay {
@ -74,7 +75,7 @@ public class ClockManager : AbstractPlugin() {
): CoroutineDispatcher = if (timeCompression == 1.0) { ): CoroutineDispatcher = if (timeCompression == 1.0) {
dispatcher dispatcher
} else { } else {
CompressedTimeDispatcher(dispatcher, timeCompression) CompressedTimeDispatcher(this, dispatcher, timeCompression)
} }
public companion object : PluginFactory<ClockManager> { public companion object : PluginFactory<ClockManager> {

View File

@ -1,10 +1,7 @@
package space.kscience.controls.spec package space.kscience.controls.spec
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import space.kscience.controls.api.ActionDescriptor import space.kscience.controls.api.*
import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.api.metaDescriptor
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.descriptors.MetaDescriptor import space.kscience.dataforge.meta.descriptors.MetaDescriptor
@ -159,7 +156,6 @@ public abstract class DeviceSpec<D : Device> {
deviceAction deviceAction
} }
} }
} }
/** /**
@ -196,3 +192,16 @@ public fun <D : Device> DeviceSpec<D>.metaAction(
execute(it) 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,42 +19,37 @@ import kotlin.test.assertEquals
class MagixLoopTest { class MagixLoopTest {
@Test @Test
fun deviceHub() = runTest { fun realDeviceHub() = runTest {
val context = Context {
plugin(DeviceManager)
}
val server = context.startMagixServer()
val deviceManager = context.request(DeviceManager)
val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
// deviceEndpoint.subscribe().onEach {
// println(it)
// }.launchIn(this)
deviceManager.launchMagixService(deviceEndpoint, "device")
launch {
delay(50)
repeat(10) {
deviceManager.install("test[$it]", TestDevice)
}
}
val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
assertEquals(0, remoteHub.devices.size)
delay(60)
//switch context to use actual delay
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val context = Context {
plugin(DeviceManager)
}
val server = context.startMagixServer()
val deviceManager = context.request(DeviceManager)
val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
deviceManager.launchMagixService(deviceEndpoint, "device")
launch {
delay(50)
repeat(10) {
deviceManager.install("test[$it]", TestDevice)
}
}
val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
assertEquals(0, remoteHub.devices.size)
delay(60)
clientEndpoint.requestDeviceUpdate("client", "device") clientEndpoint.requestDeviceUpdate("client", "device")
delay(60) delay(60)
assertEquals(10, remoteHub.devices.size) 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>, converter: MetaConverter<T>,
magAge: Double = 500.0 magAge: Double = 500.0
): Pair<T, DateTime> { ): 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 time = data.serverTime ?: error("No server time provided")
val meta: Meta = when (val content = data.value.value) { val meta: Meta = when (val content = data.value.value) {
is T -> return content to time 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.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
@ -8,14 +7,13 @@ plugins {
} }
kscience { kscience {
jvm { jvm()
withJava()
}
useKtor() useKtor()
useSerialization() useSerialization()
useContextReceivers() useContextReceivers()
commonMain { commonMain {
implementation(projects.controlsVision) implementation(projects.controlsVisualisationCompose)
// implementation(projects.controlsVision)
implementation(projects.controlsConstructor) implementation(projects.controlsConstructor)
// implementation("io.github.koalaplot:koalaplot-core:0.6.0") // implementation("io.github.koalaplot:koalaplot-core:0.6.0")
} }
@ -30,8 +28,6 @@ kotlin {
jvmMain { jvmMain {
dependencies { dependencies {
implementation(compose.desktop.currentOs) 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.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.MaterialTheme 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.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.controls.compose.asComposeState
import space.kscience.controls.constructor.* import space.kscience.controls.constructor.*
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.time.Duration.Companion.milliseconds 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.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.times(c: Double): XY = XY(x * c, y * c)
operator fun XY.div(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( class Spring(
context: Context, context: Context,
@ -46,27 +41,25 @@ class Spring(
val l0: Double, val l0: Double,
val begin: DeviceState<XY>, val begin: DeviceState<XY>,
val end: DeviceState<XY>, val end: DeviceState<XY>,
) : DeviceConstructor(context) { ) : ConstructorModel(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
}
/** /**
* 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 dx = end.x - begin.x
val dy = end.y - begin.y 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) 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 -> val beginForce = combineState(direction, tension) { direction: XY, tension: Double ->
direction * (tension) direction * (tension)
} }
@ -83,13 +76,15 @@ class MaterialPoint(
val force: DeviceState<XY>, val force: DeviceState<XY>,
val position: MutableDeviceState<XY>, val position: MutableDeviceState<XY>,
val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO), val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
) : DeviceModel(context, force) { ) : ConstructorModel(context, force, position, velocity) {
private val timer: TimerState = timer(2.milliseconds) private val timer: TimerState = timer(2.milliseconds)
//TODO synchronize force change
private val movement = timer.onChange( private val movement = timer.onChange(
position, velocity, writes = setOf(position, velocity),
alsoReads = setOf(force, velocity, position) reads = setOf(force, velocity, position)
) { prev, next -> ) { prev, next ->
val dt = (next - prev).toDouble(DurationUnit.SECONDS) val dt = (next - prev).toDouble(DurationUnit.SECONDS)
val a = force.value / mass val a = force.value / mass
@ -105,31 +100,31 @@ class BodyOnSprings(
k: Double, k: Double,
startPosition: XY, startPosition: XY,
l0: Double = 1.0, l0: Double = 1.0,
val xLeft: Double = 0.0, val xLeft: Double = -1.0,
val xRight: Double = 2.0, val xRight: Double = 1.0,
val yBottom: Double = 0.0, val yBottom: Double = -1.0,
val yTop: Double = 2.0, val yTop: Double = 1.0,
) : DeviceConstructor(context) { ) : DeviceConstructor(context) {
val width = xRight - xLeft val width = xRight - xLeft
val height = yTop - yBottom 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) 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) Spring(context, k, l0, rightAnchor, position)
) )
val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, rignt -> val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, right ->
left + rignt left + right
} }
@ -138,18 +133,13 @@ class BodyOnSprings(
context = context, context = context,
mass = mass, mass = mass,
force = force, force = force,
position = position position = position,
) )
) )
} }
@Composable
fun <T> DeviceState<T>.collect(
coroutineContext: CoroutineContext = EmptyCoroutineContext,
): State<T> = valueFlow.collectAsState(value, coroutineContext)
fun main() = application { fun main() = application {
val initialState = XY(1.1, 1.1) val initialState = XY(0.1, 0.2)
Window(title = "Ball on springs", onCloseRequest = ::exitApplication) { Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
MaterialTheme { MaterialTheme {
@ -161,12 +151,20 @@ fun main() = application {
BodyOnSprings(context, 100.0, 1000.0, initialState) 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)) { Box(Modifier.size(400.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
fun XY.toOffset() = Offset( fun XY.toOffset() = Offset(
(x / model.width * size.width).toFloat(), center.x + (x / model.width * size.width).toFloat(),
(y / model.height * size.height).toFloat() center.y - (y / model.height * size.height).toFloat()
) )
drawCircle( drawCircle(

View File

@ -1,38 +1,40 @@
package space.kscience.controls.demo.constructor package space.kscience.controls.demo.constructor
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.getValue import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import space.kscience.controls.constructor.DeviceConstructor import io.github.koalaplot.core.ChartLayout
import space.kscience.controls.constructor.DoubleInRangeState import io.github.koalaplot.core.legend.FlowLegend
import space.kscience.controls.constructor.device import io.github.koalaplot.core.style.LineStyle
import space.kscience.controls.constructor.deviceProperty 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.constructor.library.*
import space.kscience.controls.manager.ClockManager import space.kscience.controls.manager.ClockManager
import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.manager.clock import space.kscience.controls.manager.clock
import space.kscience.controls.manager.install 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.context.Context
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.plotly.models.ScatterMode import java.awt.Dimension
import space.kscience.visionforge.plotly.PlotlyPlugin
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.sin import kotlin.math.sin
import kotlin.time.Duration import kotlin.time.Duration
@ -49,15 +51,15 @@ class LinearDrive(
meta: Meta = Meta.EMPTY, meta: Meta = Meta.EMPTY,
) : DeviceConstructor(drive.context, meta) { ) : DeviceConstructor(drive.context, meta) {
val drive: Drive by device(drive) val drive by device(drive)
val pid by device(PidRegulator(drive, pidParameters)) val pid by device(PidRegulator(drive, pidParameters))
val start by device(start) val start by device(start)
val end by device(end) 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,163 +79,205 @@ fun LinearDrive(
meta = meta 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 { fun main() = application {
val context = Context { val context = remember {
plugin(DeviceManager) Context {
plugin(PlotlyPlugin) plugin(DeviceManager)
plugin(ClockManager) plugin(ClockManager)
}
} }
class MutablePidParameters( val clock = remember { context.clock }
kp: Double,
ki: Double,
kd: Double, var pidParameters by remember {
timeStep: Duration, mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds))
) : 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 { val state = remember { DoubleInRangeState(0.0, -6.0..6.0) }
MutablePidParameters(
kp = 2.5, val linearDrive = remember {
ki = 0.0, context.install(
kd = -0.1, "linearDrive",
timeStep = 0.005.seconds LinearDrive(context, state, 0.05, pidParameters)
) )
} }
val state = DoubleInRangeState(0.0, -6.0..6.0) val modulator = remember {
context.install(
val linearDrive = context.install( "modulator",
"linearDrive", Modulator(context, linearDrive.target)
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))
} }
//bind pid parameters
val maxAge = 10.seconds LaunchedEffect(Unit) {
snapshotFlow {
context.showDashboard { pidParameters
plot { }.onEach {
plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) { linearDrive.pid.pidParameters = pidParameters
name = "real position" }.collect()
}
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
}
}
} }
Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
window.minimumSize = Dimension(800, 400)
MaterialTheme { MaterialTheme {
Column { HorizontalSplitPane {
Row { first(400.dp) {
Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) {
TextField( Row {
String.format("%.2f", pidParameters.kp), Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
{ pidParameters.kp = it.toDouble() }, TextField(
Modifier.width(100.dp), String.format("%.2f", pidParameters.kp),
enabled = false { pidParameters = pidParameters.copy(kp = it.toDouble()) },
) Modifier.width(100.dp),
Slider( enabled = false
pidParameters.kp.toFloat(), )
{ pidParameters.kp = it.toDouble() }, Slider(
valueRange = 0f..20f, pidParameters.kp.toFloat(),
steps = 100 { pidParameters = pidParameters.copy(kp = it.toDouble()) },
) valueRange = 0f..20f,
} steps = 100
Row { )
Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) }
TextField( Row {
String.format("%.2f", pidParameters.ki), Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
{ pidParameters.ki = it.toDouble() }, TextField(
Modifier.width(100.dp), String.format("%.2f", pidParameters.ki),
enabled = false { pidParameters = pidParameters.copy(ki = it.toDouble()) },
) Modifier.width(100.dp),
enabled = false
Slider( )
pidParameters.ki.toFloat(),
{ pidParameters.ki = it.toDouble() }, Slider(
valueRange = -10f..10f, pidParameters.ki.toFloat(),
steps = 100 { pidParameters = pidParameters.copy(ki = it.toDouble()) },
) valueRange = -10f..10f,
} steps = 100
Row { )
Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) }
TextField( Row {
String.format("%.2f", pidParameters.kd), Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
{ pidParameters.kd = it.toDouble() }, TextField(
Modifier.width(100.dp), String.format("%.2f", pidParameters.kd),
enabled = false { pidParameters = pidParameters.copy(kd = it.toDouble()) },
) Modifier.width(100.dp),
enabled = false
Slider( )
pidParameters.kd.toFloat(),
{ pidParameters.kd = it.toDouble() }, Slider(
valueRange = -10f..10f, pidParameters.kd.toFloat(),
steps = 100 { pidParameters = pidParameters.copy(kd = it.toDouble()) },
) valueRange = -10f..10f,
} steps = 100
)
Row { }
Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
TextField( Row {
pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
{ pidParameters.timeStep = it.toDouble().milliseconds }, TextField(
Modifier.width(100.dp), pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
enabled = false { 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 }, Slider(
valueRange = 0f..100f, pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
steps = 100 { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
) valueRange = 0f..100f,
} steps = 100
Row { )
Button({ }
pidParameters.run { Row {
kp = 2.5 Button({
ki = 0.0 pidParameters = PidParameters(
kd = -0.1 kp = 2.5,
timeStep = 0.005.seconds 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)
}
}
})
} }
}) {
Text("Reset")
} }
} }
} }

View File

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

View File

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