Skip to content

Commit 5ecf848

Browse files
authored
feat(maps): add native AIS data support and AISVessel NodeProperty (#5369)
1 parent 440a4c1 commit 5ecf848

31 files changed

Lines changed: 6278 additions & 79 deletions

File tree

.idea/jsLibraryMappings.xml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

alchemist-maps/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ plugins {
1717
dependencies {
1818
ksp(alchemist("factories-generator"))
1919
api(alchemist("api"))
20+
api(libs.ais.lib.messages)
2021

2122
implementation(alchemist("implementationbase"))
2223
implementation(alchemist("loading"))
@@ -49,6 +50,10 @@ publishing.publications {
4950
name.set("Andrea Placuzzi")
5051
email.set("andrea.placuzzi@studio.unibo.it")
5152
}
53+
developer {
54+
name.set("Lorenzo Antonioli")
55+
email.set("lorenzo.antonioli2@studio.unibo.it")
56+
}
5257
}
5358
contributors {
5459
contributor {

alchemist-maps/src/main/java/it/unibo/alchemist/model/maps/actions/ReproduceGPSTrace.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,17 @@ public ReproduceGPSTrace(
6060
final String normalizer,
6161
final Object... normalizerArgs
6262
) {
63-
super(environment, node,
64-
new IgnoreStreets<>(),
65-
new StraightLineTraceDependantSpeed<>(environment, node, reaction),
66-
new FollowTrace<>(reaction),
67-
path, cycle, normalizer, normalizerArgs);
63+
super(
64+
environment,
65+
node,
66+
new IgnoreStreets<>(),
67+
new StraightLineTraceDependantSpeed<>(environment, node, reaction),
68+
new FollowTrace<>(reaction),
69+
path,
70+
cycle,
71+
normalizer,
72+
normalizerArgs
73+
);
6874
}
6975

7076
/**

alchemist-maps/src/main/java/it/unibo/alchemist/model/maps/deployments/FromGPSTrace.java

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (C) 2010-2026, Danilo Pianini and contributors
3+
* listed, for each module, in the respective subproject's build.gradle.kts file.
4+
*
5+
* This file is part of Alchemist, and is distributed under the terms of the
6+
* GNU General Public License, with a linking exception,
7+
* as described in the file LICENSE in the Alchemist distribution's top directory.
8+
*/
9+
10+
package it.unibo.alchemist.boundary.gps.loaders
11+
12+
import com.google.common.collect.ImmutableSet
13+
import it.unibo.alchemist.boundary.gps.GPSFileLoader
14+
import it.unibo.alchemist.boundary.gps.loaders.ais.AISDecoder
15+
import it.unibo.alchemist.boundary.gps.loaders.ais.AISPayload
16+
import it.unibo.alchemist.boundary.gps.loaders.ais.AISTrace
17+
import it.unibo.alchemist.model.maps.GPSTrace
18+
import it.unibo.alchemist.model.maps.positions.GPSPointImpl
19+
import it.unibo.alchemist.model.maps.routes.GPSTraceImpl
20+
import it.unibo.alchemist.model.times.DoubleTime
21+
import java.net.URL
22+
import kotlin.time.DurationUnit.SECONDS
23+
import kotlin.time.Instant
24+
import org.openstreetmap.osmosis.osmbinary.file.FileFormatException
25+
26+
/**
27+
* Reads raw AIS NMEA files as Alchemist GPS traces.
28+
*/
29+
class AISLoader : GPSFileLoader {
30+
override fun readTrace(url: URL): List<GPSTrace> = runCatching {
31+
url.openStream().use { input ->
32+
AISPayload.fromTimedMessages(AISDecoder.parsePayload(input.bufferedReader().readText()))
33+
.values
34+
.flatten()
35+
.toTraces()
36+
}
37+
}.getOrElse {
38+
throw FileFormatException("Incorrect AIS Payload in $url").initCause(it)
39+
}
40+
41+
override fun supportedExtensions(): ImmutableSet<String> = EXTENSIONS
42+
43+
/**
44+
* AIS trace conversion helpers.
45+
*/
46+
companion object {
47+
private val EXTENSIONS = ImmutableSet.of("ais", "nmea", "txt")
48+
private val EPOCH = Instant.fromEpochSeconds(0)
49+
50+
/**
51+
* Converts AIS payloads to GPS traces, preserving epoch-based times by default.
52+
*
53+
* @param timeOrigin instant mapped to simulation time zero.
54+
*/
55+
internal fun Iterable<AISPayload>.toTraces(timeOrigin: Instant = EPOCH): List<GPSTrace> = AISTrace
56+
.from(this)
57+
.map { trace -> trace.toTrace(timeOrigin) }
58+
59+
private fun AISTrace.toTrace(timeOrigin: Instant): GPSTrace = GPSTraceImpl(
60+
positionPayloads.asSequence()
61+
.map {
62+
check(it.latitude != null && it.longitude != null)
63+
GPSPointImpl(
64+
it.latitude,
65+
it.longitude,
66+
it.timestamp.toTraceTime(timeOrigin),
67+
)
68+
}
69+
.toList(),
70+
)
71+
72+
private fun Instant.toTraceTime(timeOrigin: Instant): DoubleTime = DoubleTime(
73+
(this - timeOrigin).toDouble(SECONDS),
74+
)
75+
}
76+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (C) 2010-2023, Danilo Pianini and contributors
3+
* listed, for each module, in the respective subproject's build.gradle.kts file.
4+
*
5+
* This file is part of Alchemist, and is distributed under the terms of the
6+
* GNU General Public License, with a linking exception,
7+
* as described in the file LICENSE in the Alchemist distribution's top directory.
8+
*/
9+
10+
package it.unibo.alchemist.boundary.gps.loaders.ais
11+
12+
import dk.dma.ais.binary.SixbitException
13+
import dk.dma.ais.message.AisMessage
14+
import dk.dma.ais.message.AisMessageException
15+
import dk.dma.ais.message.UTCDateResponseMessage
16+
import dk.dma.ais.sentence.SentenceException
17+
import dk.dma.ais.sentence.Vdm
18+
import java.io.File
19+
import java.time.LocalDate
20+
import kotlin.time.Instant
21+
import org.slf4j.LoggerFactory
22+
23+
/**
24+
* Utility object to decode AIS raw messages.
25+
*/
26+
object AISDecoder {
27+
private const val DATE_TIME_PREFIX = "!DATE-TIME,"
28+
private const val DATE_TIME_SEPARATOR = 'T'
29+
private const val FALLBACK_DATE = "1970-01-01"
30+
private const val INVALID_CHECKSUM = "Invalid checksum"
31+
private const val OUT_OF_SEQUENCE_SENTENCE = "Out of sequence sentence:"
32+
33+
private val logger = LoggerFactory.getLogger(AISDecoder::class.java)
34+
35+
/** Parses all the raw AIS lines contained in a [File].
36+
* @param file the [File] from which parse AIS info.
37+
**/
38+
fun parseFile(file: File): List<Pair<Instant, AisMessage>> = parsePayload(file.readText(Charsets.UTF_8))
39+
40+
/**
41+
* @return the messages parsed from a raw [String] to [AisMessage], paired with timestamps obtained from AIS
42+
* source tags, AIS UTC response messages, or in-file `!DATE-TIME` markers when no AIS timestamp is available.
43+
**/
44+
fun parsePayload(payload: String): List<Pair<Instant, AisMessage>> =
45+
parsePayload(payload, startOfDay(FALLBACK_DATE))
46+
47+
/**
48+
* @param date the payload date as an instant. Messages preceding the first explicit timestamp use this instant.
49+
* @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message.
50+
**/
51+
fun parsePayload(payload: String, date: Instant): List<Pair<Instant, AisMessage>> {
52+
var payloadDate = LocalDate.parse(date.toIsoDate())
53+
var currentTimestamp = date
54+
var vdmAccumulator = Vdm()
55+
return buildList {
56+
for (line in payload.lines()) {
57+
when {
58+
line.startsWith(DATE_TIME_PREFIX) -> {
59+
val time = line.substringAfter(DATE_TIME_PREFIX).trim()
60+
var timestamp = Instant.parse("${payloadDate}T${time}Z")
61+
if (timestamp < currentTimestamp) {
62+
payloadDate = payloadDate.plusDays(1)
63+
timestamp = Instant.parse("${payloadDate}T${time}Z")
64+
}
65+
currentTimestamp = timestamp
66+
}
67+
line.isBlank() -> Unit
68+
else -> vdmAccumulator = vdmAccumulator.parseSentence(line) { message ->
69+
val messageTimestamp = message.timestamp ?: currentTimestamp
70+
currentTimestamp = messageTimestamp
71+
add(messageTimestamp to message)
72+
}
73+
}
74+
}
75+
}
76+
}
77+
78+
private fun startOfDay(date: String): Instant = Instant.parse("${date}T00:00:00Z")
79+
80+
private fun Instant.toIsoDate(): String = toString().substringBefore(DATE_TIME_SEPARATOR)
81+
82+
private val AisMessage.timestamp: Instant?
83+
get() = sourceTag?.timestamp?.time?.let(Instant::fromEpochMilliseconds)
84+
?: (this as? UTCDateResponseMessage)?.date?.time?.let(Instant::fromEpochMilliseconds)
85+
86+
private val SentenceException.hasInvalidChecksum: Boolean
87+
get() = message?.contains(INVALID_CHECKSUM) == true
88+
89+
private val SentenceException.isOutOfSequence: Boolean
90+
get() = message?.contains(OUT_OF_SEQUENCE_SENTENCE) == true
91+
92+
private val SentenceException.isRecoverable: Boolean
93+
get() = isOutOfSequence || hasInvalidChecksum
94+
95+
private fun Vdm.parseSentence(line: String, onComplete: (AisMessage) -> Unit): Vdm {
96+
val vdm = runCatching { apply { parse(line) } }.getOrElse { exception ->
97+
if (exception is SentenceException && exception.isRecoverable) {
98+
logger.debug(
99+
"Resetting partial AIS message after an out-of-sequence sentence or invalid checksum",
100+
exception,
101+
)
102+
return Vdm()
103+
} else {
104+
throw exception
105+
}
106+
}
107+
return if (vdm.isCompletePacket) {
108+
vdm.toAisMessage()?.let(onComplete)
109+
Vdm()
110+
} else {
111+
vdm
112+
}
113+
}
114+
115+
private val Throwable.isUndecodableMessage: Boolean
116+
get() = this is AisMessageException || this is SixbitException
117+
118+
private fun Vdm.toAisMessage(): AisMessage? = runCatching {
119+
AisMessage.getInstance(this)
120+
}.getOrElse { exception ->
121+
if (exception.isUndecodableMessage) {
122+
logger.debug("Discarding undecodable AIS message", exception)
123+
null
124+
} else {
125+
throw exception
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)