Compare commits

...

2 Commits

32 changed files with 857 additions and 475 deletions

View File

@ -3,11 +3,13 @@ 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.* import kotlinx.coroutines.flow.*
import kotlinx.datetime.Instant
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
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/** /**
* A binding that is used to describe device functionality * A binding that is used to describe device functionality
@ -36,7 +38,7 @@ public class ConnectionConstrucorElement(
) : ConstructorElement ) : ConstructorElement
public class ModelConstructorElement( public class ModelConstructorElement(
public val model: ConstructorModel public val model: ModelConstructor,
) : ConstructorElement ) : ConstructorElement
@ -77,19 +79,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.registerState(state: D): D {
registerElement(StateConstructorElement(state)) registerElement(StateConstructorElement(state))
return state return state
} }
/** /**
* Create a register a [MutableDeviceState] with a given [converter] * Create a register a [MutableDeviceState]
*/ */
public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = state( public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = registerState(
MutableDeviceState(initialValue) MutableDeviceState(initialValue)
) )
public fun <T : ConstructorModel> StateContainer.model(model: T): T { public fun <T : ModelConstructor> StateContainer.model(model: T): T {
registerElement(ModelConstructorElement(model)) registerElement(ModelConstructorElement(model))
return model return model
} }
@ -97,23 +99,53 @@ public fun <T : ConstructorModel> StateContainer.model(model: T): T {
/** /**
* Create and register a timer state. * Create and register a timer state.
*/ */
public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(context.request(ClockManager), tick)) public fun StateContainer.timer(tick: Duration): TimerState =
registerState(TimerState(context.request(ClockManager), tick))
/**
* Register a new timer and perform [block] on its change
*/
public fun StateContainer.onTimer(
tick: Duration,
writes: Collection<DeviceState<*>> = emptySet(),
reads: Collection<DeviceState<*>> = emptySet(),
block: suspend (prev: Instant, next: Instant) -> Unit,
): Job = timer(tick).onChange(writes = writes, reads = reads, onChange = block)
public enum class DefaultTimer(public val duration: Duration){
REALTIME(5.milliseconds),
VERY_FAST(10.milliseconds),
FAST(20.milliseconds),
MEDIUM(50.milliseconds),
SLOW(100.milliseconds),
VERY_SLOW(500.milliseconds),
}
/**
* Perform an action on default timer
*/
public fun StateContainer.onTimer(
defaultTimer: DefaultTimer = DefaultTimer.FAST,
writes: Collection<DeviceState<*>> = emptySet(),
reads: Collection<DeviceState<*>> = emptySet(),
block: suspend (prev: Instant, next: Instant) -> Unit,
): Job = timer(defaultTimer.duration).onChange(writes = writes, reads = reads, onChange = block)
//TODO implement timer pooling
public fun <T, R> StateContainer.mapState( public fun <T, R> StateContainer.mapState(
origin: DeviceState<T>, origin: DeviceState<T>,
transformation: (T) -> R, transformation: (T) -> R,
): DeviceStateWithDependencies<R> = state(DeviceState.map(origin, transformation)) ): DeviceStateWithDependencies<R> = registerState(DeviceState.map(origin, transformation))
public fun <T, R> StateContainer.flowState( public fun <T, R> StateContainer.flowState(
origin: DeviceState<T>, origin: DeviceState<T>,
initialValue: R, initialValue: R,
transformation: suspend FlowCollector<R>.(T) -> Unit transformation: suspend FlowCollector<R>.(T) -> Unit,
): DeviceStateWithDependencies<R> { ): DeviceStateWithDependencies<R> {
val state = MutableDeviceState(initialValue) val state = MutableDeviceState(initialValue)
origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this) origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this)
return state(state.withDependencies(setOf(origin))) return registerState(state.withDependencies(setOf(origin)))
} }
/** /**
@ -123,8 +155,7 @@ public fun <T1, T2, R> StateContainer.combineState(
first: DeviceState<T1>, first: DeviceState<T1>,
second: DeviceState<T2>, second: DeviceState<T2>,
transformation: (T1, T2) -> R, transformation: (T1, T2) -> R,
): DeviceState<R> = state(DeviceState.combine(first, second, transformation)) ): DeviceState<R> = registerState(DeviceState.combine(first, second, transformation))
/** /**
* Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically
@ -132,7 +163,7 @@ 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.bind(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState)) val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
registerElement(descriptor) registerElement(descriptor)
return sourceState.valueFlow.onEach { return sourceState.valueFlow.onEach {

View File

@ -32,13 +32,14 @@ public abstract class DeviceConstructor(
_constructorElements.remove(constructorElement) _constructorElements.remove(constructorElement)
} }
override fun <T> registerProperty( override fun <T, S: DeviceState<T>> registerAsProperty(
converter: MetaConverter<T>, converter: MetaConverter<T>,
descriptor: PropertyDescriptor, descriptor: PropertyDescriptor,
state: DeviceState<T>, state: S,
) { ): S {
super.registerProperty(converter, descriptor, state) val res = super.registerAsProperty(converter, descriptor, state)
registerElement(PropertyConstructorElement(this, descriptor.name, state)) registerElement(PropertyConstructorElement(this, descriptor.name, state))
return res
} }
} }
@ -83,7 +84,7 @@ public fun <T, S : DeviceState<T>> DeviceConstructor.property(
PropertyDelegateProvider { _: DeviceConstructor, property -> PropertyDelegateProvider { _: DeviceConstructor, property ->
val name = nameOverride ?: property.name val name = nameOverride ?: property.name
val descriptor = PropertyDescriptor(name).apply(descriptorBuilder) val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
registerProperty(converter, descriptor, state) registerAsProperty(converter, descriptor, state)
ReadOnlyProperty { _: DeviceConstructor, _ -> ReadOnlyProperty { _: DeviceConstructor, _ ->
state state
} }
@ -140,7 +141,10 @@ public fun <T> DeviceConstructor.virtualProperty(
nameOverride, nameOverride,
) )
public fun <T, S : DeviceState<T>> DeviceConstructor.property( public fun <T, S : DeviceState<T>> DeviceConstructor.registerAsProperty(
spec: DevicePropertySpec<*, T>, spec: DevicePropertySpec<*, T>,
state: S, state: S,
): Unit = registerProperty(spec.converter, spec.descriptor, state) ): S {
registerAsProperty(spec.converter, spec.descriptor, state)
return state
}

View File

@ -93,11 +93,11 @@ public open class DeviceGroup(
/** /**
* Register a new property based on [DeviceState]. Properties could be modified dynamically * Register a new property based on [DeviceState]. Properties could be modified dynamically
*/ */
public open fun <T> registerProperty( public open fun <T, S : DeviceState<T>> registerAsProperty(
converter: MetaConverter<T>, converter: MetaConverter<T>,
descriptor: PropertyDescriptor, descriptor: PropertyDescriptor,
state: DeviceState<T>, state: S,
) { ): S {
val name = descriptor.name.parseAsName() val name = descriptor.name.parseAsName()
require(properties[name] == null) { "Can't add property with name $name. It already exists." } require(properties[name] == null) { "Can't add property with name $name. It already exists." }
properties[name] = Property(state, converter, descriptor) properties[name] = Property(state, converter, descriptor)
@ -109,6 +109,7 @@ public open class DeviceGroup(
) )
) )
}.launchIn(this) }.launchIn(this)
return state
} }
private val actions: MutableMap<Name, Action> = hashMapOf() private val actions: MutableMap<Name, Action> = hashMapOf()
@ -174,8 +175,8 @@ public open class DeviceGroup(
} }
} }
public fun <T> DeviceGroup.registerProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) { public fun <T> DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
registerProperty(propertySpec.converter, propertySpec.descriptor, state) registerAsProperty(propertySpec.converter, propertySpec.descriptor, state)
} }
public fun DeviceManager.registerDeviceGroup( public fun DeviceManager.registerDeviceGroup(
@ -246,13 +247,13 @@ public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -
/** /**
* Register read-only property based on [state] * Register read-only property based on [state]
*/ */
public fun <T : Any> DeviceGroup.registerProperty( public fun <T : Any> DeviceGroup.registerAsProperty(
name: String, name: String,
converter: MetaConverter<T>, converter: MetaConverter<T>,
state: DeviceState<T>, state: DeviceState<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
) { ) {
registerProperty( registerAsProperty(
converter, converter,
PropertyDescriptor(name).apply(descriptorBuilder), PropertyDescriptor(name).apply(descriptorBuilder),
state state
@ -268,7 +269,7 @@ public fun <T : Any> DeviceGroup.registerMutableProperty(
state: MutableDeviceState<T>, state: MutableDeviceState<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
) { ) {
registerProperty( registerAsProperty(
converter, converter,
PropertyDescriptor(name).apply(descriptorBuilder), PropertyDescriptor(name).apply(descriptorBuilder),
state state

View File

@ -6,12 +6,14 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.UnitsOfMeasurement
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** /**
* An observable state of a device * An observable state of a device
*/ */
public interface DeviceState<T> { public interface DeviceState<out T> {
public val value: T public val value: T
public val valueFlow: Flow<T> public val valueFlow: Flow<T>
@ -49,7 +51,7 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
} }
public fun <T> DeviceState<T>.withDependencies( public fun <T> DeviceState<T>.withDependencies(
dependencies: Collection<DeviceState<*>> dependencies: Collection<DeviceState<*>>,
): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this { ): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this {
override val dependencies: Collection<DeviceState<*>> = dependencies override val dependencies: Collection<DeviceState<*>> = dependencies
} }
@ -67,7 +69,19 @@ public fun <T, R> DeviceState.Companion.map(
override val valueFlow: Flow<R> = state.valueFlow.map(mapper) override val valueFlow: Flow<R> = state.valueFlow.map(mapper)
override fun toString(): String = "DeviceState.map(arg=${state})" override fun toString(): String = "DeviceState.map(state=${state})"
}
public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper)
public fun DeviceState<out NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = object : DeviceState<Double> {
override val value: Double
get() = this@values.value.value
override val valueFlow: Flow<Double>
get() = this@values.valueFlow.map { it.value }
override fun toString(): String = this@values.toString()
} }
/** /**

View File

@ -6,7 +6,7 @@ import kotlinx.coroutines.newCoroutineContext
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
public abstract class ConstructorModel( public abstract class ModelConstructor(
final override val context: Context, final override val context: Context,
vararg dependencies: DeviceState<*>, vararg dependencies: DeviceState<*>,
) : StateContainer, CoroutineScope { ) : StateContainer, CoroutineScope {

View File

@ -0,0 +1,18 @@
package space.kscience.controls.constructor.devices
import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.property
import space.kscience.controls.constructor.units.NewtonsMeters
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.numerical
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.MetaConverter
public class Drive(
context: Context,
force: MutableDeviceState<NumericalValue<NewtonsMeters>> = MutableDeviceState(NumericalValue(0)),
) : DeviceConstructor(context) {
public val force: MutableDeviceState<NumericalValue<NewtonsMeters>> by property(MetaConverter.numerical(), force)
}

View File

@ -0,0 +1,20 @@
package space.kscience.controls.constructor.devices
import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.property
import space.kscience.controls.constructor.units.Degrees
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.numerical
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.MetaConverter
/**
* An encoder that can read an angle
*/
public class EncoderDevice(
context: Context,
position: DeviceState<NumericalValue<Degrees>>
) : DeviceConstructor(context) {
public val position: DeviceState<NumericalValue<Degrees>> by property(MetaConverter.numerical<Degrees>(), position)
}

View File

@ -0,0 +1,44 @@
package space.kscience.controls.constructor.devices
import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.map
import space.kscience.controls.constructor.registerAsProperty
import space.kscience.controls.constructor.units.Direction
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.UnitsOfMeasurement
import space.kscience.controls.spec.DevicePropertySpec
import space.kscience.controls.spec.DeviceSpec
import space.kscience.controls.spec.booleanProperty
import space.kscience.dataforge.context.Context
/**
* Virtual [LimitSwitch]
*/
public class LimitSwitch(
context: Context,
locked: DeviceState<Boolean>,
) : DeviceConstructor(context) {
public val locked: DeviceState<Boolean> = registerAsProperty(LimitSwitch.locked, locked)
public companion object : DeviceSpec<LimitSwitch>() {
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked.value }
}
}
public fun <U : UnitsOfMeasurement, T : NumericalValue<U>> LimitSwitch(
context: Context,
limit: T,
boundary: Direction,
position: DeviceState<T>,
): LimitSwitch = LimitSwitch(
context,
DeviceState.map(position) {
when (boundary) {
Direction.UP -> it >= limit
Direction.DOWN -> it <= limit
}
}
)

View File

@ -0,0 +1,38 @@
package space.kscience.controls.constructor.devices
import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.models.PidParameters
import space.kscience.controls.constructor.models.PidRegulator
import space.kscience.controls.constructor.units.Meters
import space.kscience.controls.constructor.units.NewtonsMeters
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.numerical
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
public class LinearDrive(
drive: Drive,
start: LimitSwitch,
end: LimitSwitch,
position: DeviceState<NumericalValue<Meters>>,
pidParameters: PidParameters,
context: Context = drive.context,
meta: Meta = Meta.EMPTY,
) : DeviceConstructor(context, meta) {
public val position: DeviceState<NumericalValue<Meters>> by property(MetaConverter.numerical(), position)
public val drive: Drive by device(drive)
public val pid: PidRegulator<Meters, NewtonsMeters> = model(
PidRegulator(
context = context,
position = position,
output = drive.force,
pidParameters = pidParameters
)
)
public val startLimit: LimitSwitch by device(start)
public val endLimit: LimitSwitch by device(end)
}

View File

@ -0,0 +1,61 @@
package space.kscience.controls.constructor.devices
import kotlinx.coroutines.launch
import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.units.Degrees
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.plus
import space.kscience.controls.constructor.units.times
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.MetaConverter
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.time.DurationUnit
/**
* A step drive
*
* @param speed ticks per second
* @param target target ticks state
* @param writeTicks a hardware callback
*/
public class StepDrive(
context: Context,
speed: MutableDeviceState<Double>,
target: MutableDeviceState<Int> = MutableDeviceState(0),
private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit,
) : DeviceConstructor(context) {
public val target: MutableDeviceState<Int> by property(MetaConverter.int, target)
public val speed: MutableDeviceState<Double> by property(MetaConverter.double, speed)
private val positionState = stateOf(target.value)
public val position: DeviceState<Int> by property(MetaConverter.int, positionState)
private val ticker = onTimer { prev, next ->
val tickSpeed = speed.value
val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
val ticksDelta: Int = target.value - position.value
val steps: Int = when {
ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToInt())
ticksDelta < 0 -> max(ticksDelta, (timeDelta * tickSpeed).roundToInt())
else -> return@onTimer
}
launch {
writeTicks(steps, tickSpeed)
positionState.value += steps
}
}
}
/**
* Compute a state using given tick-to-angle transformation
*/
public fun StepDrive.angle(
zero: NumericalValue<Degrees>,
step: NumericalValue<Degrees>,
): DeviceState<NumericalValue<Degrees>> = position.map { zero + it * step }

View File

@ -1,57 +0,0 @@
package space.kscience.controls.constructor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* A state describing a [Double] value in the [range]
*/
public class DoubleInRangeState(
initialValue: Double,
public val range: ClosedFloatingPointRange<Double>,
) : MutableDeviceState<Double> {
init {
require(initialValue in range) { "Initial value should be in range" }
}
private val _valueFlow = MutableStateFlow(initialValue)
override var value: Double
get() = _valueFlow.value
set(newValue) {
_valueFlow.value = newValue.coerceIn(range)
}
override val valueFlow: StateFlow<Double> get() = _valueFlow
/**
* A state showing that the range is on its lower boundary
*/
public val atStart: DeviceState<Boolean> = DeviceState.map(this) {
it <= range.start
}
/**
* A state showing that the range is on its higher boundary
*/
public val atEnd: DeviceState<Boolean> = DeviceState.map(this) {
it >= range.endInclusive
}
override fun toString(): String = "DoubleRangeState(range=$range)"
}
/**
* Create and register a [DoubleInRangeState]
*/
public fun StateContainer.doubleInRangeState(
initialValue: Double,
range: ClosedFloatingPointRange<Double>,
): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
registerElement(StateConstructorElement(it))
}

View File

@ -2,6 +2,7 @@ package space.kscience.controls.constructor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
/** /**
* A [MutableDeviceState] that does not correspond to a physical state * A [MutableDeviceState] that does not correspond to a physical state
@ -35,3 +36,18 @@ public fun <T> MutableDeviceState(
initialValue: T, initialValue: T,
callback: (T) -> Unit = {}, callback: (T) -> Unit = {},
): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback) ): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback)
/**
* Create a [DeviceState] with constant value
*/
public fun <T> DeviceState(
value: T
): DeviceState<T> = object : DeviceState<T> {
override val value: T get() = value
override val valueFlow: Flow<T>
get() = emptyFlow()
override fun toString(): String = "ConstDeviceState($value)"
}

View File

@ -1,20 +0,0 @@
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

@ -1,101 +0,0 @@
package space.kscience.controls.constructor.library
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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.propertyAsState
import space.kscience.controls.manager.clock
import space.kscience.controls.spec.*
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.Factory
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.double
import space.kscience.dataforge.meta.get
import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* A classic drive regulated by force with encoder
*/
public interface Drive : Device {
/**
* Get or set drive force or momentum
*/
public var force: Double
/**
* Current position value
*/
public val position: Double
public companion object : DeviceSpec<Drive>() {
public val force: MutableDevicePropertySpec<Drive, Double> by Drive.mutableProperty(
MetaConverter.double,
Drive::force
)
public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
}
}
/**
* A virtual drive
*/
public class VirtualDrive(
context: Context,
private val mass: Double,
public val positionState: MutableDeviceState<Double>,
) : Drive, DeviceBySpec<Drive>(Drive, context) {
private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
private val clock = context.clock
override var force: Double = 0.0
override val position: Double get() = positionState.value
public var velocity: Double = 0.0
private set
private var updateJob: Job? = null
override suspend fun onStart() {
updateJob = launch {
var lastTime = clock.now()
while (isActive) {
delay(dt)
val realTime = clock.now()
val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
//set last time and value to new values
lastTime = realTime
// compute new value based on velocity and acceleration from the previous step
positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
propertyChanged(Drive.position, positionState.value)
// compute new velocity based on acceleration on the previous step
velocity += force / mass * dtSeconds
}
}
}
override suspend fun onStop() {
updateJob?.cancel()
}
public companion object {
public fun factory(
mass: Double,
positionState: MutableDeviceState<Double>,
): Factory<Drive> = Factory { context, _ ->
VirtualDrive(context, mass, positionState)
}
}
}
public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = propertyAsState(Drive.force)

View File

@ -1,37 +0,0 @@
package space.kscience.controls.constructor.library
import space.kscience.controls.api.Device
import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.DeviceState
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.meta.MetaConverter
/**
* A limit switch device
*/
public interface LimitSwitch : Device {
public fun isLocked(): Boolean
public companion object : DeviceSpec<LimitSwitch>() {
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { isLocked() }
}
}
/**
* Virtual [LimitSwitch]
*/
public class VirtualLimitSwitch(
context: Context,
locked: DeviceState<Boolean>,
) : DeviceConstructor(context), LimitSwitch {
public val locked: DeviceState<Boolean> by property(MetaConverter.boolean, locked)
override fun isLocked(): Boolean = locked.value
}

View File

@ -1,87 +0,0 @@
package space.kscience.controls.constructor.library
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Instant
import space.kscience.controls.constructor.DeviceGroup
import space.kscience.controls.manager.clock
import space.kscience.controls.spec.DeviceBySpec
import space.kscience.controls.spec.write
import space.kscience.dataforge.names.parseAsName
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* Pid regulator parameters
*/
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 var pidParameters: PidParameters, // TODO expose as property
) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
private val clock = drive.context.clock
override var target: Double = drive.position
private var lastTime: Instant = clock.now()
private var lastPosition: Double = target
private var integral: Double = 0.0
private var updateJob: Job? = null
private val mutex = Mutex()
override suspend fun onStart() {
drive.start()
updateJob = launch {
while (isActive) {
delay(pidParameters.timeStep)
mutex.withLock {
val realTime = clock.now()
val delta = target - getPosition()
val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
integral += delta * dtSeconds
val derivative = (drive.position - lastPosition) / dtSeconds
//set last time and value to new values
lastTime = realTime
lastPosition = drive.position
drive.write(Drive.force,pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative)
//drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
propertyChanged(Regulator.position, drive.position)
}
}
}
}
override suspend fun onStop() {
updateJob?.cancel()
drive.stop()
}
override suspend fun getPosition(): Double = drive.position
}
public fun DeviceGroup.pid(
name: String,
drive: Drive,
pidParameters: PidParameters,
): PidRegulator = install(name.parseAsName(), PidRegulator(drive, pidParameters))

View File

@ -1,27 +0,0 @@
package space.kscience.controls.constructor.library
import space.kscience.controls.api.Device
import space.kscience.controls.spec.*
import space.kscience.dataforge.meta.MetaConverter
/**
* A regulator with target value and current position
*/
public interface Regulator : Device {
/**
* Get or set target value
*/
public var target: Double
/**
* Current position value
*/
public suspend fun getPosition(): Double
public companion object : DeviceSpec<Regulator>() {
public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { getPosition() }
}
}

View File

@ -0,0 +1,104 @@
package space.kscience.controls.constructor.models
import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.units.*
import space.kscience.dataforge.context.Context
import kotlin.math.pow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* A model for inertial movement. Both linear and angular
*/
public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
context: Context,
force: DeviceState<Double>, //TODO add system unit sets
inertia: Double,
public val position: MutableDeviceState<NumericalValue<U>>,
public val velocity: MutableDeviceState<NumericalValue<V>>,
timerPrecision: Duration = 10.milliseconds,
) : ModelConstructor(context) {
init {
registerState(position)
registerState(velocity)
}
private val movementTimer = timer(timerPrecision)
private var currentForce = force.value
private val movement = movementTimer.onChange { prev, next ->
val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
// compute new value based on velocity and acceleration from the previous step
position.value += NumericalValue(velocity.value.value * dtSeconds + currentForce / inertia * dtSeconds.pow(2) / 2)
// compute new velocity based on acceleration on the previous step
velocity.value += NumericalValue(currentForce / inertia * dtSeconds)
currentForce = force.value
}
public companion object {
/**
* Linear inertial model with [force] in newtons and [mass] in kilograms
*/
public fun linear(
context: Context,
force: DeviceState<NumericalValue<Newtons>>,
mass: NumericalValue<Kilograms>,
position: MutableDeviceState<NumericalValue<Meters>>,
velocity: MutableDeviceState<NumericalValue<MetersPerSecond>> = MutableDeviceState(NumericalValue(0.0)),
): Inertia<Meters, MetersPerSecond> = Inertia(
context = context,
force = force.values(),
inertia = mass.value,
position = position,
velocity = velocity
)
//
//
// public fun linear(
// context: Context,
// force: DeviceState<NumericalValue<Newtons>>,
// mass: NumericalValue<Kilograms>,
// initialPosition: NumericalValue<Meters>,
// initialVelocity: NumericalValue<MetersPerSecond> = NumericalValue(0),
// ): Inertia<Meters, MetersPerSecond> = Inertia(
// context = context,
// force = force.values(),
// inertia = mass.value,
// position = MutableDeviceState(initialPosition),
// velocity = MutableDeviceState(initialVelocity)
// )
public fun circular(
context: Context,
force: DeviceState<NumericalValue<NewtonsMeters>>,
momentOfInertia: NumericalValue<KgM2>,
position: MutableDeviceState<NumericalValue<Degrees>>,
velocity: MutableDeviceState<NumericalValue<DegreesPerSecond>> = MutableDeviceState(NumericalValue(0.0)),
): Inertia<Degrees, DegreesPerSecond> = Inertia(
context = context,
force = force.values(),
inertia = momentOfInertia.value,
position = position,
velocity = velocity
)
//
// public fun circular(
// context: Context,
// force: DeviceState<NumericalValue<NewtonsMeters>>,
// momentOfInertia: NumericalValue<KgM2>,
// initialPosition: NumericalValue<Degrees>,
// initialVelocity: NumericalValue<DegreesPerSecond> = NumericalValue(0),
// ): Inertia<Degrees, DegreesPerSecond> = Inertia(
// context = context,
// force = force.values(),
// inertia = momentOfInertia.value,
// position = MutableDeviceState(initialPosition),
// velocity = MutableDeviceState(initialVelocity)
// )
}
}

View File

@ -0,0 +1,73 @@
package space.kscience.controls.constructor.models
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.units.*
import space.kscience.controls.manager.clock
import space.kscience.dataforge.context.Context
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* Pid regulator parameters
*/
public data class PidParameters(
val kp: Double,
val ki: Double,
val kd: Double,
val timeStep: Duration = 10.milliseconds,
)
/**
* A PID regulator
*
* @param P units of position values
* @param O units of output values
*/
public class PidRegulator<P : UnitsOfMeasurement, O : UnitsOfMeasurement>(
context: Context,
private val position: DeviceState<NumericalValue<P>>,
public var pidParameters: PidParameters, // TODO expose as property
output: MutableDeviceState<NumericalValue<O>> = MutableDeviceState(NumericalValue(0.0)),
private val convertOutput: (NumericalValue<P>) -> NumericalValue<O> = { NumericalValue(it.value) },
) : ModelConstructor(context) {
public val target: MutableDeviceState<NumericalValue<P>> = stateOf(NumericalValue(0.0))
public val output: MutableDeviceState<NumericalValue<O>> = registerState(output)
private val updateJob = launch {
var lastPosition: NumericalValue<P> = target.value
var integral: NumericalValue<P> = NumericalValue(0.0)
val mutex = Mutex()
val clock = context.clock
var lastTime = clock.now()
while (isActive) {
delay(pidParameters.timeStep)
mutex.withLock {
val realTime = clock.now()
val delta: NumericalValue<P> = target.value - position.value
val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
integral += delta * dtSeconds
val derivative = (position.value - lastPosition) / dtSeconds
//set last time and value to new values
lastTime = realTime
lastPosition = position.value
output.value =
convertOutput(pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative)
}
}
}
}

View File

@ -0,0 +1,68 @@
package space.kscience.controls.constructor.models
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.map
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.UnitsOfMeasurement
/**
* A state describing a [T] value in the [range]
*/
public open class RangeState<T : Comparable<T>>(
private val input: DeviceState<T>,
public val range: ClosedRange<T>,
) : DeviceState<T> {
override val valueFlow: Flow<T> get() = input.valueFlow.map { it.coerceIn(range) }
override val value: T get() = input.value.coerceIn(range)
/**
* A state showing that the range is on its lower boundary
*/
public val atStart: DeviceState<Boolean> = input.map { it <= range.start }
/**
* A state showing that the range is on its higher boundary
*/
public val atEnd: DeviceState<Boolean> = input.map { it >= range.endInclusive }
override fun toString(): String = "DoubleRangeState(value=${value},range=$range)"
}
public class MutableRangeState<T : Comparable<T>>(
private val mutableInput: MutableDeviceState<T>,
range: ClosedRange<T>,
) : RangeState<T>(mutableInput, range), MutableDeviceState<T> {
override var value: T
get() = super.value
set(value) {
mutableInput.value = value.coerceIn(range)
}
}
public fun <T : Comparable<T>> MutableRangeState(
initialValue: T,
range: ClosedRange<T>,
): MutableRangeState<T> = MutableRangeState<T>(MutableDeviceState(initialValue), range)
public fun <U : UnitsOfMeasurement> MutableRangeState(
initialValue: Double,
range: ClosedRange<Double>,
): MutableRangeState<NumericalValue<U>> = MutableRangeState(
initialValue = NumericalValue(initialValue),
range = NumericalValue<U>(range.start)..NumericalValue<U>(range.endInclusive)
)
public fun <T : Comparable<T>> DeviceState<T>.coerceIn(
range: ClosedFloatingPointRange<T>,
): RangeState<T> = RangeState(this, range)
public fun <T : Comparable<T>> MutableDeviceState<T>.coerceIn(
range: ClosedFloatingPointRange<T>,
): MutableRangeState<T> = MutableRangeState(this, range)

View File

@ -0,0 +1,25 @@
package space.kscience.controls.constructor.models
import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.units.Degrees
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.units.times
import space.kscience.dataforge.context.Context
/**
* A reducer device used for simulations only (no public properties)
*/
public class Reducer(
context: Context,
public val ratio: Double,
public val input: DeviceState<NumericalValue<Degrees>>,
public val output: MutableDeviceState<NumericalValue<Degrees>>,
) : ModelConstructor(context) {
init {
registerState(input)
registerState(output)
transformTo(input, output) {
it * ratio
}
}
}

View File

@ -0,0 +1,22 @@
package space.kscience.controls.constructor.models
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.ModelConstructor
import space.kscience.controls.constructor.map
import space.kscience.controls.constructor.units.Meters
import space.kscience.controls.constructor.units.Newtons
import space.kscience.controls.constructor.units.NewtonsMeters
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.dataforge.context.Context
public class ScrewDrive(
context: Context,
public val leverage: NumericalValue<Meters>,
) : ModelConstructor(context) {
public fun transformForce(
stateOfForce: DeviceState<NumericalValue<NewtonsMeters>>,
): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfForce) {
NumericalValue(it.value * leverage.value)
}
}

