Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions alchemist-maps/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ plugins {
dependencies {
ksp(alchemist("factories-generator"))
api(alchemist("api"))
api(libs.ais.lib.messages)

implementation(alchemist("implementationbase"))
implementation(alchemist("loading"))
Expand Down
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
Comment thread
lolloantonioli marked this conversation as resolved.

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
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
Comment thread
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}""")
}
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
Comment thread
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()
}
}
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
Comment thread
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)
}
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
Comment thread
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
}
}
Loading
Loading