From 6fe28a9a2863bc7c3a847e2986a0402eed385b9b Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Wed, 13 May 2026 19:59:13 +0200 Subject: [PATCH 1/7] feat(maps): partial implementation of AIS module --- alchemist-maps/build.gradle.kts | 1 + .../boundary/gps/loaders/AISLoader.kt | 53 +++++++++ .../boundary/gps/loaders/ais/AISDecoder.kt | 44 +++++++ .../gps/loaders/ais/AISGPXConverter.kt | 50 ++++++++ .../gps/loaders/ais/AISMessageParser.kt | 58 ++++++++++ .../boundary/gps/loaders/ais/AISPayload.kt | 70 +++++++++++ .../boundary/gps/loaders/ais/AISProperty.kt | 109 ++++++++++++++++++ .../model/maps/properties/AISComm.kt | 42 +++++++ gradle/libs.versions.toml | 2 + 9 files changed, 429 insertions(+) create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt create mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt diff --git a/alchemist-maps/build.gradle.kts b/alchemist-maps/build.gradle.kts index 29c74442dd..e128b93df0 100644 --- a/alchemist-maps/build.gradle.kts +++ b/alchemist-maps/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(lang3) } implementation(libs.appdirs) + implementation(libs.ais.lib.messages) implementation(libs.boilerplate) implementation(libs.caffeine) implementation(libs.gson) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt new file mode 100644 index 0000000000..2e2ad22ebb --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt @@ -0,0 +1,53 @@ +package it.unibo.alchemist.boundary.gps.loaders + +import com.google.common.collect.ImmutableSet +import it.unibo.alchemist.boundary.gps.GPSFileLoader +import it.unibo.alchemist.boundary.gps.loaders.ais.AISDecoder +import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload +import it.unibo.alchemist.model.maps.GPSTrace +import it.unibo.alchemist.model.maps.positions.GPSPointImpl +import it.unibo.alchemist.model.maps.routes.GPSTraceImpl +import it.unibo.alchemist.model.times.DoubleTime +import java.net.URL + +/** + * Reads raw AIS NMEA files as Alchemist GPS traces. + */ +class AISLoader : GPSFileLoader { + override fun readTrace(url: URL): List = url.openStream().use { input -> + AISPayload + .from(AISDecoder.parsePayload(input.bufferedReader().readText(), dateFrom(url))) + .toTraces() + } + + override fun supportedExtensions(): ImmutableSet = EXTENSIONS + + private fun Iterable.toTraces(): List = groupBy(AISPayload::vesselId) + .values + .map { vesselPayloads -> + GPSTraceImpl( + vesselPayloads + .sortedBy(AISPayload::timestamp) + .map { + GPSPointImpl( + it.latitude, + it.longitude, + DoubleTime(it.timestamp.toEpochMilli() / MILLIS_IN_SECOND), + ) + }, + ) + } + + private companion object { + private val EXTENSIONS = ImmutableSet.of("ais", "nmea", "txt") + private const val MILLIS_IN_SECOND = 1_000.0 + + private fun dateFrom(url: URL): String { + val dateLong = url.path.substringAfterLast("/").substringBefore("-") + val year = dateLong.take(4) + val month = dateLong.drop(4).take(2) + val day = dateLong.takeLast(2) + return "$year-$month-$day" + } + } +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt new file mode 100644 index 0000000000..207a88e2dd --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -0,0 +1,44 @@ +package it.unibo.alchemist.boundary.gps.loaders.ais + +import dk.dma.ais.message.AisMessage +import java.io.File +import java.time.Instant + +/** + * Utility object to decode AIS raw messages. + */ +object AISDecoder { + /** @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. + **/ + fun parsePayload(payload: String, date: String): Map { + val aisMessageBuilder = AISMessageParser() + val payloadDecoded = mutableMapOf() + payload.lines().forEach { + if (it.startsWith(DATE_TIME_PREFIX)) { + val time = it.substringAfter(DATE_TIME_PREFIX).trim() + val currentTimestamp = Instant.parse("${date}T${time}Z") + if (aisMessageBuilder.isComplete()) { + val aisMessage = aisMessageBuilder.build() + if (aisMessage != null) payloadDecoded[currentTimestamp] = aisMessage + } + } else if (it != "") { + aisMessageBuilder.parseLine(it) + } + } + return payloadDecoded + } + + /** Parses all the raw AIS lines contained in a [File]. + * @param file the [File] from which parse AIS info. + **/ + fun parseFile(file: File): Map { + val dateLong = file.name.substringAfterLast("/").substringBefore("-") + val year = dateLong.take(4) + val month = dateLong.drop(4).take(2) + val day = dateLong.takeLast(2) + val date = "$year-$month-$day" + return parsePayload(file.readText(Charsets.UTF_8), date) + } + + private const val DATE_TIME_PREFIX = "!DATE-TIME," +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt new file mode 100644 index 0000000000..b007f0389d --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt @@ -0,0 +1,50 @@ +package it.unibo.alchemist.boundary.gps.loaders.ais + +import io.jenetics.jpx.GPX +import io.jenetics.jpx.Speed +import io.jenetics.jpx.Track +import io.jenetics.jpx.TrackSegment +import io.jenetics.jpx.WayPoint +import java.nio.file.Files +import java.nio.file.Path + +/** + * Converts decoded AIS payloads into one GPX file per vessel. + */ +class AISGPXConverter { + /** + * Writes GPX traces grouped by vessel. + * + * @param payloads AIS payloads to export. + * @param outputDirectory destination directory. + * @param vesselIdMapper mapping used to avoid writing raw MMSIs when anonymization is desired. + */ + fun write(payloads: Iterable, outputDirectory: Path, vesselIdMapper: (Int) -> String = Int::toString) { + Files.createDirectories(outputDirectory) + payloads + .groupBy(AISPayload::vesselId) + .forEach { (vesselId, points) -> + val anonymizedId = vesselIdMapper(vesselId) + val track = Track + .builder() + .name("Vessel $anonymizedId") + .addSegment(TrackSegment.of(points.sortedBy(AISPayload::timestamp).map { it.toWayPoint() })) + .build() + GPX.write( + GPX.builder("Alchemist AIS importer").addTrack(track).build(), + outputDirectory.resolve("$anonymizedId.gpx"), + ) + } + } + + private fun AISPayload.toWayPoint(): WayPoint { + val builder = WayPoint + .builder() + .lat(latitude) + .lon(longitude) + .time(timestamp) + properties[AISProperty.SOG]?.let { builder.speed(it, Speed.Unit.KNOTS) } + properties[AISProperty.COG]?.let { builder.course(it) } + return builder.build() + } +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt new file mode 100644 index 0000000000..8970453280 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt @@ -0,0 +1,58 @@ +package it.unibo.alchemist.boundary.gps.loaders.ais + +import dk.dma.ais.binary.SixbitException +import dk.dma.ais.message.AisMessage +import dk.dma.ais.message.AisMessageException +import dk.dma.ais.sentence.SentenceException +import dk.dma.ais.sentence.Vdm + +/** + * Parser for AIS NMEA VDM sentences. + * + * The AIS library already handles multipart messages using the sequence information + * embedded in the sentence. This builder is more flexible than the library one because it allows to parse messages + * with any subdivisions, without considering the whole stream of messages incoming. + */ +class AISMessageParser { + private var vdm = Vdm() + + /** + * Creates a new instance of the AIS sentence reader. + */ + fun reset() { + vdm = Vdm() + } + + /** + * Parses a line of a raw message into an AIS sentence. + */ + fun parseLine(message: String) { + try { + vdm.parse(message) + } catch (exception: SentenceException) { + when { + exception.message?.contains("Out of sequence sentence:") == true -> reset() + exception.message?.contains("Invalid checksum") == true -> reset() + else -> throw exception + } + } + } + + /** + * @return true if the AIS sentence is complete. + */ + fun isComplete(): Boolean = vdm.isCompletePacket + + /** + * @return the [AisMessage] from the read sentence. + */ + fun build(): AisMessage? = try { + AisMessage.getInstance(vdm) + } catch (exception: AisMessageException) { + null + } catch (exception: SixbitException) { + null + } finally { + reset() + } +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt new file mode 100644 index 0000000000..e6cd43c9db --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -0,0 +1,70 @@ +package it.unibo.alchemist.boundary.gps.loaders.ais + +import dk.dma.ais.message.AisMessage +import dk.dma.ais.message.IPositionMessage +import dk.dma.ais.message.IVesselPositionMessage +import java.time.Instant + +/** + * Subset of AIS information used to generate traces. + * + * @property vesselId AIS MMSI. + * @property timestamp the timestamp related to the receipt of the message. + * @property longitude longitude of the boat. + * @property latitude latitude of the boat. + * @property properties numeric AIS properties. + */ +data class AISPayload( + val vesselId: Int, + val timestamp: Instant, + val longitude: Double, + val latitude: Double, + val properties: Map, +) { + /** + * Static factory for [AISPayload] + */ + companion object { + /** + * Builds an [AISPayload] from an AIS message when it carries a valid position. + */ + fun from( + timestamp: Instant, + message: AisMessage, + properties: Set = AISProperty.DEFAULT, + ): AISPayload? { + val positionMessage = message as? IPositionMessage ?: return null + val longitude = positionMessage.pos.longitudeDouble + val latitude = positionMessage.pos.latitudeDouble + return if (longitude.isValidLongitude() && latitude.isValidLatitude()) { + AISPayload( + vesselId = message.userId, + timestamp = timestamp, + longitude = longitude, + latitude = latitude, + properties = properties.mapNotNull { property -> + property.extract(message)?.let { property to it } + }.toMap(), + ) + } else { + null + } + } + + /** + * Converts timestamped AIS messages to payloads. + */ + fun from( + messages: Map, + properties: Set = AISProperty.DEFAULT, + ): List = messages + .mapNotNull { (timestamp, message) -> from(timestamp, message, properties) } + .sortedWith(compareBy(AISPayload::vesselId, AISPayload::timestamp)) + + internal fun AisMessage.vesselPosition(): IVesselPositionMessage? = this as? IVesselPositionMessage + + private fun Double.isValidLatitude(): Boolean = !isNaN() && this in -90.0..90.0 + + private fun Double.isValidLongitude(): Boolean = !isNaN() && this in -180.0..180.0 + } +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt new file mode 100644 index 0000000000..8023650cc5 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt @@ -0,0 +1,109 @@ +package it.unibo.alchemist.boundary.gps.loaders.ais + +import dk.dma.ais.message.AisMessage +import dk.dma.ais.message.AisMessage5 +import dk.dma.ais.message.AisPositionMessage +import dk.dma.ais.message.AisStaticCommon +import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload.Companion.vesselPosition + +enum class AISProperty { + /** + * Maritime Mobile Service Identity + */ + MMSI, + + /** + * Data timestamp AIS format + */ + TIME, + + /** + * Geographical longitude + */ + LONGITUDE, + + /** + * Geographical latitude + */ + LATITUDE, + + /** + * Speed over ground, expressed in knots. + */ + SOG, + + /** + * Course over ground, expressed in degrees. + */ + COG, + + /** + * Vessel heading, expressed in degrees. + */ + HEADING, + + /** + * Raw AIS position accuracy flag. + */ + PAC, + + /** + * Rate of turn, expressed according to the AIS library conversion. + */ + ROT, + + /** + * Raw AIS navigational status. + */ + NAVSTAT, + + /** + * IMO ship identification number + */ + IMO, + + /** + * Vessel's name + */ + NAME, + + /** + * Vessel's callsign + */ + CALLSIGN, + + /** + * Vessel's type + */ + TYPE, + + /** + * Positioning device type + */ + DEVICE, + + /** + * Raw AIS RAIM flag. + */ + RAIM, + ; + + internal fun extract(message: AisMessage): Double? = when (this) { + MMSI -> message.userId.toDouble() + TYPE -> (message as? AisStaticCommon)?.shipType?.toDouble() + SOG -> message.vesselPosition()?.takeIf { it.isSogValid }?.sog?.div(DIV) + COG -> message.vesselPosition()?.takeIf { it.isCogValid }?.cog?.div(DIV) + HEADING -> message.vesselPosition()?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble() + RAIM -> message.vesselPosition()?.raim?.toDouble() + PAC -> message.vesselPosition()?.posAcc?.toDouble() + ROT -> (message as? AisPositionMessage)?.takeIf { it.isRotValid }?.rot?.toDouble() + NAVSTAT -> (message as? AisPositionMessage)?.navStatus?.toDouble() + IMO -> (message as? AisMessage5)?.imo?.toDouble() + else -> null + } + + companion object { + val DEFAULT: Set = setOf(SOG, COG, HEADING, PAC, ROT, NAVSTAT, RAIM, TYPE) + private const val DIV = 10.0 + } +} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt new file mode 100644 index 0000000000..166885adf5 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -0,0 +1,42 @@ +package it.unibo.alchemist.model.maps.properties + +import dk.dma.ais.message.AisMessage +import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload +import it.unibo.alchemist.boundary.gps.loaders.ais.AISProperty +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.NodeProperty +import it.unibo.alchemist.model.properties.AbstractNodeProperty +import java.time.Instant + +/** + * Minimal AIS communication property. + */ +class AISComm(node: Node) : AbstractNodeProperty(node) { + private val receivedPayloads = mutableListOf() + + val messages: List + get() = receivedPayloads + + val latestMessage: AISPayload? + get() = receivedPayloads.lastOrNull() + + val speedOverGround: Double? + get() = this[AISProperty.SOG] + + val courseOverGround: Double? + get() = this[AISProperty.COG] + + operator fun get(property: AISProperty): Double? = latestMessage?.properties?.get(property) + + fun receive(message: AISPayload) { + receivedPayloads += message + } + + fun receive(timestamp: Instant, message: AisMessage) { + AISPayload.from(timestamp, message)?.let(::receive) + } + + override fun cloneOnNewNode(node: Node): NodeProperty = AISComm(node).also { + it.receivedPayloads += receivedPayloads + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d59b40c240..2588fb8041 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] androidx-lifecycle = "2.10.0" antlr4 = "4.13.2" +ais-lib = "2.8.5" apollo ="4.0.0-beta.7" arrow = "2.2.2.1" compose-multiplatform = "1.10.3" @@ -26,6 +27,7 @@ scalacache = "0.28.0" androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } antlr4 = { module = "org.antlr:antlr4", version.ref = "antlr4" } antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr4" } +ais-lib-messages = { module = "dk.dma.ais.lib:ais-lib-messages", version.ref = "ais-lib" } apache-commons-codec = "commons-codec:commons-codec:1.22.0" apache-commons-collections4 = "org.apache.commons:commons-collections4:4.5.0" apache-commons-io = "commons-io:commons-io:2.22.0" From 8002846b7ab8b3ffe9a763ad2affcbaadca97019 Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Thu, 14 May 2026 11:49:43 +0200 Subject: [PATCH 2/7] refactor(maps): reorganize AIS classes and delete enum --- .../gps/loaders/ais/AISGPXConverter.kt | 4 +- .../boundary/gps/loaders/ais/AISPayload.kt | 49 +++++--- .../boundary/gps/loaders/ais/AISProperty.kt | 109 ------------------ .../model/maps/properties/AISComm.kt | 7 +- 4 files changed, 37 insertions(+), 132 deletions(-) delete mode 100644 alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt index b007f0389d..0b2c194d47 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt @@ -43,8 +43,8 @@ class AISGPXConverter { .lat(latitude) .lon(longitude) .time(timestamp) - properties[AISProperty.SOG]?.let { builder.speed(it, Speed.Unit.KNOTS) } - properties[AISProperty.COG]?.let { builder.course(it) } + sog?.let { builder.speed(it, Speed.Unit.KNOTS) } + cog?.let { builder.course(it) } return builder.build() } } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt index e6cd43c9db..d9f6c18072 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -1,6 +1,8 @@ package it.unibo.alchemist.boundary.gps.loaders.ais import dk.dma.ais.message.AisMessage +import dk.dma.ais.message.AisPositionMessage +import dk.dma.ais.message.AisStaticCommon import dk.dma.ais.message.IPositionMessage import dk.dma.ais.message.IVesselPositionMessage import java.time.Instant @@ -12,14 +14,28 @@ import java.time.Instant * @property timestamp the timestamp related to the receipt of the message. * @property longitude longitude of the boat. * @property latitude latitude of the boat. - * @property properties numeric AIS properties. + * @property sog speed over ground, expressed in knots. + * @property cog course over ground, expressed in degrees. + * @property heading vessel heading, expressed in degrees. + * @property positionAccuracy raw AIS position accuracy flag. + * @property rateOfTurn rate of turn, expressed according to the AIS library conversion. + * @property navigationalStatus raw AIS navigational status. + * @property raim raw AIS RAIM flag. + * @property shipType vessel type. */ data class AISPayload( val vesselId: Int, val timestamp: Instant, val longitude: Double, val latitude: Double, - val properties: Map, + val sog: Double? = null, + val cog: Double? = null, + val heading: Double? = null, + val positionAccuracy: Double? = null, + val rateOfTurn: Double? = null, + val navigationalStatus: Double? = null, + val raim: Double? = null, + val shipType: Double? = null, ) { /** * Static factory for [AISPayload] @@ -28,23 +44,25 @@ data class AISPayload( /** * Builds an [AISPayload] from an AIS message when it carries a valid position. */ - fun from( - timestamp: Instant, - message: AisMessage, - properties: Set = AISProperty.DEFAULT, - ): AISPayload? { + fun from(timestamp: Instant, message: AisMessage): AISPayload? { val positionMessage = message as? IPositionMessage ?: return null val longitude = positionMessage.pos.longitudeDouble val latitude = positionMessage.pos.latitudeDouble + val vesselPosition = message.vesselPosition() return if (longitude.isValidLongitude() && latitude.isValidLatitude()) { AISPayload( vesselId = message.userId, timestamp = timestamp, longitude = longitude, latitude = latitude, - properties = properties.mapNotNull { property -> - property.extract(message)?.let { property to it } - }.toMap(), + sog = vesselPosition?.takeIf { it.isSogValid }?.sog?.div(DIV), + cog = vesselPosition?.takeIf { it.isCogValid }?.cog?.div(DIV), + heading = vesselPosition?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble(), + positionAccuracy = vesselPosition?.posAcc?.toDouble(), + rateOfTurn = (message as? AisPositionMessage)?.takeIf { it.isRotValid }?.rot?.toDouble(), + navigationalStatus = (message as? AisPositionMessage)?.navStatus?.toDouble(), + raim = vesselPosition?.raim?.toDouble(), + shipType = (message as? AisStaticCommon)?.shipType?.toDouble(), ) } else { null @@ -54,17 +72,16 @@ data class AISPayload( /** * Converts timestamped AIS messages to payloads. */ - fun from( - messages: Map, - properties: Set = AISProperty.DEFAULT, - ): List = messages - .mapNotNull { (timestamp, message) -> from(timestamp, message, properties) } + fun from(messages: Map): List = messages + .mapNotNull { (timestamp, message) -> from(timestamp, message) } .sortedWith(compareBy(AISPayload::vesselId, AISPayload::timestamp)) - internal fun AisMessage.vesselPosition(): IVesselPositionMessage? = this as? IVesselPositionMessage + private fun AisMessage.vesselPosition(): IVesselPositionMessage? = this as? IVesselPositionMessage private fun Double.isValidLatitude(): Boolean = !isNaN() && this in -90.0..90.0 private fun Double.isValidLongitude(): Boolean = !isNaN() && this in -180.0..180.0 + + private const val DIV = 10.0 } } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt deleted file mode 100644 index 8023650cc5..0000000000 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISProperty.kt +++ /dev/null @@ -1,109 +0,0 @@ -package it.unibo.alchemist.boundary.gps.loaders.ais - -import dk.dma.ais.message.AisMessage -import dk.dma.ais.message.AisMessage5 -import dk.dma.ais.message.AisPositionMessage -import dk.dma.ais.message.AisStaticCommon -import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload.Companion.vesselPosition - -enum class AISProperty { - /** - * Maritime Mobile Service Identity - */ - MMSI, - - /** - * Data timestamp AIS format - */ - TIME, - - /** - * Geographical longitude - */ - LONGITUDE, - - /** - * Geographical latitude - */ - LATITUDE, - - /** - * Speed over ground, expressed in knots. - */ - SOG, - - /** - * Course over ground, expressed in degrees. - */ - COG, - - /** - * Vessel heading, expressed in degrees. - */ - HEADING, - - /** - * Raw AIS position accuracy flag. - */ - PAC, - - /** - * Rate of turn, expressed according to the AIS library conversion. - */ - ROT, - - /** - * Raw AIS navigational status. - */ - NAVSTAT, - - /** - * IMO ship identification number - */ - IMO, - - /** - * Vessel's name - */ - NAME, - - /** - * Vessel's callsign - */ - CALLSIGN, - - /** - * Vessel's type - */ - TYPE, - - /** - * Positioning device type - */ - DEVICE, - - /** - * Raw AIS RAIM flag. - */ - RAIM, - ; - - internal fun extract(message: AisMessage): Double? = when (this) { - MMSI -> message.userId.toDouble() - TYPE -> (message as? AisStaticCommon)?.shipType?.toDouble() - SOG -> message.vesselPosition()?.takeIf { it.isSogValid }?.sog?.div(DIV) - COG -> message.vesselPosition()?.takeIf { it.isCogValid }?.cog?.div(DIV) - HEADING -> message.vesselPosition()?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble() - RAIM -> message.vesselPosition()?.raim?.toDouble() - PAC -> message.vesselPosition()?.posAcc?.toDouble() - ROT -> (message as? AisPositionMessage)?.takeIf { it.isRotValid }?.rot?.toDouble() - NAVSTAT -> (message as? AisPositionMessage)?.navStatus?.toDouble() - IMO -> (message as? AisMessage5)?.imo?.toDouble() - else -> null - } - - companion object { - val DEFAULT: Set = setOf(SOG, COG, HEADING, PAC, ROT, NAVSTAT, RAIM, TYPE) - private const val DIV = 10.0 - } -} diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt index 166885adf5..001cb9b358 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -2,7 +2,6 @@ package it.unibo.alchemist.model.maps.properties import dk.dma.ais.message.AisMessage import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload -import it.unibo.alchemist.boundary.gps.loaders.ais.AISProperty import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.NodeProperty import it.unibo.alchemist.model.properties.AbstractNodeProperty @@ -21,12 +20,10 @@ class AISComm(node: Node) : AbstractNodeProperty(node) { get() = receivedPayloads.lastOrNull() val speedOverGround: Double? - get() = this[AISProperty.SOG] + get() = latestMessage?.sog val courseOverGround: Double? - get() = this[AISProperty.COG] - - operator fun get(property: AISProperty): Double? = latestMessage?.properties?.get(property) + get() = latestMessage?.cog fun receive(message: AISPayload) { receivedPayloads += message From 1b3366ba1776a0c346b7152a151b7b12b7d9cb1a Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Thu, 14 May 2026 12:53:38 +0200 Subject: [PATCH 3/7] refactor(maps): use List of Pair instead of Map --- .../boundary/gps/loaders/ais/AISDecoder.kt | 17 ++++++++++------- .../boundary/gps/loaders/ais/AISPayload.kt | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt index 207a88e2dd..be1166ab59 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -10,19 +10,22 @@ import java.time.Instant object AISDecoder { /** @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. **/ - fun parsePayload(payload: String, date: String): Map { + fun parsePayload(payload: String, date: String): List> { val aisMessageBuilder = AISMessageParser() - val payloadDecoded = mutableMapOf() + val payloadDecoded = mutableListOf>() + var currentTimestamp: Instant? = null payload.lines().forEach { if (it.startsWith(DATE_TIME_PREFIX)) { val time = it.substringAfter(DATE_TIME_PREFIX).trim() - val currentTimestamp = Instant.parse("${date}T${time}Z") + currentTimestamp = Instant.parse("${date}T${time}Z") + } else if (it != "") { + aisMessageBuilder.parseLine(it) if (aisMessageBuilder.isComplete()) { val aisMessage = aisMessageBuilder.build() - if (aisMessage != null) payloadDecoded[currentTimestamp] = aisMessage + if (aisMessage != null && currentTimestamp != null) { + payloadDecoded += currentTimestamp to aisMessage + } } - } else if (it != "") { - aisMessageBuilder.parseLine(it) } } return payloadDecoded @@ -31,7 +34,7 @@ object AISDecoder { /** Parses all the raw AIS lines contained in a [File]. * @param file the [File] from which parse AIS info. **/ - fun parseFile(file: File): Map { + fun parseFile(file: File): List> { val dateLong = file.name.substringAfterLast("/").substringBefore("-") val year = dateLong.take(4) val month = dateLong.drop(4).take(2) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt index d9f6c18072..f82d188707 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -72,7 +72,7 @@ data class AISPayload( /** * Converts timestamped AIS messages to payloads. */ - fun from(messages: Map): List = messages + fun from(messages: Iterable>): List = messages .mapNotNull { (timestamp, message) -> from(timestamp, message) } .sortedWith(compareBy(AISPayload::vesselId, AISPayload::timestamp)) From dd70e1401823d52f49ded59c1b5a3ac1c94eb070 Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Thu, 14 May 2026 16:39:16 +0200 Subject: [PATCH 4/7] refactor(maps): fix problems in comments and ci/cd --- alchemist-maps/build.gradle.kts | 2 +- .../boundary/gps/loaders/AISLoader.kt | 20 +++--- .../boundary/gps/loaders/ais/AISDecoder.kt | 65 +++++++++++------ .../gps/loaders/ais/AISGPXConverter.kt | 11 ++- .../gps/loaders/ais/AISMessageParser.kt | 71 ++++++++++--------- .../boundary/gps/loaders/ais/AISPayload.kt | 22 ++++-- .../model/maps/properties/AISComm.kt | 27 ++++++- 7 files changed, 145 insertions(+), 73 deletions(-) diff --git a/alchemist-maps/build.gradle.kts b/alchemist-maps/build.gradle.kts index e128b93df0..695bdc9fd5 100644 --- a/alchemist-maps/build.gradle.kts +++ b/alchemist-maps/build.gradle.kts @@ -29,7 +29,7 @@ dependencies { implementation(lang3) } implementation(libs.appdirs) - implementation(libs.ais.lib.messages) + api(libs.ais.lib.messages) implementation(libs.boilerplate) implementation(libs.caffeine) implementation(libs.gson) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt index 2e2ad22ebb..025694f1cd 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.boundary.gps.loaders import com.google.common.collect.ImmutableSet @@ -15,8 +24,9 @@ import java.net.URL */ class AISLoader : GPSFileLoader { override fun readTrace(url: URL): List = url.openStream().use { input -> + val date = AISDecoder.dateFrom(url.path.substringAfterLast("/")) AISPayload - .from(AISDecoder.parsePayload(input.bufferedReader().readText(), dateFrom(url))) + .from(AISDecoder.parsePayload(input.bufferedReader().readText(), date)) .toTraces() } @@ -41,13 +51,5 @@ class AISLoader : GPSFileLoader { private companion object { private val EXTENSIONS = ImmutableSet.of("ais", "nmea", "txt") private const val MILLIS_IN_SECOND = 1_000.0 - - private fun dateFrom(url: URL): String { - val dateLong = url.path.substringAfterLast("/").substringBefore("-") - val year = dateLong.take(4) - val month = dateLong.drop(4).take(2) - val day = dateLong.takeLast(2) - return "$year-$month-$day" - } } } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt index be1166ab59..392fc8b46e 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -1,8 +1,20 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.boundary.gps.loaders.ais import dk.dma.ais.message.AisMessage +import dk.dma.ais.sentence.Vdm import java.io.File import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter /** * Utility object to decode AIS raw messages. @@ -11,37 +23,44 @@ object AISDecoder { /** @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. **/ fun parsePayload(payload: String, date: String): List> { - val aisMessageBuilder = AISMessageParser() - val payloadDecoded = mutableListOf>() - var currentTimestamp: Instant? = null - payload.lines().forEach { - if (it.startsWith(DATE_TIME_PREFIX)) { - val time = it.substringAfter(DATE_TIME_PREFIX).trim() - currentTimestamp = Instant.parse("${date}T${time}Z") - } else if (it != "") { - aisMessageBuilder.parseLine(it) - if (aisMessageBuilder.isComplete()) { - val aisMessage = aisMessageBuilder.build() - if (aisMessage != null && currentTimestamp != null) { - payloadDecoded += currentTimestamp to aisMessage - } + var vdm = Vdm() + var currentTimestamp = Instant.parse("${date}T00:00:00Z") + return payload.lines().mapNotNull { + when { + it.startsWith(DATE_TIME_PREFIX) -> { + val time = it.substringAfter(DATE_TIME_PREFIX).trim() + currentTimestamp = Instant.parse("${date}T${time}Z") + null + } + it.isBlank() -> null + else -> { + vdm = AISMessageParser.parseLine(vdm, it) + vdm.takeIf { aisMessage -> aisMessage.isCompletePacket } + ?.let(AISMessageParser::build) + .also { if (vdm.isCompletePacket) vdm = Vdm() } + ?.let { message -> currentTimestamp to message } } } } - return payloadDecoded } /** Parses all the raw AIS lines contained in a [File]. * @param file the [File] from which parse AIS info. **/ - fun parseFile(file: File): List> { - val dateLong = file.name.substringAfterLast("/").substringBefore("-") - val year = dateLong.take(4) - val month = dateLong.drop(4).take(2) - val day = dateLong.takeLast(2) - val date = "$year-$month-$day" - return parsePayload(file.readText(Charsets.UTF_8), date) - } + fun parseFile(file: File): List> = + parsePayload(file.readText(Charsets.UTF_8), dateFrom(file.name)) + + fun dateFrom(resourceName: String): String = DATE_PATTERN + .find(resourceName) + ?.value + ?.let { date -> + runCatching { + LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE).toString() + }.getOrNull() + } + ?: FALLBACK_DATE private const val DATE_TIME_PREFIX = "!DATE-TIME," + private const val FALLBACK_DATE = "1970-01-01" + private val DATE_PATTERN = Regex("""\d{8}""") } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt index 0b2c194d47..9d56140bfe 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.boundary.gps.loaders.ais import io.jenetics.jpx.GPX @@ -43,7 +52,7 @@ class AISGPXConverter { .lat(latitude) .lon(longitude) .time(timestamp) - sog?.let { builder.speed(it, Speed.Unit.KNOTS) } + speedOverGroundKnots?.let { builder.speed(it, Speed.Unit.KNOTS) } cog?.let { builder.course(it) } return builder.build() } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt index 8970453280..5cbf58abc8 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.boundary.gps.loaders.ais import dk.dma.ais.binary.SixbitException @@ -5,54 +14,52 @@ import dk.dma.ais.message.AisMessage import dk.dma.ais.message.AisMessageException import dk.dma.ais.sentence.SentenceException import dk.dma.ais.sentence.Vdm +import org.slf4j.LoggerFactory /** * Parser for AIS NMEA VDM sentences. * * The AIS library already handles multipart messages using the sequence information - * embedded in the sentence. This builder is more flexible than the library one because it allows to parse messages - * with any subdivisions, without considering the whole stream of messages incoming. + * embedded in the sentence. */ -class AISMessageParser { - private var vdm = Vdm() - - /** - * Creates a new instance of the AIS sentence reader. - */ - fun reset() { - vdm = Vdm() - } - +object AISMessageParser { /** * Parses a line of a raw message into an AIS sentence. */ - fun parseLine(message: String) { - try { - vdm.parse(message) - } catch (exception: SentenceException) { + fun parseLine(vdm: Vdm, message: String): Vdm = runCatching { + vdm.apply { parse(message) } + }.recoverCatching { exception -> + if (exception is SentenceException) { when { - exception.message?.contains("Out of sequence sentence:") == true -> reset() - exception.message?.contains("Invalid checksum") == true -> reset() + exception.message?.contains("Out of sequence sentence:") == true -> { + logger.debug("Resetting partial AIS message after an out-of-sequence sentence", exception) + Vdm() + } + exception.message?.contains("Invalid checksum") == true -> { + logger.debug("Resetting partial AIS message after an invalid checksum", exception) + Vdm() + } else -> throw exception } + } else { + throw exception } - } - - /** - * @return true if the AIS sentence is complete. - */ - fun isComplete(): Boolean = vdm.isCompletePacket + }.getOrThrow() /** * @return the [AisMessage] from the read sentence. */ - fun build(): AisMessage? = try { + fun build(vdm: Vdm): AisMessage? = runCatching { AisMessage.getInstance(vdm) - } catch (exception: AisMessageException) { - null - } catch (exception: SixbitException) { - null - } finally { - reset() - } + }.recoverCatching { exception -> + when (exception) { + is AisMessageException, is SixbitException -> { + logger.debug("Discarding undecodable AIS message", exception) + null + } + else -> throw exception + } + }.getOrThrow() + + private val logger = LoggerFactory.getLogger(AISMessageParser::class.java) } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt index f82d188707..8dea9e3f89 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.boundary.gps.loaders.ais import dk.dma.ais.message.AisMessage @@ -14,7 +23,8 @@ import java.time.Instant * @property timestamp the timestamp related to the receipt of the message. * @property longitude longitude of the boat. * @property latitude latitude of the boat. - * @property sog speed over ground, expressed in knots. + * @property speedOverGroundKnots speed over ground, expressed in knots. + * @property speedOverGroundMetersPerSecond speed over ground, expressed in meters per second. * @property cog course over ground, expressed in degrees. * @property heading vessel heading, expressed in degrees. * @property positionAccuracy raw AIS position accuracy flag. @@ -28,7 +38,7 @@ data class AISPayload( val timestamp: Instant, val longitude: Double, val latitude: Double, - val sog: Double? = null, + val speedOverGroundKnots: Double? = null, val cog: Double? = null, val heading: Double? = null, val positionAccuracy: Double? = null, @@ -37,8 +47,11 @@ data class AISPayload( val raim: Double? = null, val shipType: Double? = null, ) { + val speedOverGroundMetersPerSecond: Double? + get() = speedOverGroundKnots?.times(KNOTS_TO_METERS_PER_SECOND) + /** - * Static factory for [AISPayload] + * Static factory for [AISPayload]. */ companion object { /** @@ -55,7 +68,7 @@ data class AISPayload( timestamp = timestamp, longitude = longitude, latitude = latitude, - sog = vesselPosition?.takeIf { it.isSogValid }?.sog?.div(DIV), + speedOverGroundKnots = vesselPosition?.takeIf { it.isSogValid }?.sog?.div(DIV), cog = vesselPosition?.takeIf { it.isCogValid }?.cog?.div(DIV), heading = vesselPosition?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble(), positionAccuracy = vesselPosition?.posAcc?.toDouble(), @@ -83,5 +96,6 @@ data class AISPayload( private fun Double.isValidLongitude(): Boolean = !isNaN() && this in -180.0..180.0 private const val DIV = 10.0 + private const val KNOTS_TO_METERS_PER_SECOND = 0.5144444444444445 } } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt index 001cb9b358..88830a51a7 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2010-2023, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + package it.unibo.alchemist.model.maps.properties import dk.dma.ais.message.AisMessage @@ -19,9 +28,21 @@ class AISComm(node: Node) : AbstractNodeProperty(node) { val latestMessage: AISPayload? get() = receivedPayloads.lastOrNull() - val speedOverGround: Double? - get() = latestMessage?.sog - + /** + * Speed over ground (in knots) from the latest AIS message. + */ + val speedOverGroundKnots: Double? + get() = latestMessage?.speedOverGroundKnots + + /** + * Speed over ground (in m/s) from the latest AIS message. + */ + val speedOverGroundMetersPerSecond: Double? + get() = latestMessage?.speedOverGroundMetersPerSecond + + /** + * Course over ground (in degrees) from the latest AIS message. + */ val courseOverGround: Double? get() = latestMessage?.cog From 37b01af69f36b3c655294e60b75e448e920f2795 Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Thu, 14 May 2026 18:41:33 +0200 Subject: [PATCH 5/7] refactor(maps): add documentation --- .../it/unibo/alchemist/model/maps/properties/AISComm.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt index 88830a51a7..2c81f4d6f9 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -22,9 +22,15 @@ import java.time.Instant class AISComm(node: Node) : AbstractNodeProperty(node) { private val receivedPayloads = mutableListOf() + /** + * List of all received AIS payloads. + */ val messages: List get() = receivedPayloads + /** + * The most recently received AIS payload. + */ val latestMessage: AISPayload? get() = receivedPayloads.lastOrNull() From 952ed24457cd20974f8abb8b2467828505ca029d Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Fri, 15 May 2026 16:42:00 +0200 Subject: [PATCH 6/7] refactor(maps): fix problems of review and add simple tests --- alchemist-maps/build.gradle.kts | 2 +- .../boundary/gps/loaders/AISLoader.kt | 51 ++++++---- .../boundary/gps/loaders/ais/AISDecoder.kt | 5 + .../boundary/gps/loaders/ais/AISPayload.kt | 38 +++---- .../model/maps/properties/AISComm.kt | 56 +++++++++-- .../gps/loaders/ais/TestAISDecoder.kt | 29 ++++++ .../gps/loaders/ais/TestAISGPXConverter.kt | 66 +++++++++++++ .../boundary/gps/loaders/ais/TestAISLoader.kt | 71 +++++++++++++ .../gps/loaders/ais/TestAISPayload.kt | 40 ++++++++ .../model/maps/properties/TestAISComm.kt | 99 +++++++++++++++++++ 10 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISDecoder.kt create mode 100644 alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt create mode 100644 alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt create mode 100644 alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt create mode 100644 alchemist-maps/src/test/kotlin/it/unibo/alchemist/model/maps/properties/TestAISComm.kt diff --git a/alchemist-maps/build.gradle.kts b/alchemist-maps/build.gradle.kts index 695bdc9fd5..0f2619c24b 100644 --- a/alchemist-maps/build.gradle.kts +++ b/alchemist-maps/build.gradle.kts @@ -17,6 +17,7 @@ plugins { dependencies { ksp(alchemist("factories-generator")) api(alchemist("api")) + api(libs.ais.lib.messages) implementation(alchemist("implementationbase")) implementation(alchemist("loading")) @@ -29,7 +30,6 @@ dependencies { implementation(lang3) } implementation(libs.appdirs) - api(libs.ais.lib.messages) implementation(libs.boilerplate) implementation(libs.caffeine) implementation(libs.gson) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt index 025694f1cd..0badfb4a67 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt @@ -18,6 +18,8 @@ import it.unibo.alchemist.model.maps.positions.GPSPointImpl import it.unibo.alchemist.model.maps.routes.GPSTraceImpl import it.unibo.alchemist.model.times.DoubleTime import java.net.URL +import java.time.Duration +import java.time.Instant /** * Reads raw AIS NMEA files as Alchemist GPS traces. @@ -31,25 +33,34 @@ class AISLoader : GPSFileLoader { } override fun supportedExtensions(): ImmutableSet = EXTENSIONS +} - private fun Iterable.toTraces(): List = groupBy(AISPayload::vesselId) - .values - .map { vesselPayloads -> - GPSTraceImpl( - vesselPayloads - .sortedBy(AISPayload::timestamp) - .map { - GPSPointImpl( - it.latitude, - it.longitude, - DoubleTime(it.timestamp.toEpochMilli() / MILLIS_IN_SECOND), - ) - }, - ) - } - - private companion object { - private val EXTENSIONS = ImmutableSet.of("ais", "nmea", "txt") - private const val MILLIS_IN_SECOND = 1_000.0 +/** + * Converts AIS payloads to GPS traces, preserving epoch-based times by default. + * + * @param timeOrigin instant mapped to simulation time zero. + */ +internal fun Iterable.toTraces(timeOrigin: Instant = Instant.EPOCH): List = groupBy( + AISPayload::vesselId, +) + .values + .map { vesselPayloads -> + GPSTraceImpl( + vesselPayloads + .sortedBy(AISPayload::timestamp) + .map { + GPSPointImpl( + it.latitude, + it.longitude, + it.timestamp.toTraceTime(timeOrigin), + ) + }, + ) } -} + +private fun Instant.toTraceTime(timeOrigin: Instant): DoubleTime = DoubleTime( + Duration.between(timeOrigin, this).toMillis() / MILLIS_IN_SECOND, +) + +private val EXTENSIONS = ImmutableSet.of("ais", "nmea", "txt") +private const val MILLIS_IN_SECOND = 1_000.0 diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt index 392fc8b46e..c904939ffe 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -50,6 +50,11 @@ object AISDecoder { fun parseFile(file: File): List> = parsePayload(file.readText(Charsets.UTF_8), dateFrom(file.name)) + /** + * Extract date from a file name. + * @param resourceName the name of file. + * @return the date or a fallback date. + */ fun dateFrom(resourceName: String): String = DATE_PATTERN .find(resourceName) ?.value diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt index 8dea9e3f89..924bf2e634 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -59,27 +59,21 @@ data class AISPayload( */ fun from(timestamp: Instant, message: AisMessage): AISPayload? { val positionMessage = message as? IPositionMessage ?: return null - val longitude = positionMessage.pos.longitudeDouble - val latitude = positionMessage.pos.latitudeDouble val vesselPosition = message.vesselPosition() - return if (longitude.isValidLongitude() && latitude.isValidLatitude()) { - AISPayload( - vesselId = message.userId, - timestamp = timestamp, - longitude = longitude, - latitude = latitude, - speedOverGroundKnots = vesselPosition?.takeIf { it.isSogValid }?.sog?.div(DIV), - cog = vesselPosition?.takeIf { it.isCogValid }?.cog?.div(DIV), - heading = vesselPosition?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble(), - positionAccuracy = vesselPosition?.posAcc?.toDouble(), - rateOfTurn = (message as? AisPositionMessage)?.takeIf { it.isRotValid }?.rot?.toDouble(), - navigationalStatus = (message as? AisPositionMessage)?.navStatus?.toDouble(), - raim = vesselPosition?.raim?.toDouble(), - shipType = (message as? AisStaticCommon)?.shipType?.toDouble(), - ) - } else { - null - } + return AISPayload( + vesselId = message.userId, + timestamp = timestamp, + longitude = positionMessage.pos.longitudeDouble, + latitude = positionMessage.pos.latitudeDouble, + speedOverGroundKnots = vesselPosition?.takeIf { it.isSogValid }?.sog?.div(DIV), + cog = vesselPosition?.takeIf { it.isCogValid }?.cog?.div(DIV), + heading = vesselPosition?.takeIf { it.isHeadingValid }?.trueHeading?.toDouble(), + positionAccuracy = vesselPosition?.posAcc?.toDouble(), + rateOfTurn = (message as? AisPositionMessage)?.takeIf { it.isRotValid }?.rot?.toDouble(), + navigationalStatus = (message as? AisPositionMessage)?.navStatus?.toDouble(), + raim = vesselPosition?.raim?.toDouble(), + shipType = (message as? AisStaticCommon)?.shipType?.toDouble(), + ) } /** @@ -91,10 +85,6 @@ data class AISPayload( private fun AisMessage.vesselPosition(): IVesselPositionMessage? = this as? IVesselPositionMessage - private fun Double.isValidLatitude(): Boolean = !isNaN() && this in -90.0..90.0 - - private fun Double.isValidLongitude(): Boolean = !isNaN() && this in -180.0..180.0 - private const val DIV = 10.0 private const val KNOTS_TO_METERS_PER_SECOND = 0.5144444444444445 } diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt index 2c81f4d6f9..7acbb789f5 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -14,25 +14,38 @@ import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.NodeProperty import it.unibo.alchemist.model.properties.AbstractNodeProperty +import java.time.Duration import java.time.Instant /** * Minimal AIS communication property. + * + * @param maxSize maximum number of payloads to retain. + * @param validityWindow optional time window for retained payloads. */ -class AISComm(node: Node) : AbstractNodeProperty(node) { - private val receivedPayloads = mutableListOf() +class AISComm( + node: Node, + private val maxSize: Int = Int.MAX_VALUE, + private val validityWindow: Duration? = null, +) : AbstractNodeProperty(node) { + init { + require(maxSize > 0) { "maxSize must be positive" } + require(validityWindow?.isNegative != true) { "validityWindow must not be negative" } + } + + private val receivedPayloads = ArrayDeque() /** - * List of all received AIS payloads. + * List of all retained AIS payloads, from newest to oldest receipt. */ val messages: List - get() = receivedPayloads + get() = receivedPayloads.toList() /** * The most recently received AIS payload. */ val latestMessage: AISPayload? - get() = receivedPayloads.lastOrNull() + get() = receivedPayloads.firstOrNull() /** * Speed over ground (in knots) from the latest AIS message. @@ -52,15 +65,42 @@ class AISComm(node: Node) : AbstractNodeProperty(node) { val courseOverGround: Double? get() = latestMessage?.cog + /** + * Receives an AIS payload and adds it to the list of received messages. + * + * @param message the AIS payload to receive. + */ fun receive(message: AISPayload) { - receivedPayloads += message + receivedPayloads.addFirst(message) + trim() } + /** + * Receives an AIS message with a timestamp, converts it to an AIS payload + * and adds it to the list of received messages. + * + * @param timestamp the timestamp of the message. + * @param message the AIS message to receive. + */ fun receive(timestamp: Instant, message: AisMessage) { AISPayload.from(timestamp, message)?.let(::receive) } - override fun cloneOnNewNode(node: Node): NodeProperty = AISComm(node).also { - it.receivedPayloads += receivedPayloads + override fun cloneOnNewNode(node: Node): NodeProperty = AISComm(node, maxSize, validityWindow).also { + it.receivedPayloads.addAll(receivedPayloads) + } + + private fun trim() { + while (receivedPayloads.size > maxSize) { + receivedPayloads.removeLast() + } + validityWindow?.let { window -> + receivedPayloads + .maxOfOrNull(AISPayload::timestamp) + ?.minus(window) + ?.let { oldestRetainedTimestamp -> + receivedPayloads.removeAll { payload -> payload.timestamp < oldestRetainedTimestamp } + } + } } } diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISDecoder.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISDecoder.kt new file mode 100644 index 0000000000..2dc7b105ec --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISDecoder.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.gps.loaders.ais + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class TestAISDecoder : + StringSpec({ + "AISDecoder should extract dates from resource names" { + AISDecoder.dateFrom("ais-20260515.nmea") shouldBe "2026-05-15" + } + + "AISDecoder should fall back when no valid date is available" { + AISDecoder.dateFrom("ais-without-date.nmea") shouldBe "1970-01-01" + AISDecoder.dateFrom("ais-20261340.nmea") shouldBe "1970-01-01" + } + + "AISDecoder should ignore blank payloads" { + AISDecoder.parsePayload("\n\n", "2026-05-15") shouldBe emptyList() + } + }) diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt new file mode 100644 index 0000000000..df72a08e3e --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.gps.loaders.ais + +import io.jenetics.jpx.GPX +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.Comparator +import java.util.stream.Collectors + +class TestAISGPXConverter : + StringSpec({ + "AISGPXConverter should write one anonymized GPX file per vessel" { + val outputDirectory = Files.createTempDirectory("alchemist-ais-gpx-test") + try { + AISGPXConverter().write( + listOf( + payloadAt(vesselId = 1, seconds = 2), + payloadAt(vesselId = 2, seconds = 1), + payloadAt(vesselId = 1, seconds = 0), + ), + outputDirectory, + ) { vesselId -> "vessel-$vesselId" } + + Files.exists(outputDirectory.resolve("vessel-1.gpx")) shouldBe true + Files.exists(outputDirectory.resolve("vessel-2.gpx")) shouldBe true + Files.exists(outputDirectory.resolve("1.gpx")) shouldBe false + + val exportedTimes = GPX + .read(outputDirectory.resolve("vessel-1.gpx")) + .tracks() + .flatMap { track -> track.segments() } + .flatMap { segment -> segment.points() } + .map { point -> point.time.orElseThrow() } + .collect(Collectors.toList()) + + exportedTimes shouldBe listOf(Instant.EPOCH, Instant.EPOCH.plusSeconds(2)) + } finally { + outputDirectory.deleteRecursively() + } + } + }) + +private fun payloadAt(vesselId: Int, seconds: Long) = AISPayload( + vesselId = vesselId, + timestamp = Instant.EPOCH.plusSeconds(seconds), + longitude = vesselId.toDouble(), + latitude = vesselId.toDouble(), +) + +private fun Path.deleteRecursively() { + Files + .walk(this) + .sorted(Comparator.reverseOrder()) + .forEach(Files::deleteIfExists) +} diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt new file mode 100644 index 0000000000..676f7b39c5 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.gps.loaders.ais + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import it.unibo.alchemist.boundary.gps.loaders.AISLoader +import it.unibo.alchemist.boundary.gps.loaders.toTraces +import java.time.Instant + +class TestAISLoader : + StringSpec({ + "AIS payloads should be converted to epoch-based trace times by default" { + val trace = listOf( + payloadAt(10, vesselId = 1), + payloadAt(12, vesselId = 1), + ).toTraces().single() + + trace.startTime.toDouble() shouldBe 10.0 + trace.finalTime.toDouble() shouldBe 12.0 + } + + "AIS payloads from the same vessel should be ordered by timestamp" { + val trace = listOf( + payloadAt(12, vesselId = 1), + payloadAt(10, vesselId = 1), + ).toTraces().single() + + trace.initialPosition.time.toDouble() shouldBe 10.0 + trace.finalPosition.time.toDouble() shouldBe 12.0 + } + + "AIS payloads should support realignment to a custom time origin" { + val origin = Instant.parse("2026-05-15T12:00:00Z") + val traces = listOf( + payloadAt(origin, 8, vesselId = 2), + payloadAt(origin, 5, vesselId = 1), + payloadAt(origin, 3, vesselId = 2), + ).toTraces(timeOrigin = origin) + + traces + .map { it.startTime.toDouble() to it.finalTime.toDouble() } + .toSet() shouldBe setOf(5.0 to 5.0, 3.0 to 8.0) + } + + "AISLoader should advertise AIS-like file extensions" { + val extensions = AISLoader().supportedExtensions() + + extensions shouldContain "ais" + extensions shouldContain "nmea" + extensions shouldContain "txt" + } + }) + +private fun payloadAt(secondsFromEpoch: Long, vesselId: Int): AISPayload = + payloadAt(Instant.EPOCH, secondsFromEpoch, vesselId) + +private fun payloadAt(origin: Instant, secondsFromOrigin: Long, vesselId: Int): AISPayload = AISPayload( + vesselId = vesselId, + timestamp = origin.plusSeconds(secondsFromOrigin), + longitude = vesselId.toDouble(), + latitude = vesselId.toDouble(), +) diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt new file mode 100644 index 0000000000..4de68243b4 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.gps.loaders.ais + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.Instant + +class TestAISPayload : + StringSpec({ + "AISPayload should convert speed from knots to meters per second" { + val payload = AISPayload( + vesselId = 1, + timestamp = Instant.EPOCH, + longitude = 11.0, + latitude = 44.0, + speedOverGroundKnots = 10.0, + ) + + payload.speedOverGroundMetersPerSecond shouldBe 5.144444444444445 + } + + "AISPayload should keep missing speed unavailable" { + val payload = AISPayload( + vesselId = 1, + timestamp = Instant.EPOCH, + longitude = 11.0, + latitude = 44.0, + ) + + payload.speedOverGroundMetersPerSecond shouldBe null + } + }) diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/model/maps/properties/TestAISComm.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/model/maps/properties/TestAISComm.kt new file mode 100644 index 0000000000..bca8452eb1 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/model/maps/properties/TestAISComm.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.maps.properties + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.mockk +import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload +import it.unibo.alchemist.model.Node +import java.time.Duration +import java.time.Instant + +class TestAISComm : + StringSpec({ + "AISComm should expose retained messages as a LIFO" { + val first = payloadAt(0) + val second = payloadAt(1) + val comm = AISComm(node()) + + comm.receive(first) + comm.receive(second) + + comm.latestMessage shouldBe second + comm.messages shouldContainExactly listOf(second, first) + } + + "AISComm should expose navigation data from the latest message" { + val comm = AISComm(node()) + + comm.receive(payloadAt(0, speedOverGroundKnots = 10.0, cog = 90.0)) + + comm.speedOverGroundKnots shouldBe 10.0 + comm.speedOverGroundMetersPerSecond shouldBe 5.144444444444445 + comm.courseOverGround shouldBe 90.0 + } + + "AISComm should trim retained messages to maxSize" { + val first = payloadAt(0) + val second = payloadAt(1) + val third = payloadAt(2) + val comm = AISComm(node(), maxSize = 2) + + listOf(first, second, third).forEach(comm::receive) + + comm.messages shouldContainExactly listOf(third, second) + } + + "AISComm should discard messages outside the validity window" { + val oldest = payloadAt(0) + val withinWindow = payloadAt(3) + val newest = payloadAt(5) + val lateExpired = payloadAt(1) + val comm = AISComm(node(), validityWindow = Duration.ofSeconds(3)) + + listOf(oldest, withinWindow, newest, lateExpired).forEach(comm::receive) + + comm.messages shouldContainExactly listOf(newest, withinWindow) + } + + "AISComm should clone retained messages and trimming policy" { + val newest = payloadAt(2) + val comm = AISComm(node(), maxSize = 1).apply { + receive(payloadAt(1)) + receive(newest) + } + + val clone = comm.cloneOnNewNode(node()) as AISComm + clone.receive(payloadAt(3)) + + clone.messages.size shouldBe 1 + clone.latestMessage shouldNotBe newest + } + + "AISComm should reject invalid retention settings" { + shouldThrow { AISComm(node(), maxSize = 0) } + shouldThrow { AISComm(node(), validityWindow = Duration.ofSeconds(-1)) } + } + }) + +private fun payloadAt(seconds: Long, speedOverGroundKnots: Double? = null, cog: Double? = null) = AISPayload( + vesselId = seconds.toInt(), + timestamp = Instant.EPOCH.plusSeconds(seconds), + longitude = seconds.toDouble(), + latitude = seconds.toDouble(), + speedOverGroundKnots = speedOverGroundKnots, + cog = cog, +) + +private fun node(): Node = mockk() From f560d7e01d24eb5a08c771fb3feeaba6d36610dc Mon Sep 17 00:00:00 2001 From: Lorenzo Antonioli Date: Tue, 19 May 2026 19:27:38 +0200 Subject: [PATCH 7/7] refactor(maps): fix problems in PR comments --- .../boundary/gps/loaders/ais/AISDecoder.kt | 14 ++++++++------ .../gps/loaders/ais/TestAISGPXConverter.kt | 14 +------------- .../boundary/gps/loaders/ais/TestAISLoader.kt | 4 ---- .../boundary/gps/loaders/ais/TestAISPayload.kt | 11 +++++++---- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt index c904939ffe..c6237ce394 100644 --- a/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -20,24 +20,26 @@ import java.time.format.DateTimeFormatter * Utility object to decode AIS raw messages. */ object AISDecoder { - /** @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. + /** + * @param date the payload date, formatted as an ISO local date (`yyyy-MM-dd`). + * @throws java.time.format.DateTimeParseException if [date] is not a valid ISO local date. + * @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. **/ fun parsePayload(payload: String, date: String): List> { - var vdm = Vdm() - var currentTimestamp = Instant.parse("${date}T00:00:00Z") + val payloadDate = LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE) + var currentTimestamp = Instant.parse("${payloadDate}T00:00:00Z") return payload.lines().mapNotNull { when { it.startsWith(DATE_TIME_PREFIX) -> { val time = it.substringAfter(DATE_TIME_PREFIX).trim() - currentTimestamp = Instant.parse("${date}T${time}Z") + currentTimestamp = Instant.parse("${payloadDate}T${time}Z") null } it.isBlank() -> null else -> { - vdm = AISMessageParser.parseLine(vdm, it) + val vdm = AISMessageParser.parseLine(Vdm(), it) vdm.takeIf { aisMessage -> aisMessage.isCompletePacket } ?.let(AISMessageParser::build) - .also { if (vdm.isCompletePacket) vdm = Vdm() } ?.let { message -> currentTimestamp to message } } } diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt index df72a08e3e..f06c82d567 100644 --- a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt @@ -13,9 +13,7 @@ import io.jenetics.jpx.GPX import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import java.nio.file.Files -import java.nio.file.Path import java.time.Instant -import java.util.Comparator import java.util.stream.Collectors class TestAISGPXConverter : @@ -31,11 +29,9 @@ class TestAISGPXConverter : ), outputDirectory, ) { vesselId -> "vessel-$vesselId" } - Files.exists(outputDirectory.resolve("vessel-1.gpx")) shouldBe true Files.exists(outputDirectory.resolve("vessel-2.gpx")) shouldBe true Files.exists(outputDirectory.resolve("1.gpx")) shouldBe false - val exportedTimes = GPX .read(outputDirectory.resolve("vessel-1.gpx")) .tracks() @@ -43,10 +39,9 @@ class TestAISGPXConverter : .flatMap { segment -> segment.points() } .map { point -> point.time.orElseThrow() } .collect(Collectors.toList()) - exportedTimes shouldBe listOf(Instant.EPOCH, Instant.EPOCH.plusSeconds(2)) } finally { - outputDirectory.deleteRecursively() + outputDirectory.toFile().deleteRecursively() } } }) @@ -57,10 +52,3 @@ private fun payloadAt(vesselId: Int, seconds: Long) = AISPayload( longitude = vesselId.toDouble(), latitude = vesselId.toDouble(), ) - -private fun Path.deleteRecursively() { - Files - .walk(this) - .sorted(Comparator.reverseOrder()) - .forEach(Files::deleteIfExists) -} diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt index 676f7b39c5..78dd834950 100644 --- a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt @@ -23,7 +23,6 @@ class TestAISLoader : payloadAt(10, vesselId = 1), payloadAt(12, vesselId = 1), ).toTraces().single() - trace.startTime.toDouble() shouldBe 10.0 trace.finalTime.toDouble() shouldBe 12.0 } @@ -33,7 +32,6 @@ class TestAISLoader : payloadAt(12, vesselId = 1), payloadAt(10, vesselId = 1), ).toTraces().single() - trace.initialPosition.time.toDouble() shouldBe 10.0 trace.finalPosition.time.toDouble() shouldBe 12.0 } @@ -45,7 +43,6 @@ class TestAISLoader : payloadAt(origin, 5, vesselId = 1), payloadAt(origin, 3, vesselId = 2), ).toTraces(timeOrigin = origin) - traces .map { it.startTime.toDouble() to it.finalTime.toDouble() } .toSet() shouldBe setOf(5.0 to 5.0, 3.0 to 8.0) @@ -53,7 +50,6 @@ class TestAISLoader : "AISLoader should advertise AIS-like file extensions" { val extensions = AISLoader().supportedExtensions() - extensions shouldContain "ais" extensions shouldContain "nmea" extensions shouldContain "txt" diff --git a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt index 4de68243b4..2a27324f56 100644 --- a/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt @@ -16,15 +16,16 @@ import java.time.Instant class TestAISPayload : StringSpec({ "AISPayload should convert speed from knots to meters per second" { + val speedOverGroundKnots = 10.0 val payload = AISPayload( vesselId = 1, timestamp = Instant.EPOCH, longitude = 11.0, latitude = 44.0, - speedOverGroundKnots = 10.0, + speedOverGroundKnots = speedOverGroundKnots, ) - - payload.speedOverGroundMetersPerSecond shouldBe 5.144444444444445 + payload.speedOverGroundMetersPerSecond shouldBe + speedOverGroundKnots * METERS_IN_NAUTICAL_MILE / SECONDS_PER_HOUR } "AISPayload should keep missing speed unavailable" { @@ -34,7 +35,9 @@ class TestAISPayload : longitude = 11.0, latitude = 44.0, ) - payload.speedOverGroundMetersPerSecond shouldBe null } }) + +private const val METERS_IN_NAUTICAL_MILE = 1_852.0 +private const val SECONDS_PER_HOUR = 3_600.0