View File

@ -0,0 +1,6 @@
package space.kscience.controls.constructor.units
public enum class Direction(public val coef: Int) {
UP(1),
DOWN(-1)
}

View File

@ -1,5 +1,8 @@
package space.kscience.controls.constructor.units package space.kscience.controls.constructor.units
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.double
import kotlin.jvm.JvmInline import kotlin.jvm.JvmInline
@ -7,4 +10,46 @@ import kotlin.jvm.JvmInline
* A value without identity coupled to units of measurements. * A value without identity coupled to units of measurements.
*/ */
@JvmInline @JvmInline
public value class NumericalValue<T: UnitsOfMeasurement>(public val value: Double) public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double): Comparable<NumericalValue<U>> {
override fun compareTo(other: NumericalValue<U>): Int = value.compareTo(other.value)
}
public fun <U : UnitsOfMeasurement> NumericalValue(
number: Number,
): NumericalValue<U> = NumericalValue(number.toDouble())
public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.plus(
other: NumericalValue<U>,
): NumericalValue<U> = NumericalValue(this.value + other.value)
public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.minus(
other: NumericalValue<U>,
): NumericalValue<U> = NumericalValue(this.value - other.value)
public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
c: Number,
): NumericalValue<U> = NumericalValue(this.value * c.toDouble())
public operator fun <U : UnitsOfMeasurement> Number.times(
numericalValue: NumericalValue<U>,
): NumericalValue<U> = NumericalValue(numericalValue.value * toDouble())
public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
c: Double,
): NumericalValue<U> = NumericalValue(this.value * c)
public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(
c: Number,
): NumericalValue<U> = NumericalValue(this.value / c.toDouble())
private object NumericalValueMetaConverter : MetaConverter<NumericalValue<*>> {
override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value)
override fun readOrNull(source: Meta): NumericalValue<*>? = source.double?.let { NumericalValue<Nothing>(it) }
}
@Suppress("UNCHECKED_CAST")
public fun <U : UnitsOfMeasurement> MetaConverter.Companion.numerical(): MetaConverter<NumericalValue<U>> =
NumericalValueMetaConverter as MetaConverter<NumericalValue<U>>

