diff --git a/alchemist-maps/build.gradle.kts b/alchemist-maps/build.gradle.kts index 29c74442dd..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")) 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..0badfb4a67 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt @@ -0,0 +1,66 @@ +/* + * 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 +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 +import java.time.Duration +import java.time.Instant + +/** + * Reads raw AIS NMEA files as Alchemist GPS traces. + */ +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(), date)) + .toTraces() + } + + override fun supportedExtensions(): ImmutableSet = EXTENSIONS +} + +/** + * 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 new file mode 100644 index 0000000000..c6237ce394 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt @@ -0,0 +1,73 @@ +/* + * 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. + */ +object AISDecoder { + /** + * @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> { + 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("${payloadDate}T${time}Z") + null + } + it.isBlank() -> null + else -> { + val vdm = AISMessageParser.parseLine(Vdm(), it) + vdm.takeIf { aisMessage -> aisMessage.isCompletePacket } + ?.let(AISMessageParser::build) + ?.let { message -> currentTimestamp to message } + } + } + } + } + + /** Parses all the raw AIS lines contained in a [File]. + * @param file the [File] from which parse AIS info. + **/ + 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 + ?.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 new file mode 100644 index 0000000000..9d56140bfe --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt @@ -0,0 +1,59 @@ +/* + * 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 +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) + 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 new file mode 100644 index 0000000000..5cbf58abc8 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt @@ -0,0 +1,65 @@ +/* + * 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 +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. + */ +object AISMessageParser { + /** + * Parses a line of a raw message into an AIS sentence. + */ + 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 -> { + 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 + } + }.getOrThrow() + + /** + * @return the [AisMessage] from the read sentence. + */ + fun build(vdm: Vdm): AisMessage? = runCatching { + AisMessage.getInstance(vdm) + }.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 new file mode 100644 index 0000000000..924bf2e634 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt @@ -0,0 +1,91 @@ +/* + * 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.message.AisPositionMessage +import dk.dma.ais.message.AisStaticCommon +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 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. + * @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 speedOverGroundKnots: 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, +) { + val speedOverGroundMetersPerSecond: Double? + get() = speedOverGroundKnots?.times(KNOTS_TO_METERS_PER_SECOND) + + /** + * 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): AISPayload? { + val positionMessage = message as? IPositionMessage ?: return null + val vesselPosition = message.vesselPosition() + 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(), + ) + } + + /** + * Converts timestamped AIS messages to payloads. + */ + fun from(messages: Iterable>): List = messages + .mapNotNull { (timestamp, message) -> from(timestamp, message) } + .sortedWith(compareBy(AISPayload::vesselId, AISPayload::timestamp)) + + private fun AisMessage.vesselPosition(): IVesselPositionMessage? = this as? IVesselPositionMessage + + 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 new file mode 100644 index 0000000000..7acbb789f5 --- /dev/null +++ b/alchemist-maps/src/main/kotlin/it/unibo/alchemist/model/maps/properties/AISComm.kt @@ -0,0 +1,106 @@ +/* + * 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 +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, + 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 retained AIS payloads, from newest to oldest receipt. + */ + val messages: List + get() = receivedPayloads.toList() + + /** + * The most recently received AIS payload. + */ + val latestMessage: AISPayload? + get() = receivedPayloads.firstOrNull() + + /** + * 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 + + /** + * 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.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, 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..f06c82d567 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISGPXConverter.kt @@ -0,0 +1,54 @@ +/* + * 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.time.Instant +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.toFile().deleteRecursively() + } + } + }) + +private fun payloadAt(vesselId: Int, seconds: Long) = AISPayload( + vesselId = vesselId, + timestamp = Instant.EPOCH.plusSeconds(seconds), + longitude = vesselId.toDouble(), + latitude = vesselId.toDouble(), +) 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..78dd834950 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISLoader.kt @@ -0,0 +1,67 @@ +/* + * 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..2a27324f56 --- /dev/null +++ b/alchemist-maps/src/test/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/TestAISPayload.kt @@ -0,0 +1,43 @@ +/* + * 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 speedOverGroundKnots = 10.0 + val payload = AISPayload( + vesselId = 1, + timestamp = Instant.EPOCH, + longitude = 11.0, + latitude = 44.0, + speedOverGroundKnots = speedOverGroundKnots, + ) + payload.speedOverGroundMetersPerSecond shouldBe + speedOverGroundKnots * METERS_IN_NAUTICAL_MILE / SECONDS_PER_HOUR + } + + "AISPayload should keep missing speed unavailable" { + val payload = AISPayload( + vesselId = 1, + timestamp = Instant.EPOCH, + 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 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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6a508e740..db7ac4e128 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.11.0" @@ -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"