-
Notifications
You must be signed in to change notification settings - Fork 49
feat(maps): add native AIS data support and AISComm NodeProperty #5353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lolloantonioli
wants to merge
6
commits into
AlchemistSimulator:master
Choose a base branch
from
lolloantonioli:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+766
−0
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6fe28a9
feat(maps): partial implementation of AIS module
lolloantonioli 8002846
refactor(maps): reorganize AIS classes and delete enum
lolloantonioli 1b3366b
refactor(maps): use List of Pair instead of Map
lolloantonioli dd70e14
refactor(maps): fix problems in comments and ci/cd
lolloantonioli 37b01af
refactor(maps): add documentation
lolloantonioli 952ed24
refactor(maps): fix problems of review and add simple tests
lolloantonioli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/AISLoader.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GPSTrace> = url.openStream().use { input -> | ||
| val date = AISDecoder.dateFrom(url.path.substringAfterLast("/")) | ||
| AISPayload | ||
| .from(AISDecoder.parsePayload(input.bufferedReader().readText(), date)) | ||
| .toTraces() | ||
| } | ||
|
|
||
| override fun supportedExtensions(): ImmutableSet<String> = EXTENSIONS | ||
| } | ||
|
|
||
| /** | ||
| * Converts AIS payloads to GPS traces, preserving epoch-based times by default. | ||
| * | ||
| * @param timeOrigin instant mapped to simulation time zero. | ||
| */ | ||
| internal fun Iterable<AISPayload>.toTraces(timeOrigin: Instant = Instant.EPOCH): List<GPSTrace> = 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 | ||
71 changes: 71 additions & 0 deletions
71
alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISDecoder.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| /* | ||
| * 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 | ||
|
lolloantonioli marked this conversation as resolved.
|
||
|
|
||
| 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 { | ||
| /** @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<Pair<Instant, 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 } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Parses all the raw AIS lines contained in a [File]. | ||
| * @param file the [File] from which parse AIS info. | ||
| **/ | ||
| fun parseFile(file: File): List<Pair<Instant, AisMessage>> = | ||
| 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}""") | ||
| } | ||
59 changes: 59 additions & 0 deletions
59
...emist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISGPXConverter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
lolloantonioli marked this conversation as resolved.
|
||
|
|
||
| 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<AISPayload>, 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() | ||
| } | ||
| } | ||
65 changes: 65 additions & 0 deletions
65
...mist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISMessageParser.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
lolloantonioli marked this conversation as resolved.
|
||
|
|
||
| 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) | ||
| } | ||
91 changes: 91 additions & 0 deletions
91
alchemist-maps/src/main/kotlin/it/unibo/alchemist/boundary/gps/loaders/ais/AISPayload.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
lolloantonioli marked this conversation as resolved.
|
||
|
|
||
| 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<Pair<Instant, AisMessage>>): List<AISPayload> = 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 | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.