View File

@ -23,8 +23,38 @@ public data object MetersPerSecond: UnitsOfVelocity
/**/ /**/
public interface UnitsAngularOfVelocity: UnitsOfMeasurement public sealed interface UnitsOfAngles : UnitsOfMeasurement
public data object Radians : UnitsOfAngles
public data object Degrees : UnitsOfAngles
/**/
public sealed interface UnitsAngularOfVelocity : UnitsOfMeasurement
public data object RadiansPerSecond : UnitsAngularOfVelocity public data object RadiansPerSecond : UnitsAngularOfVelocity
public data object DegreesPerSecond : UnitsAngularOfVelocity public data object DegreesPerSecond : UnitsAngularOfVelocity
/**/
public interface UnitsOfForce: UnitsOfMeasurement
public data object Newtons: UnitsOfForce
/**/
public interface UnitsOfTorque: UnitsOfMeasurement
public data object NewtonsMeters: UnitsOfTorque
/**/
public interface UnitsOfMass: UnitsOfMeasurement
public data object Kilograms : UnitsOfMass
/**/
public interface UnitsOfMomentOfInertia: UnitsOfMeasurement
public data object KgM2: UnitsOfMomentOfInertia

View File

@ -15,8 +15,9 @@ class TimerTest {
@Test @Test
fun timer() = runTest { fun timer() = runTest {
val timer = TimerState(Global.request(ClockManager), 10.milliseconds) val timer = TimerState(Global.request(ClockManager), 10.milliseconds)
timer.valueFlow.take(10).onEach { timer.valueFlow.take(100).onEach {
println(it) println(it)
}.collect() }.collect()
} }
} }

