diff --git a/build.gradle.kts b/build.gradle.kts index 50a61d8..773272c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,9 @@ plugins { allprojects { group = "space.kscience" version = "0.4.0-dev-4" + repositories{ + google() + } } ksciencePublish { diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts index e69c4d4..008a242 100644 --- a/controls-constructor/build.gradle.kts +++ b/controls-constructor/build.gradle.kts @@ -11,6 +11,7 @@ kscience{ jvm() js() useCoroutines() + useSerialization() commonMain { api(projects.controlsCore) } diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt similarity index 63% rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt index 30a0299..911a682 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt @@ -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( +public class PropertyConstructorElement( public val device: Device, public val propertyName: String, public val state: DeviceState, -) : StateDescriptor +) : ConstructorElement /** * A binding for independent state like a timer */ -public class StateNodeDescriptor( +public class StateConstructorElement( public val state: DeviceState, -) : StateDescriptor +) : ConstructorElement -public class StateConnectionDescriptor( +public class ConnectionConstrucorElement( public val reads: Collection>, public val writes: Collection>, -) : StateDescriptor +) : ConstructorElement + +public class ModelConstructorElement( + public val model: ConstructorModel +) : ConstructorElement public interface StateContainer : ContextAware, CoroutineScope { - public val stateDescriptors: Set - public fun registerState(stateDescriptor: StateDescriptor) - public fun unregisterState(stateDescriptor: StateDescriptor) + public val constructorElements: Set + 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 DeviceState.onNext( - vararg writes: DeviceState<*>, - alsoReads: Collection> = emptySet(), + writes: Collection> = emptySet(), + reads: Collection> = 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 DeviceState.onChange( - vararg writes: DeviceState<*>, - alsoReads: Collection> = emptySet(), + writes: Collection> = emptySet(), + reads: Collection> = 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 > StateContainer.state(state: D): D { - registerState(StateNodeDescriptor(state)) + registerElement(StateConstructorElement(state)) return state } /** * Create a register a [MutableDeviceState] with a given [converter] */ -public fun StateContainer.mutableState(initialValue: T): MutableDeviceState = state( +public fun StateContainer.stateOf(initialValue: T): MutableDeviceState = state( MutableDeviceState(initialValue) ) -public fun StateContainer.model(model: T): T { - model.stateDescriptors.forEach { - registerState(it) - } +public fun 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 StateContainer.mapState( - state: DeviceState, + origin: DeviceState, transformation: (T) -> R, -): DeviceStateWithDependencies = state(DeviceState.map(state, transformation)) +): DeviceStateWithDependencies = state(DeviceState.map(origin, transformation)) + + +public fun StateContainer.flowState( + origin: DeviceState, + initialValue: R, + transformation: suspend FlowCollector.(T) -> Unit +): DeviceStateWithDependencies { + 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 StateContainer.combineState( * On resulting [Job] cancel the binding is unregistered */ public fun StateContainer.bindTo(sourceState: DeviceState, targetState: MutableDeviceState): 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 StateContainer.transformTo( targetState: MutableDeviceState, 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 StateContainer.combineTo( targetState: MutableDeviceState, 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 StateContainer.combineTo( targetState: MutableDeviceState, noinline transformation: suspend (Array) -> 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) } } } \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt new file mode 100644 index 0000000..35a1e31 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt @@ -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 = mutableSetOf().apply { + dependencies.forEach { + add(StateConstructorElement(it)) + } + } + + override val constructorElements: Set get() = _constructorElements + + override fun registerElement(constructorElement: ConstructorElement) { + _constructorElements.add(constructorElement) + } + + override fun unregisterElement(constructorElement: ConstructorElement) { + _constructorElements.remove(constructorElement) + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt index 17294f1..8854247 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt @@ -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 = mutableSetOf() - override val stateDescriptors: Set get() = _stateDescriptors + private val _constructorElements: MutableSet = mutableSetOf() + override val constructorElements: Set 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 registerProperty( @@ -39,7 +38,7 @@ public abstract class DeviceConstructor( state: DeviceState, ) { super.registerProperty(converter, descriptor, state) - registerState(StatePropertyDescriptor(this, descriptor.name, state)) + registerElement(PropertyConstructorElement(this, descriptor.name, state)) } } @@ -108,7 +107,7 @@ public fun DeviceConstructor.property( ) /** - * Register a mutable external state as a property + * Create and register a mutable external state as a property */ public fun DeviceConstructor.mutableProperty( metaConverter: MetaConverter, @@ -141,22 +140,7 @@ public fun DeviceConstructor.virtualProperty( nameOverride, ) -/** - * Bind existing property provided by specification to this device - */ -public fun DeviceConstructor.deviceProperty( - device: D, - property: DevicePropertySpec, - initialValue: T, -): PropertyDelegateProvider>> = - property(property.converter, device.propertyAsState(property, initialValue)) - -/** - * Bind existing property provided by specification to this device - */ -public fun DeviceConstructor.deviceProperty( - device: D, - property: MutableDevicePropertySpec, - initialValue: T, -): PropertyDelegateProvider>> = - property(property.converter, device.mutablePropertyAsState(property, initialValue)) +public fun > DeviceConstructor.property( + spec: DevicePropertySpec<*, T>, + state: S, +): Unit = registerProperty(spec.converter, spec.descriptor, state) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt deleted file mode 100644 index 484b471..0000000 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt +++ /dev/null @@ -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 = mutableSetOf().apply { - dependencies.forEach { - add(StateNodeDescriptor(it)) - } - } - - override val stateDescriptors: Set get() = _stateDescriptors - - override fun registerState(stateDescriptor: StateDescriptor) { - _stateDescriptors.add(stateDescriptor) - } - - override fun unregisterState(stateDescriptor: StateDescriptor) { - _stateDescriptors.remove(stateDescriptor) - } -} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt index e74c4c0..db388d3 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt @@ -48,6 +48,12 @@ public interface DeviceStateWithDependencies : DeviceState { public val dependencies: Collection> } +public fun DeviceState.withDependencies( + dependencies: Collection> +): DeviceStateWithDependencies = object : DeviceStateWithDependencies, DeviceState by this { + override val dependencies: Collection> = dependencies +} + /** * Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper]. */ diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt index f5c4480..1c128fa 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt @@ -92,11 +92,11 @@ public suspend fun Device.mutablePropertyAsState( return mutablePropertyAsState(propertyName, metaConverter, initialValue) } -public suspend fun D.mutablePropertyAsState( +public suspend fun D.propertyAsState( propertySpec: MutableDevicePropertySpec, ): MutableDeviceState = mutablePropertyAsState(propertySpec.name, propertySpec.converter) -public fun D.mutablePropertyAsState( +public fun D.propertyAsState( propertySpec: MutableDevicePropertySpec, initialValue: T, ): MutableDeviceState = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt index aabacfd..96ea4fe 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt @@ -53,5 +53,5 @@ public fun StateContainer.doubleInRangeState( initialValue: Double, range: ClosedFloatingPointRange, ): DoubleInRangeState = DoubleInRangeState(initialValue, range).also { - registerState(StateNodeDescriptor(it)) + registerElement(StateConstructorElement(it)) } \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt new file mode 100644 index 0000000..45cd4db --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt @@ -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( + context: Context, + input: DeviceState, + initialValue: R, + transform: suspend FlowCollector.(T) -> Unit, +) : DeviceConstructor(context) { + public val output: DeviceStateWithDependencies = flowState(input, initialValue, transform) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt index 5678b34..6a07db0 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt @@ -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 = mutablePropertyAsState(Drive.force) +public suspend fun Drive.stateOfForce(): MutableDeviceState = propertyAsState(Drive.force) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt index af6436f..876d2dc 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt @@ -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() { - public val locked: DevicePropertySpec by booleanProperty { locked } - public operator fun invoke(lockedState: DeviceState): Factory = Factory { context, _ -> - VirtualLimitSwitch(context, lockedState) - } + public val locked: DevicePropertySpec by booleanProperty { isLocked() } } } @@ -32,14 +28,10 @@ public interface LimitSwitch : Device { */ public class VirtualLimitSwitch( context: Context, - public val lockedState: DeviceState, -) : DeviceBySpec(LimitSwitch, context), LimitSwitch { + locked: DeviceState, +) : DeviceConstructor(context), LimitSwitch { - override suspend fun onStart() { - lockedState.valueFlow.onEach { - propertyChanged(LimitSwitch.locked, it) - }.launchIn(this) - } + public val locked: DeviceState by property(MetaConverter.boolean, locked) - override val locked: Boolean get() = lockedState.value + override fun isLocked(): Boolean = locked.value } \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt index afb2cbd..9edf91e 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt @@ -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, 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( diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt index adf319b..eb9a333 100644 --- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt @@ -17,11 +17,11 @@ public interface Regulator : Device { /** * Current position value */ - public val position: Double + public suspend fun getPosition(): Double public companion object : DeviceSpec() { public val target: MutableDevicePropertySpec by mutableProperty(MetaConverter.double, Regulator::target) - public val position: DevicePropertySpec by doubleProperty { position } + public val position: DevicePropertySpec by doubleProperty { getPosition() } } } \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt new file mode 100644 index 0000000..c97784d --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt @@ -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(public val value: Double) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt new file mode 100644 index 0000000..c29c1be --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt @@ -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 \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt index 20ba47e..a245d29 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt @@ -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 { diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt index 07d831f..503df83 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt @@ -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 { deviceAction } } - } /** @@ -196,3 +192,16 @@ public fun DeviceSpec.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}" } + } +} diff --git a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt index 764dcad..b08066b 100644 --- a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt +++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt @@ -19,42 +19,37 @@ import kotlin.test.assertEquals class MagixLoopTest { @Test - fun deviceHub() = 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 + fun realDeviceHub() = runTest { 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") delay(60) assertEquals(10, remoteHub.devices.size) + server.stop() } } } \ No newline at end of file diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt index 08a84b0..41369dc 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt @@ -34,7 +34,7 @@ public suspend inline fun OpcUaDevice.readOpcWithTime( converter: MetaConverter, magAge: Double = 500.0 ): Pair { - 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 diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt similarity index 100% rename from controls-vision/src/commonMain/kotlin/plotExtensions.kt rename to controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts new file mode 100644 index 0000000..48ae9cd --- /dev/null +++ b/controls-visualisation-compose/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt new file mode 100644 index 0000000..2c82802 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt @@ -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, +) : AxisModel { + + override fun computeTickValues(axisLength: Dp): TickValues { + val currentRange = rangeProvider() + val rangeLength = currentRange.endInclusive - currentRange.start + val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt() + val numMinorTicks = numTicks * 2 + return object : TickValues { + override val majorTickValues: List = List(numTicks) { + currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength + } + + override val minorTickValues: List = 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 + } + } +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt new file mode 100644 index 0000000..5b793d6 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt @@ -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 DeviceState.asComposeState( + coroutineContext: CoroutineContext = EmptyCoroutineContext, +): State = valueFlow.collectAsState(value, coroutineContext) + + +/** + * Represent this Compose [State] as [DeviceState] + */ +public fun State.asDeviceState(): DeviceState = object : DeviceState { + override val value: T get() = this@asDeviceState.value + + override val valueFlow: Flow get() = snapshotFlow { this@asDeviceState.value } + + override fun toString(): String = "ComposeState(value=$value)" +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt new file mode 100644 index 0000000..ada0f10 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt @@ -0,0 +1,2 @@ +package space.kscience.controls.compose + diff --git a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt new file mode 100644 index 0000000..d721f0a --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt @@ -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 Flow>.collectAndTrim( + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + clock: Clock = Clock.System, +): Flow>> { + require(maxPoints > 2) + require(minPoints > 0) + require(maxPoints > minPoints) + val points = mutableListOf>() + 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 XYGraphScope.PlotTimeSeries( + data: List>, + 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.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>>(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.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.PlotNumberState( + context: Context, + state: DeviceState, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + lineStyle: LineStyle = defaultLineStyle, +): Unit { + var points by remember { mutableStateOf>>(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.averageTime(): Instant { + val min = min() + val max = max() + val duration = max - min + return min + duration / 2 +} + +private fun Flow.chunkedByPeriod(duration: Duration): Flow> { + val collector: ArrayDeque = ArrayDeque() + 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.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>>(emptyList()) } + + LaunchedEffect(device, propertyName, startValue, maxAge, maxPoints, minPoints, averagingInterval) { + val clock = device.clock + var lastValue = startValue + device.propertyMessageFlow(propertyName) + .chunkedByPeriod(averagingInterval) + .transform, ValueWithTime> { 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) +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt new file mode 100644 index 0000000..4adf952 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt @@ -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, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 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, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 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, + ) +} \ No newline at end of file diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts index 8628dd1..6b6f461 100644 --- a/demo/constructor/build.gradle.kts +++ b/demo/constructor/build.gradle.kts @@ -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) } } } diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt index bb15c49..9f84cb7 100644 --- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt +++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt @@ -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 = mutableState(x0) -// val y: MutableDeviceState = 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, val end: DeviceState, -) : 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 = 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 = 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, val position: MutableDeviceState, val velocity: MutableDeviceState = 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 = combineState(leftSpring.endForce, rightSpring.endForce) { left, rignt -> - left + rignt + val force: DeviceState = 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 DeviceState.collect( - coroutineContext: CoroutineContext = EmptyCoroutineContext, -): State = 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( diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt index 95df7ad..f64ef72 100644 --- a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt +++ b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt @@ -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,163 +79,205 @@ fun LinearDrive( meta = meta ) +class Modulator( + context: Context, + target: MutableDeviceState, + 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 { - plugin(DeviceManager) - plugin(PlotlyPlugin) - plugin(ClockManager) + val context = remember { + Context { + plugin(DeviceManager) + 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 clock = remember { context.clock } + + + var pidParameters by remember { + mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds)) } - val pidParameters = remember { - MutablePidParameters( - kp = 2.5, - ki = 0.0, - kd = -0.1, - timeStep = 0.005.seconds + val state = remember { DoubleInRangeState(0.0, -6.0..6.0) } + + val linearDrive = remember { + context.install( + "linearDrive", + LinearDrive(context, state, 0.05, pidParameters) ) } - val state = DoubleInRangeState(0.0, -6.0..6.0) - - val linearDrive = 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 modulator = remember { + context.install( + "modulator", + Modulator(context, linearDrive.target) + ) } - - 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 - } - } - + //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 { - Row { - Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) - TextField( - String.format("%.2f", pidParameters.kp), - { pidParameters.kp = it.toDouble() }, - Modifier.width(100.dp), - enabled = false - ) - Slider( - pidParameters.kp.toFloat(), - { pidParameters.kp = it.toDouble() }, - valueRange = 0f..20f, - steps = 100 - ) - } - Row { - Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) - TextField( - String.format("%.2f", pidParameters.ki), - { pidParameters.ki = it.toDouble() }, - Modifier.width(100.dp), - enabled = false - ) - - Slider( - pidParameters.ki.toFloat(), - { pidParameters.ki = it.toDouble() }, - valueRange = -10f..10f, - steps = 100 - ) - } - Row { - Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) - TextField( - String.format("%.2f", pidParameters.kd), - { pidParameters.kd = it.toDouble() }, - Modifier.width(100.dp), - enabled = false - ) - - Slider( - pidParameters.kd.toFloat(), - { pidParameters.kd = it.toDouble() }, - valueRange = -10f..10f, - steps = 100 - ) - } - - Row { - Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) - TextField( - pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), - { pidParameters.timeStep = it.toDouble().milliseconds }, - Modifier.width(100.dp), - enabled = false - ) - - Slider( - pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(), - { pidParameters.timeStep = it.toDouble().milliseconds }, - valueRange = 0f..100f, - steps = 100 - ) - } - Row { - Button({ - pidParameters.run { - kp = 2.5 - ki = 0.0 - kd = -0.1 - timeStep = 0.005.seconds + 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 = pidParameters.copy(kp = it.toDouble()) }, + Modifier.width(100.dp), + enabled = false + ) + Slider( + pidParameters.kp.toFloat(), + { 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( + String.format("%.2f", pidParameters.ki), + { pidParameters = pidParameters.copy(ki = it.toDouble()) }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.ki.toFloat(), + { 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( + String.format("%.2f", pidParameters.kd), + { pidParameters = pidParameters.copy(kd = it.toDouble()) }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.kd.toFloat(), + { 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( + pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), + { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(), + { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, + valueRange = 0f..100f, + steps = 100 + ) + } + Row { + Button({ + pidParameters = PidParameters( + kp = 2.5, + ki = 0.0, + kd = -0.1, + timeStep = 0.005.seconds + ) + }) { + Text("Reset") + } + } + } + } + second(400.dp) { + ChartLayout { + XYGraph( + 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") } } } diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt new file mode 100644 index 0000000..bd0d366 --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt @@ -0,0 +1,2 @@ +package space.kscience.controls.demo.constructor + diff --git a/settings.gradle.kts b/settings.gradle.kts index 39adc72..366c39e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include( ":controls-storage", ":controls-storage:controls-xodus", ":controls-constructor", + ":controls-visualisation-compose", ":controls-vision", ":controls-jupyter", ":magix",