Finalize collective demo

This commit is contained in:
Alexander Nozik 2024-06-12 16:31:14 +03:00
parent a5bb42706b
commit eb126a6090
6 changed files with 188 additions and 95 deletions

View File

@ -73,7 +73,7 @@ public data class PropertySetMessage(
public val property: String, public val property: String,
public val value: Meta, public val value: Meta,
override val sourceDevice: Name? = null, override val sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name?,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {

View File

@ -68,7 +68,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
/** /**
* Process incoming [DeviceMessage], using hub naming to find target. * Process incoming [DeviceMessage], using hub naming to find target.
* If the `targetDevice` is `null`, then message is sent to each device in this hub * If the `targetDevice` is `null`, then the message is sent to each device in this hub
*/ */
public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> { public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> {
return try { return try {

View File

@ -23,7 +23,11 @@ class CollectiveDeviceConfiguration(deviceId: CollectiveDeviceId) : Scheme() {
var deviceId by string(deviceId) var deviceId by string(deviceId)
var description by string() var description by string()
var reportInterval by int(500) var reportInterval by int(500)
var radioFrequency by string(default = "169 MHz") var radioFrequency by string(default = DEFAULT_FREQUENCY)
companion object {
const val DEFAULT_FREQUENCY = "169 MHz"
}
} }
typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration> typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration>

View File

@ -1,12 +1,14 @@
package space.kscience.controls.demo.collective package space.kscience.controls.demo.collective
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import kotlinx.io.writeString
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import space.kscience.controls.api.DeviceMessage import space.kscience.controls.api.DeviceMessage
import space.kscience.controls.api.PropertySetMessage
import space.kscience.controls.client.DeviceClient
import space.kscience.controls.client.launchMagixService import space.kscience.controls.client.launchMagixService
import space.kscience.controls.client.write
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.ModelConstructor import space.kscience.controls.constructor.ModelConstructor
import space.kscience.controls.constructor.MutableDeviceState import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.onTimer import space.kscience.controls.constructor.onTimer
@ -14,18 +16,38 @@ import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.manager.install import space.kscience.controls.manager.install
import space.kscience.controls.manager.respondMessage import space.kscience.controls.manager.respondMessage
import space.kscience.controls.peer.PeerConnection import space.kscience.controls.peer.PeerConnection
import space.kscience.controls.spec.name
import space.kscience.controls.spec.write
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import space.kscience.dataforge.io.Envelope import space.kscience.dataforge.io.Envelope
import space.kscience.dataforge.io.toByteArray import space.kscience.dataforge.io.toByteArray
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.parseAsName import space.kscience.dataforge.names.parseAsName
import space.kscience.kmath.geometry.degrees
import space.kscience.kmath.geometry.radians
import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.MagixEndpoint
import space.kscience.magix.rsocket.rSocketWithWebSockets import space.kscience.magix.rsocket.rSocketWithWebSockets
import space.kscience.magix.server.startMagixServer import space.kscience.magix.server.startMagixServer
import space.kscience.maps.coordinates.* import space.kscience.maps.coordinates.*
import kotlin.math.PI
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val deviceVelocity = 0.1.kilometers
private val center = Gmc.ofDegrees(55.925, 37.514)
private val radius = 0.01.degrees
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
internal data class CollectiveDeviceState( internal data class CollectiveDeviceState(
val id: CollectiveDeviceId, val id: CollectiveDeviceId,
val configuration: CollectiveDeviceConfiguration, val configuration: CollectiveDeviceConfiguration,
@ -33,7 +55,7 @@ internal data class CollectiveDeviceState(
val velocity: MutableDeviceState<GmcVelocity>, val velocity: MutableDeviceState<GmcVelocity>,
) )
internal fun VirtualDeviceState( internal fun CollectiveDeviceState(
id: CollectiveDeviceId, id: CollectiveDeviceId,
position: Gmc, position: Gmc,
configuration: CollectiveDeviceConfiguration.() -> Unit = {}, configuration: CollectiveDeviceConfiguration.() -> Unit = {},
@ -44,16 +66,11 @@ internal fun VirtualDeviceState(
MutableDeviceState(GmcVelocity.zero) MutableDeviceState(GmcVelocity.zero)
) )
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
internal class DeviceCollectiveModel( internal class DeviceCollectiveModel(
context: Context, context: Context,
val deviceStates: Collection<CollectiveDeviceState>, val deviceStates: Collection<CollectiveDeviceState>,
val visibilityRange: Distance = 0.5.kilometers, val visibilityRange: Distance = 0.5.kilometers,
val radioRange: Distance = 5.kilometers, val radioRange: Distance = 1.kilometers,
) : ModelConstructor(context) { ) : ModelConstructor(context) {
/** /**
@ -78,41 +95,89 @@ internal class DeviceCollectiveModel(
return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange } return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
} }
inner class RadioPeerConnection(private val peerState: CollectiveDeviceState) : PeerConnection { inner class RadioPeerConnectionModel(private val position: DeviceState<Gmc>) : PeerConnection {
override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope? = null override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope? = null
override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) { override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) {
devices.filter { it.value.configuration.radioFrequency == address }.filter { devices.values.filter { it.configuration.radioFrequency == address }.filter {
GeoEllipsoid.WGS84.curveBetween(peerState.position.value, it.value.position.value).distance < radioRange GeoEllipsoid.WGS84.curveBetween(position.value, it.position.value).distance < radioRange
}.forEach { (id, target) -> }.forEach { target ->
check(envelope.data != null) { "Envelope data is empty" } check(envelope.data != null) { "Envelope data is empty" }
val message = json.decodeFromString( val message = json.decodeFromString(
DeviceMessage.serializer(), DeviceMessage.serializer(),
envelope.data?.toByteArray()?.decodeToString() ?: "" envelope.data?.toByteArray()?.decodeToString() ?: ""
) )
target.respondMessage(id.parseAsName(), message) target.respondMessage(target.configuration.deviceId.parseAsName(), message)
} }
} }
} }
val devices = deviceStates.associate { val devices = deviceStates.associate { state ->
val device = CollectiveDeviceConstructor( val device = CollectiveDeviceConstructor(
context = context, context = context,
configuration = it.configuration, configuration = state.configuration,
position = it.position, position = state.position,
velocity = it.velocity, velocity = state.velocity,
peerConnection = RadioPeerConnection(it), peerConnection = RadioPeerConnectionModel(state.position),
) { ) {
locateVisible(it.id) locateVisible(state.id)
} }
it.id to device state.id to device
}
internal fun createTrawler(position: Gmc, id: CollectiveDeviceId = "trawler"): CollectiveDeviceConstructor {
val state = CollectiveDeviceState(
id = id,
configuration = CollectiveDeviceConfiguration(id),
position = MutableDeviceState(position),
velocity = MutableDeviceState(GmcVelocity.zero)
)
val result = CollectiveDeviceConstructor(
context = context,
configuration = state.configuration,
position = state.position,
velocity = state.velocity,
peerConnection = RadioPeerConnectionModel(state.position),
) {
locateVisible(state.id)
}
// TODO move to CollectiveDeviceState
onTimer { prev, next ->
val delta = (next - prev)
state.position.value = state.position.value.moveWith(state.velocity.value, delta)
}
result.onTimer(1.seconds) { _, _ ->
val envelope = Envelope {
data {
writeString(
json.encodeToString(
DeviceMessage.serializer(),
PropertySetMessage(
property = CollectiveDevice.velocity.name,
value = gmcVelocityMetaConverter.convert(state.velocity.value),
targetDevice = null
)
)
)
}
}
result.peerConnection.send(
CollectiveDeviceConfiguration.DEFAULT_FREQUENCY,
envelope
)
}
return result
} }
val roster = deviceStates.associate { it.id to it.configuration } val roster = deviceStates.associate { it.id to it.configuration }
} }
internal fun CoroutineScope.launchCollectiveMagixServer( internal fun CoroutineScope.launchCollectiveMagixServer(
collectiveModel: DeviceCollectiveModel, collectiveModel: DeviceCollectiveModel,
): Job = launch(Dispatchers.IO) { ): Job = launch(Dispatchers.IO) {
@ -134,3 +199,57 @@ internal fun CoroutineScope.launchCollectiveMagixServer(
deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id) deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id)
} }
} }
internal fun generateModel(
context: Context,
size: Int = 50,
reportInterval: Duration = 500.milliseconds,
additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {},
): DeviceCollectiveModel {
val devices: List<CollectiveDeviceState> = List(size) { index ->
val id = "device[$index]"
CollectiveDeviceState(
id = id,
Gmc(
center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble()
)
) {
deviceId = id
description = "Virtual remote device $id"
this.reportInterval = reportInterval.inWholeMilliseconds.toInt()
additionalConfiguration()
}
}
val model = DeviceCollectiveModel(context, devices)
return model
}
fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch {
var bearing = Random.nextDouble(-PI, PI).radians
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) {
delay(500)
bearing += 5.degrees
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
}
}
internal fun CollectiveDeviceConstructor.moveTo(
targetPosition: Gmc,
speedLimit: Distance = deviceVelocity,
scope: CoroutineScope = this,
): Job = scope.launch {
do {
val curve = GeoEllipsoid.WGS84.curveBetween(position.value, targetPosition)
write(CollectiveDevice.velocity, GmcVelocity(curve.forward.bearing, speedLimit))
delay(1.seconds)
} while (curve.distance > 0.1.kilometers)
write(CollectiveDevice.velocity, GmcVelocity.zero)
}

View File

@ -1,58 +0,0 @@
package space.kscience.controls.demo.collective
import kotlinx.coroutines.*
import space.kscience.controls.client.DeviceClient
import space.kscience.controls.client.write
import space.kscience.dataforge.context.Context
import space.kscience.kmath.geometry.degrees
import space.kscience.kmath.geometry.radians
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.kilometers
import kotlin.math.PI
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private val deviceVelocity = 0.1.kilometers
private val center = Gmc.ofDegrees(55.925, 37.514)
private val radius = 0.01.degrees
internal fun generateModel(
context: Context,
size: Int = 50,
reportInterval: Duration = 500.milliseconds,
additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {},
): DeviceCollectiveModel {
val devices: List<CollectiveDeviceState> = List(size) { index ->
val id = "device[$index]"
VirtualDeviceState(
id = id,
Gmc(
center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble()
)
) {
deviceId = id
description = "Virtual remote device $id"
this.reportInterval = reportInterval.inWholeMilliseconds.toInt()
additionalConfiguration()
}
}
val model = DeviceCollectiveModel(context, devices)
return model
}
fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch {
var bearing = Random.nextDouble(-PI, PI).radians
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) {
delay(500)
bearing += 5.degrees
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
}
}

View File

@ -7,16 +7,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button import androidx.compose.material.*
import androidx.compose.material.Card
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -58,7 +56,8 @@ fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}
Context(name, contextBuilder) Context(name, contextBuilder)
} }
private val gmcMetaConverter = MetaConverter.serializable<Gmc>() internal val gmcMetaConverter = MetaConverter.serializable<Gmc>()
internal val gmcVelocityMetaConverter = MetaConverter.serializable<GmcVelocity>()
@Composable @Composable
fun App() { fun App() {
@ -80,7 +79,6 @@ fun App() {
val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() } val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() }
LaunchedEffect(collectiveModel) { LaunchedEffect(collectiveModel) {
launchCollectiveMagixServer(collectiveModel) launchCollectiveMagixServer(collectiveModel)
@ -113,10 +111,27 @@ fun App() {
var movementProgram: Job? by remember { mutableStateOf(null) } var movementProgram: Job? by remember { mutableStateOf(null) }
val trawler: CollectiveDeviceConstructor = remember {
collectiveModel.createTrawler(Gmc.ofDegrees(55.925, 37.50))
}
HorizontalSplitPane( HorizontalSplitPane(
splitPaneState = rememberSplitPaneState(0.9f) splitPaneState = rememberSplitPaneState(0.9f)
) { ) {
first(400.dp) { first(400.dp) {
var clickPoint by remember { mutableStateOf<Gmc?>(null) }
CursorDropdownMenu(clickPoint != null, { clickPoint = null }) {
clickPoint?.let { point ->
TextButton({
trawler.moveTo(point)
clickPoint = null
}) {
Text("Move trawler here")
}
}
}
MapView( MapView(
mapTileProvider = remember { mapTileProvider = remember {
OpenStreetMapTileProvider( OpenStreetMapTileProvider(
@ -124,8 +139,15 @@ fun App() {
cacheDirectory = Path.of("mapCache") cacheDirectory = Path.of("mapCache")
) )
}, },
config = ViewConfig() config = ViewConfig(
onClick = { event, point ->
if (event.buttons.isSecondaryPressed) {
clickPoint = point.focus
}
}
)
) { ) {
//draw real positions
collectiveModel.deviceStates.forEach { device -> collectiveModel.deviceStates.forEach { device ->
circle(device.position.value, id = device.id + ".position").color(Color.Red) circle(device.position.value, id = device.id + ".position").color(Color.Red)
device.position.valueFlow.sample(50.milliseconds).onEach { device.position.valueFlow.sample(50.milliseconds).onEach {
@ -154,8 +176,8 @@ fun App() {
}.launchIn(scope) }.launchIn(scope)
} }
//draw received data
scope.launch { scope.launch {
client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) -> client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") { if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
val id = magixMessage.sourceEndpoint val id = magixMessage.sourceEndpoint
@ -169,6 +191,12 @@ fun App() {
}.launchIn(scope) }.launchIn(scope)
} }
// draw trawler
trawler.position.valueFlow.onEach {
circle(it, id = "trawler").color(Color.Black)
}.launchIn(scope)
} }
} }
second(200.dp) { second(200.dp) {