View File

@ -9,6 +9,7 @@ import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.double import space.kscience.dataforge.meta.double
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlin.time.Duration
@OptIn(InternalCoroutinesApi::class) @OptIn(InternalCoroutinesApi::class)
private class CompressedTimeDispatcher( private class CompressedTimeDispatcher(
@ -78,6 +79,14 @@ public class ClockManager : AbstractPlugin() {
CompressedTimeDispatcher(this, dispatcher, timeCompression) CompressedTimeDispatcher(this, dispatcher, timeCompression)
} }
public fun scheduleWithFixedDelay(tick: Duration, block: suspend () -> Unit): Job = context.launch(asDispatcher()) {
while (isActive) {
delay(tick)
block()
}
}
public companion object : PluginFactory<ClockManager> { public companion object : PluginFactory<ClockManager> {
override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP) override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP)

View File

@ -0,0 +1,59 @@
package space.kscience.controls.compose
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
@Composable
public fun NumberTextField(
value: Number,
onValueChange: (Number) -> Unit,
step: Double = 0.0,
formatter: (Number) -> String = { it.toString() },
modifier: Modifier = Modifier,
enabled: Boolean = true,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
) {
var isError by remember { mutableStateOf(false) }
Row (verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
step.takeIf { it > 0.0 }?.let {
IconButton({ onValueChange(value.toDouble() - step) }, enabled = enabled) {
Icon(Icons.Default.KeyboardArrowLeft, "decrease value")
}
}
TextField(
value = formatter(value),
onValueChange = { stringValue: String ->
val number = stringValue.toDoubleOrNull()
number?.let { onValueChange(number) }
isError = number == null
},
isError = isError,
enabled = enabled,
textStyle = textStyle,
label = label,
supportingText = supportingText,
singleLine = true,
shape = shape,
colors = colors,
modifier = Modifier.weight(1f)
)
step.takeIf { it > 0.0 }?.let {
IconButton({ onValueChange(value.toDouble() + step) }, enabled = enabled) {
Icon(Icons.Default.KeyboardArrowRight, "increase value")
}
}
}
}

View File

@ -19,15 +19,12 @@ public class TimeAxisModel(
val currentRange = rangeProvider() val currentRange = rangeProvider()
val rangeLength = currentRange.endInclusive - currentRange.start val rangeLength = currentRange.endInclusive - currentRange.start
val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt() val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt()
val numMinorTicks = numTicks * 2
return object : TickValues<Instant> { return object : TickValues<Instant> {
override val majorTickValues: List<Instant> = List(numTicks) { override val majorTickValues: List<Instant> = List(numTicks) {
currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength
} }
override val minorTickValues: List<Instant> = List(numMinorTicks) { override val minorTickValues: List<Instant> = emptyList()
currentRange.start + it.toDouble() / (numMinorTicks - 1) * rangeLength
}
} }
} }

