/* Copyright (c) 2011 Danish Maritime Authority
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this library. If not, see <http://www.gnu.org/licenses/>.
*/
package dk.dma.ais.abnormal.analyzer.analysis;
import com.google.common.eventbus.AllowConcurrentEvents;
import com.google.common.eventbus.Subscribe;
import com.google.inject.Inject;
import dk.dma.ais.abnormal.analyzer.AppStatisticsService;
import dk.dma.ais.abnormal.event.db.EventRepository;
import dk.dma.ais.abnormal.event.db.domain.Event;
import dk.dma.ais.abnormal.event.db.domain.SuddenSpeedChangeEvent;
import dk.dma.ais.abnormal.event.db.domain.TrackingPoint;
import dk.dma.ais.abnormal.event.db.domain.builders.SuddenSpeedChangeEventBuilder;
import dk.dma.ais.abnormal.event.db.domain.builders.TrackingPointBuilder;
import dk.dma.ais.abnormal.util.Categorizer;
import dk.dma.ais.tracker.eventEmittingTracker.EventEmittingTracker;
import dk.dma.ais.tracker.eventEmittingTracker.InterpolatedTrackingReport;
import dk.dma.ais.tracker.eventEmittingTracker.Track;
import dk.dma.ais.tracker.eventEmittingTracker.TrackingReport;
import dk.dma.ais.tracker.eventEmittingTracker.events.PositionChangedEvent;
import dk.dma.ais.tracker.eventEmittingTracker.events.TrackStaleEvent;
import dk.dma.enav.model.geometry.Position;
import org.apache.commons.configuration.Configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_DROP_DECAY;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_DROP_SUSTAIN;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_PREDICTIONTIME_MAX;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SHIPLENGTH_MIN;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SOG_HIGHMARK;
import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SOG_LOWMARK;
import static dk.dma.ais.abnormal.util.AisDataHelper.isSpeedOverGroundAvailable;
import static dk.dma.ais.abnormal.util.AisDataHelper.nameMmsiOrMmsi;
import static dk.dma.ais.abnormal.util.AisDataHelper.nameOrMmsi;
import static dk.dma.ais.abnormal.util.TrackPredicates.isCargoVessel;
import static dk.dma.ais.abnormal.util.TrackPredicates.isClassB;
import static dk.dma.ais.abnormal.util.TrackPredicates.isEngagedInTowing;
import static dk.dma.ais.abnormal.util.TrackPredicates.isFishingVessel;
import static dk.dma.ais.abnormal.util.TrackPredicates.isPassengerVessel;
import static dk.dma.ais.abnormal.util.TrackPredicates.isSpecialCraft;
import static dk.dma.ais.abnormal.util.TrackPredicates.isTankerVessel;
import static dk.dma.ais.abnormal.util.TrackPredicates.isUnknownTypeOrSize;
import static dk.dma.ais.abnormal.util.TrackPredicates.isVeryLongVessel;
import static dk.dma.commons.util.DateTimeUtil.MILLIS_TO_LOCALDATETIME_UTC;
/**
* This analysis manages events representing sudden decrease in speed over ground.
*
* Such a decrease is an indicator for groundings.
*
* A sudden decreasing speed is defined as a speed change going from more
* than 7 knots to less than 1 knot in less than 30 seconds. To avoid false positives,
* the speed must stay below 1 knot for a least 1 minute before an event is raised.
*
* This analysis is not based on previous observations (statistic data).
*
* @author Thomas Borg Salling <tbsalling@tbsalling.dk>
*/
public class SuddenSpeedChangeAnalysis extends Analysis {
private static final Logger LOG = LoggerFactory.getLogger(SuddenSpeedChangeAnalysis.class);
private final AppStatisticsService statisticsService;
/** Track must come from SOG above this value to cause sudden speed change event */
final float SPEED_HIGH_MARK;
/** Track must drop to SOG below this value to cause sudden speed change event */
final float SPEED_LOW_MARK;
/** Track must drop from above SPEED_HIGH_MARK to below SPEED_LOW_MARK in less than this amount of seconds to cause sudden speed change event */
final int SPEED_DECAY_SECS;
/** No. of secs to sustain low speed before raising sudden speed change event */
final long SPEED_SUSTAIN_SECS;
/** Min. length of vessel (in meters) for analysis to be performed */
final int SHIP_LENGTH_MIN;
final float MAX_VALID_SPEED = (float) 102.2;
private TreeSet<Integer> tracksWithSuddenSpeedDecrease = new TreeSet<>();
private int statCount = 0;
@Inject
public SuddenSpeedChangeAnalysis(Configuration configuration, AppStatisticsService statisticsService, EventEmittingTracker trackingService, EventRepository eventRepository) {
super(eventRepository, trackingService, null);
this.statisticsService = statisticsService;
setTrackPredictionTimeMax(configuration.getInteger(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_PREDICTIONTIME_MAX, -1));
SPEED_HIGH_MARK = configuration.getFloat(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SOG_HIGHMARK, 7f);
SPEED_LOW_MARK = configuration.getFloat(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SOG_LOWMARK, 1f);
SPEED_DECAY_SECS = configuration.getInt(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_DROP_DECAY, 30);
SPEED_SUSTAIN_SECS = configuration.getInt(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_DROP_SUSTAIN, 60);
SHIP_LENGTH_MIN = configuration.getInt(CONFKEY_ANALYSIS_SUDDENSPEEDCHANGE_SHIPLENGTH_MIN, 50);
LOG.info(getAnalysisName() + " created (" + this + ").");
}
@Override
public String toString() {
return "SuddenSpeedChangeAnalysis{" +
"SPEED_HIGH_MARK=" + SPEED_HIGH_MARK +
", SPEED_LOW_MARK=" + SPEED_LOW_MARK +
", SPEED_DECAY_SECS=" + SPEED_DECAY_SECS +
", SPEED_SUSTAIN_SECS=" + SPEED_SUSTAIN_SECS +
", SHIP_LENGTH_MIN=" + SHIP_LENGTH_MIN +
"} " + super.toString();
}
@AllowConcurrentEvents
@Subscribe
public void onSpeedOverGroundUpdated(PositionChangedEvent trackEvent) {
Track track = trackEvent.getTrack();
Integer vesselLength = track.getVesselLength();
if (vesselLength != null && vesselLength < SHIP_LENGTH_MIN) {
statisticsService.incAnalysisStatistics(getAnalysisName(), "LOA < " + SHIP_LENGTH_MIN);
return;
}
/* Do not perform analysis if reported speed is invalid */
if (!isSpeedOverGroundAvailable(track.getSpeedOverGround())) {
return;
}
/* Do not perform analysis for vessels with these characteristics: */
if (isClassB.test(track)
|| isUnknownTypeOrSize.test(track)
|| isFishingVessel.test(track)
|| isSpecialCraft.test(track)
|| isEngagedInTowing.test(track)
) {
return;
}
/* Skip analysis if track has been predicted forward for too long */
/* (However: This can never happen for this event ?) */
if (isLastAisTrackingReportTooOld(track, track.getTimeOfLastPositionReport())) {
LOG.debug("Skipping analysis: MMSI " + track.getMmsi() + " was predicted for too long.");
return;
}
/* Perform analysis only for very long vessels and some other vessels: */
if (isVeryLongVessel.test(track) || (isCargoVessel.test(track) || isTankerVessel.test(track) || isPassengerVessel.test(track))) {
performAnalysis(track);
updateApplicationStatistics();
}
}
private void updateApplicationStatistics() {
statisticsService.incAnalysisStatistics(getAnalysisName(), "Analyses performed");
if (statCount++ % 10000 == 0) {
statisticsService.setAnalysisStatistics(getAnalysisName(), "# observation list", tracksWithSuddenSpeedDecrease.size());
}
}
@AllowConcurrentEvents
@Subscribe
public void onTrackStale(TrackStaleEvent trackEvent) {
final int mmsi = trackEvent.getTrack().getMmsi();
if (tracksWithSuddenSpeedDecrease.contains(mmsi)) {
LOG.debug(nameOrMmsi(trackEvent.getTrack().getShipName(), mmsi) + " is now stale. Removed from observation list.");
tracksWithSuddenSpeedDecrease.remove(mmsi);
}
}
private void performAnalysis(Track track) {
final int mmsi = track.getMmsi();
final Float speedOverGround = track.getSpeedOverGround();
if (speedOverGround != null && speedOverGround <= SPEED_LOW_MARK) {
if (!tracksWithSuddenSpeedDecrease.contains(mmsi)) {
if (isSuddenSpeedDecrease(track)) {
LOG.debug(nameOrMmsi(track.getShipName(), mmsi) + " experienced sudden speed decrease. Added to observation list.");
track.setPositionReportPurgeEnable(false);
tracksWithSuddenSpeedDecrease.add(mmsi);
}
} else {
if (isSustainedReportedSpeedDecrease(track) && isSustainedCalculatedSpeedDecrease(track)) {
LOG.debug(nameOrMmsi(track.getShipName(), mmsi) + " experienced sustained speed decrease. Event raised.");
raiseAndLowerSuddenSpeedChangeEvent(track);
tracksWithSuddenSpeedDecrease.remove(mmsi);
}
}
} else {
if (tracksWithSuddenSpeedDecrease.contains(mmsi)) {
LOG.debug(nameOrMmsi(track.getShipName(), mmsi) + " speed above low mark. Removed from observation list.");
tracksWithSuddenSpeedDecrease.remove(mmsi);
track.setPositionReportPurgeEnable(true);
}
}
}
/**
* Evaluate whether this track has kept its speed below SPEED_LOW_MARK
* for a sustained period of at least SPEED_SUSTAIN_SECS seconds.
*
* Based on the vessel's own reported SOG.
*
* @param track
* @return
*/
private boolean isSustainedReportedSpeedDecrease(Track track) {
Optional<Float> maxSog = track.getTrackingReports()
.stream()
.filter(tr -> tr.getTimestamp() >= track.getTimeOfLastPositionReport() - SPEED_SUSTAIN_SECS * 1000)
.map(tr -> Float.valueOf(tr.getSpeedOverGround()))
.max(Comparator.<Float>naturalOrder());
return maxSog.isPresent() ? maxSog.get() <= SPEED_LOW_MARK : false;
}
/**
* Evaluate whether this track has kept its speed below SPEED_LOW_MARK
* for a sustained period of at least SPEED_SUSTAIN_SECS seconds.
*
* Based on calculated SOG from the vessel's reported positions.
*
* @param track
* @return
*/
private boolean isSustainedCalculatedSpeedDecrease(Track track) {
List<TrackingReport> trackingReports = track.getTrackingReports()
.stream()
.filter(tr -> tr.getTimestamp() >= track.getTimeOfLastPositionReport() - SPEED_SUSTAIN_SECS * 1000)
.collect(Collectors.toList());
final int n = trackingReports.size();
boolean calculatedSogsAllBelowLowMark = true;
for (int i=0; i<n-1 && calculatedSogsAllBelowLowMark == true; i++) {
TrackingReport tr1 = trackingReports.get(i);
TrackingReport tr2 = trackingReports.get(i+1);
Position p1 = tr1.getPosition();
Position p2 = tr2.getPosition();
double dp = p2.rhumbLineDistanceTo(p1);
double dt = (tr2.getTimestamp() - tr1.getTimestamp()) / 1e3;
double v = dp/dt; // meters per second
double vKnots = v * 1.9438444924406046; // 1 m/s = 1.9438444924406046 knots
if (vKnots > SPEED_LOW_MARK) {
calculatedSogsAllBelowLowMark = false;
LOG.debug("Calculated sog = " + vKnots + " is larger than " + SPEED_LOW_MARK);
}
}
if (LOG.isDebugEnabled()) {
LOG.debug(nameMmsiOrMmsi(track.getShipName(), track.getMmsi()) + ": " + (calculatedSogsAllBelowLowMark ? "Calculated sog's are all below " + SPEED_LOW_MARK : "Not all calculated sog's are all below " + SPEED_LOW_MARK));
}
return calculatedSogsAllBelowLowMark;
}
/**
* Evaluate whether this track has experienced a "sudden speed decrease" in relation
* to the most recent speed report.
*
* A sudden speed decrease is a drop in SOG from above SPEED_HIGH_MARK to below
* SPEED_LOW_MARK in less than SPEED_DECAY_SECS seconds.
*
* @param track
* @return
*/
private boolean isSuddenSpeedDecrease(Track track) {
if (track.getSpeedOverGround() > SPEED_LOW_MARK) {
return false;
}
long t1 = timeOfLastTrackingReportAboveHighMark(track.getTrackingReports());
long t2 = track.getTimeOfLastPositionReport();
return t1 >= 0 && (t2 - t1) <= SPEED_DECAY_SECS *1000;
}
private long timeOfLastTrackingReportAboveHighMark(List<TrackingReport> trackingReports) {
Optional<Long> t = trackingReports
.stream()
.filter(tr -> tr.getSpeedOverGround() <= MAX_VALID_SPEED)
.filter(tr -> tr.getSpeedOverGround() >= SPEED_HIGH_MARK)
.map(tr -> tr.getTimestamp())
.max(Comparator.<Long>naturalOrder());
return t.isPresent() ? t.get() : -1;
}
private long timeOfFirstTrackingReportBelowLowMark(List<TrackingReport> trackingReports, long t1) {
Optional<Long> t = trackingReports
.stream()
.filter(tr -> tr.getTimestamp() >= t1)
.filter(tr -> tr.getSpeedOverGround() <= SPEED_LOW_MARK)
.map(tr -> Long.valueOf(tr.getTimestamp()))
.min(Comparator.<Long>naturalOrder());
return t.isPresent() ? t.get() : -1;
}
@Override
protected Event buildEvent(Track track, Track... otherTracks) {
if (otherTracks != null && otherTracks.length > 0) {
throw new IllegalArgumentException("otherTracks not supported.");
}
// Static
Integer mmsi = track.getMmsi();
Integer imo = track.getIMO();
String callsign = track.getCallsign();
String name = nameOrMmsi(track.getShipName(), track.getMmsi());
Integer shipType = track.getShipType();
Integer shipDimensionToBow = track.getShipDimensionBow();
Integer shipDimensionToStern = track.getShipDimensionStern();
Integer shipDimensionToPort = track.getShipDimensionPort();
Integer shipDimensionToStarboard = track.getShipDimensionStarboard();
String shipTypeAsString = "unknown type";
short shipTypeCategory = Categorizer.mapShipTypeToCategory(shipType);
if (shipType != null) {
shipTypeAsString = Categorizer.mapShipTypeCategoryToString(shipTypeCategory);
shipTypeAsString = shipTypeAsString.substring(0, 1).toUpperCase() + shipTypeAsString.substring(1);
}
List<TrackingReport> trackingReports = track.getTrackingReports();
long t1 = timeOfLastTrackingReportAboveHighMark(trackingReports);
long t2 = timeOfFirstTrackingReportBelowLowMark(trackingReports, t1);
// long t3 = track.getTimeOfLastPositionReport();
float deltaSecs = (float) ((t2 - t1) / 1000.0);
TrackingReport trackingReportAtT1 = track.getTrackingReportAt(t1);
Position position1 = trackingReportAtT1.getPosition();
Float cog1 = trackingReportAtT1.getCourseOverGround();
Float sog1 = trackingReportAtT1.getSpeedOverGround();
Float hdg1 = trackingReportAtT1.getTrueHeading();
Boolean interpolated1 = trackingReportAtT1 instanceof InterpolatedTrackingReport;
TrackingReport trackingReportAtT2 = track.getTrackingReportAt(t2);
Position position2 = trackingReportAtT2.getPosition();
Float cog2 = trackingReportAtT2.getCourseOverGround();
Float sog2 = trackingReportAtT2.getSpeedOverGround();
Float hdg2 = trackingReportAtT2.getTrueHeading();
Boolean interpolated2 = trackingReportAtT2 instanceof InterpolatedTrackingReport;
String title = "Sudden speed change";
String description = String.format("Sudden speed change of %s (%s) on position %s at %s: From %.1f kts to %.1f kts in %.1f secs.",
name,
shipTypeAsString,
position1,
DATE_FORMAT.format(MILLIS_TO_LOCALDATETIME_UTC.apply(t1)),
sog1, sog2, deltaSecs);
LOG.info(description);
Event event =
SuddenSpeedChangeEventBuilder.SuddenSpeedChangeEvent()
.title(title)
.description(description)
.state(Event.State.PAST)
.startTime(MILLIS_TO_LOCALDATETIME_UTC.apply(t1))
.endTime(MILLIS_TO_LOCALDATETIME_UTC.apply(t2))
.behaviour()
.isPrimary(true)
.vessel()
.mmsi(mmsi)
.imo(imo)
.callsign(callsign)
.type(shipType)
.toBow(shipDimensionToBow)
.toStern(shipDimensionToStern)
.toPort(shipDimensionToPort)
.toStarboard(shipDimensionToStarboard)
.name(name)
.trackingPoint()
.timestamp(MILLIS_TO_LOCALDATETIME_UTC.apply(t1))
.positionInterpolated(interpolated1)
.eventCertainty(TrackingPoint.EventCertainty.RAISED)
.speedOverGround(sog1)
.courseOverGround(cog1)
.trueHeading(hdg1)
.latitude(position1.getLatitude())
.longitude(position1.getLongitude())
.getEvent();
event.getBehaviour(mmsi).addTrackingPoint(
TrackingPointBuilder.TrackingPoint()
.timestamp(MILLIS_TO_LOCALDATETIME_UTC.apply(t2))
.positionInterpolated(interpolated2)
.eventCertainty(TrackingPoint.EventCertainty.RAISED)
.speedOverGround(sog2)
.courseOverGround(cog2)
.trueHeading(hdg2)
.latitude(position2.getLatitude())
.longitude(position2.getLongitude())
.getTrackingPoint());
addPreviousTrackingPoints(event, track);
statisticsService.incAnalysisStatistics(getAnalysisName(), "Events raised");
return event;
}
private void raiseAndLowerSuddenSpeedChangeEvent(Track track) {
raiseOrMaintainAbnormalEvent(SuddenSpeedChangeEvent.class, track);
lowerExistingAbnormalEventIfExists(SuddenSpeedChangeEvent.class, track);
}
}