View File

@ -17,6 +17,8 @@ import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyChangedMessage import space.kscience.controls.api.PropertyChangedMessage
import space.kscience.controls.api.propertyMessageFlow import space.kscience.controls.api.propertyMessageFlow
import space.kscience.controls.constructor.DeviceState import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.units.NumericalValue
import space.kscience.controls.constructor.values
import space.kscience.controls.manager.clock import space.kscience.controls.manager.clock
import space.kscience.controls.misc.ValueWithTime import space.kscience.controls.misc.ValueWithTime
import space.kscience.controls.spec.DevicePropertySpec import space.kscience.controls.spec.DevicePropertySpec
@ -139,7 +141,7 @@ public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
@Composable @Composable
public fun XYGraphScope<Instant, Double>.PlotNumberState( public fun XYGraphScope<Instant, Double>.PlotNumberState(
context: Context, context: Context,
state: DeviceState<out Number>, state: DeviceState<Number>,
maxAge: Duration = defaultMaxAge, maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints, maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints, minPoints: Int = defaultMinPoints,
@ -163,6 +165,19 @@ public fun XYGraphScope<Instant, Double>.PlotNumberState(
PlotTimeSeries(points, lineStyle) PlotTimeSeries(points, lineStyle)
} }
@Composable
public fun XYGraphScope<Instant, Double>.PlotNumericState(
context: Context,
state: DeviceState<NumericalValue<*>>,
maxAge: Duration = defaultMaxAge,
maxPoints: Int = defaultMaxPoints,
minPoints: Int = defaultMinPoints,
sampling: Duration = defaultSampling,
lineStyle: LineStyle = defaultLineStyle,
): Unit {
PlotNumberState(context, state.values(), maxAge, maxPoints, minPoints, sampling, lineStyle)
}
private fun List<Instant>.averageTime(): Instant { private fun List<Instant>.averageTime(): Instant {
val min = min() val min = min()

View File

@ -41,7 +41,7 @@ class Spring(
val l0: Double, val l0: Double,
val begin: DeviceState<XY>, val begin: DeviceState<XY>,
val end: DeviceState<XY>, val end: DeviceState<XY>,
) : ConstructorModel(context) { ) : ModelConstructor(context) {
/** /**
* vector from start to end * vector from start to end
@ -76,7 +76,7 @@ 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),
) : ConstructorModel(context, force, position, velocity) { ) : ModelConstructor(context, force, position, velocity) {
private val timer: TimerState = timer(2.milliseconds) private val timer: TimerState = timer(2.milliseconds)

View File

@ -23,17 +23,27 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
import org.jetbrains.compose.splitpane.HorizontalSplitPane import org.jetbrains.compose.splitpane.HorizontalSplitPane
import space.kscience.controls.compose.PlotDeviceProperty import space.kscience.controls.compose.NumberTextField
import space.kscience.controls.compose.PlotNumberState import space.kscience.controls.compose.PlotNumericState
import space.kscience.controls.compose.TimeAxisModel import space.kscience.controls.compose.TimeAxisModel
import space.kscience.controls.constructor.* import space.kscience.controls.constructor.DeviceConstructor
import space.kscience.controls.constructor.library.* import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.devices.Drive
import space.kscience.controls.constructor.devices.LimitSwitch
import space.kscience.controls.constructor.devices.LinearDrive
import space.kscience.controls.constructor.models.Inertia
import space.kscience.controls.constructor.models.MutableRangeState
import space.kscience.controls.constructor.models.PidParameters
import space.kscience.controls.constructor.models.ScrewDrive
import space.kscience.controls.constructor.timer
import space.kscience.controls.constructor.units.Kilograms
import space.kscience.controls.constructor.units.Meters
import space.kscience.controls.constructor.units.NumericalValue
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.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import java.awt.Dimension import java.awt.Dimension
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.sin import kotlin.math.sin
@ -43,63 +53,79 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
class LinearDrive(
drive: Drive,
start: LimitSwitch,
end: LimitSwitch,
pidParameters: PidParameters,
meta: Meta = Meta.EMPTY,
) : DeviceConstructor(drive.context, meta) {
val drive by device(drive)
val pid by device(PidRegulator(drive, pidParameters))
val start by device(start)
val end by device(end)
val position = drive.propertyAsState(Drive.position, Double.NaN)
val target = pid.propertyAsState(Regulator.target, 0.0)
}
/**
* A shortcut to create a virtual [LimitSwitch] from [DoubleInRangeState]
*/
fun LinearDrive(
context: Context,
positionState: DoubleInRangeState,
mass: Double,
pidParameters: PidParameters,
meta: Meta = Meta.EMPTY,
): LinearDrive = LinearDrive(
drive = VirtualDrive(context, mass, positionState),
start = VirtualLimitSwitch(context, positionState.atStart),
end = VirtualLimitSwitch(context, positionState.atEnd),
pidParameters = pidParameters,
meta = meta
)
class Modulator( class Modulator(
context: Context, context: Context,
target: MutableDeviceState<Double>, target: MutableDeviceState<NumericalValue<Meters>>,
var freq: Double = 0.1, var freq: Double = 0.1,
var timeStep: Duration = 5.milliseconds, var timeStep: Duration = 5.milliseconds,
) : DeviceConstructor(context) { ) : DeviceConstructor(context) {
private val clockStart = clock.now() private val clockStart = clock.now()
val timer = timer(10.milliseconds) private val modulation = timer(10.milliseconds).onNext {
private val modulation = timer.onNext {
val timeFromStart = clock.now() - clockStart val timeFromStart = clock.now() - clockStart
val t = timeFromStart.toDouble(DurationUnit.SECONDS) val t = timeFromStart.toDouble(DurationUnit.SECONDS)
target.value = 5 * sin(2.0 * PI * freq * t) + target.value = NumericalValue(
5 * sin(2.0 * PI * freq * t) +
sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep)) sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
)
} }
} }
private val inertia = NumericalValue<Kilograms>(0.1)
private val leverage = NumericalValue<Meters>(0.05)
private val maxAge = 10.seconds private val maxAge = 10.seconds
private val range = -6.0..6.0
/**
* The whole physical model is here
*/
private fun createLinearDriveModel(context: Context, pidParameters: PidParameters): LinearDrive {
//create a drive model with zero starting force
val drive = Drive(context)
//a screw drive to converse a rotational moment into a linear one
val screwDrive = ScrewDrive(context, leverage)
// Create a physical position coerced in a given range
val position = MutableRangeState<Meters>(0.0, range)
/**
* Create an inertia model.
* The inertia uses drive force as input. Position is used as both input and output
*
* Force is the input parameter, position is output parameter
*
*/
val inertia = Inertia.linear(
context = context,
force = screwDrive.transformForce(drive.force),
mass = inertia,
position = position
)
/**
* Create a limit switches from physical position
*/
val startLimitSwitch = LimitSwitch(context, position.atStart)
val endLimitSwitch = LimitSwitch(context, position.atEnd)
return context.install(
"linearDrive",
LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters)
)
}
private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install(
"modulator",
Modulator(linearDrive.context, linearDrive.pid.target)
)
@OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class) @OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class)
fun main() = application { fun main() = application {
val context = remember { val context = remember {
@ -109,27 +135,16 @@ fun main() = application {
} }
} }
val clock = remember { context.clock }
var pidParameters by remember { var pidParameters by remember {
mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds)) mutableStateOf(PidParameters(kp = 900.0, ki = 20.0, kd = -50.0, timeStep = 0.005.seconds))
} }
val state = remember { DoubleInRangeState(0.0, -6.0..6.0) } val linearDrive: LinearDrive = remember {
createLinearDriveModel(context, pidParameters)
val linearDrive = remember {
context.install(
"linearDrive",
LinearDrive(context, state, 0.05, pidParameters)
)
} }
val modulator = remember { val modulator = remember {
context.install( createModulator(linearDrive)
"modulator",
Modulator(context, linearDrive.target)
)
} }
//bind pid parameters //bind pid parameters
@ -141,6 +156,8 @@ fun main() = application {
}.collect() }.collect()
} }
val clock = remember { context.clock }
Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
window.minimumSize = Dimension(800, 400) window.minimumSize = Dimension(800, 400)
MaterialTheme { MaterialTheme {
@ -149,48 +166,51 @@ fun main() = application {
Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) { Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) {
Row { Row {
Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
TextField( NumberTextField(
String.format("%.2f", pidParameters.kp), value = pidParameters.kp,
{ pidParameters = pidParameters.copy(kp = it.toDouble()) }, onValueChange = { pidParameters = pidParameters.copy(kp = it.toDouble()) },
Modifier.width(100.dp), formatter = { String.format("%.2f", it.toDouble()) },
enabled = false step = 1.0,
modifier = Modifier.width(200.dp),
) )
Slider( Slider(
pidParameters.kp.toFloat(), pidParameters.kp.toFloat(),
{ pidParameters = pidParameters.copy(kp = it.toDouble()) }, { pidParameters = pidParameters.copy(kp = it.toDouble()) },
valueRange = 0f..20f, valueRange = 0f..1000f,
steps = 100 steps = 100
) )
} }
Row { Row {
Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
TextField( NumberTextField(
String.format("%.2f", pidParameters.ki), value = pidParameters.ki,
{ pidParameters = pidParameters.copy(ki = it.toDouble()) }, onValueChange = { pidParameters = pidParameters.copy(ki = it.toDouble()) },
Modifier.width(100.dp), formatter = { String.format("%.2f", it.toDouble()) },
enabled = false step = 0.1,
modifier = Modifier.width(200.dp),
) )
Slider( Slider(
pidParameters.ki.toFloat(), pidParameters.ki.toFloat(),
{ pidParameters = pidParameters.copy(ki = it.toDouble()) }, { pidParameters = pidParameters.copy(ki = it.toDouble()) },
valueRange = -10f..10f, valueRange = -100f..100f,
steps = 100 steps = 100
) )
} }
Row { Row {
Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
TextField( NumberTextField(
String.format("%.2f", pidParameters.kd), value = pidParameters.kd,
{ pidParameters = pidParameters.copy(kd = it.toDouble()) }, onValueChange = { pidParameters = pidParameters.copy(kd = it.toDouble()) },
Modifier.width(100.dp), formatter = { String.format("%.2f", it.toDouble()) },
enabled = false step = 0.1,
modifier = Modifier.width(200.dp),
) )
Slider( Slider(
pidParameters.kd.toFloat(), pidParameters.kd.toFloat(),
{ pidParameters = pidParameters.copy(kd = it.toDouble()) }, { pidParameters = pidParameters.copy(kd = it.toDouble()) },
valueRange = -10f..10f, valueRange = -100f..100f,
steps = 100 steps = 100
) )
} }
@ -200,14 +220,14 @@ fun main() = application {
TextField( TextField(
pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
{ pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
Modifier.width(100.dp), Modifier.width(200.dp),
enabled = false enabled = false
) )
Slider( Slider(
pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(), pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
{ pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
valueRange = 0f..100f, valueRange = 1f..100f,
steps = 100 steps = 100
) )
} }
@ -229,7 +249,7 @@ fun main() = application {
ChartLayout { ChartLayout {
XYGraph<Instant, Double>( XYGraph<Instant, Double>(
xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) }, xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) },
yAxisModel = rememberDoubleLinearAxisModel(state.range), yAxisModel = rememberDoubleLinearAxisModel((range.start - 1.0)..(range.endInclusive + 1.0)),
xAxisTitle = { Text("Time in seconds relative to current") }, xAxisTitle = { Text("Time in seconds relative to current") },
xAxisLabels = { it: Instant -> xAxisLabels = { it: Instant ->
androidx.compose.material3.Text( androidx.compose.material3.Text(
@ -240,22 +260,16 @@ fun main() = application {
}, },
yAxisLabels = { it: Double -> Text(it.toString(2)) } yAxisLabels = { it: Double -> Text(it.toString(2)) }
) { ) {
PlotNumberState( PlotNumericState(
context = context, context = context,
state = state, state = linearDrive.position,
maxAge = maxAge, maxAge = maxAge,
sampling = 50.milliseconds, sampling = 50.milliseconds,
lineStyle = LineStyle(SolidColor(Color.Blue)) lineStyle = LineStyle(SolidColor(Color.Blue))
) )
PlotDeviceProperty( PlotNumericState(
linearDrive.pid, context = context,
Regulator.position, state = linearDrive.pid.target,
maxAge = maxAge,
sampling = 50.milliseconds,
)
PlotDeviceProperty(
linearDrive.pid,
Regulator.target,
maxAge = maxAge, maxAge = maxAge,
sampling = 50.milliseconds, sampling = 50.milliseconds,
lineStyle = LineStyle(SolidColor(Color.Red)) lineStyle = LineStyle(SolidColor(Color.Red))
@ -269,10 +283,6 @@ fun main() = application {
} }
1 -> { 1 -> {
Text("Regulator position", color = Color.Black)
}
2 -> {
Text("Regulator target", color = Color.Red) Text("Regulator target", color = Color.Red)
} }
} }