/* 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.collect.Lists; import com.google.inject.Inject; import com.google.inject.Singleton; 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.tracker.eventEmittingTracker.EventEmittingTracker; import dk.dma.ais.tracker.eventEmittingTracker.Track; import dk.dma.enav.model.geometry.BoundingBox; import dk.dma.enav.model.geometry.CoordinateSystem; import dk.dma.enav.model.geometry.Ellipse; import dk.dma.enav.model.geometry.Position; import dk.dma.enav.util.CoordinateConverter; import dk.dma.enav.util.geometry.Point; import net.jcip.annotations.NotThreadSafe; import org.apache.commons.configuration.Configuration; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.concurrent.GuardedBy; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_BBOX; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_CSVFILE; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_DCOG; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_MIN_REPORTING_PERIOD_MINUTES; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_PREDICTIONTIME_MAX; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_RUN_PERIOD; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_XB; import static dk.dma.ais.abnormal.analyzer.config.Configuration.CONFKEY_ANALYSIS_FREEFLOW_XL; import static dk.dma.ais.abnormal.util.AisDataHelper.trimAisString; import static dk.dma.ais.abnormal.util.TrackPredicates.isCargoVessel; import static dk.dma.ais.abnormal.util.TrackPredicates.isTankerVessel; import static dk.dma.ais.abnormal.util.TrackPredicates.isVeryLongVessel; import static dk.dma.enav.safety.SafetyZones.createEllipse; import static dk.dma.enav.util.compass.CompassUtils.absoluteDirectionalDifference; import static dk.dma.enav.util.compass.CompassUtils.compass2cartesian; import static java.lang.System.nanoTime; import static org.apache.commons.lang.StringUtils.isBlank; /** * This analysis analyses "free flow" of vessels in given areas. * * An area is said to have free flow, if no large vessels have similar vessels sailing in the * appoximate same direction close by. "Close by" is defined as an ellipse around the vessel. * * @author Thomas Borg Salling <tbsalling@tbsalling.dk> */ @NotThreadSafe @Singleton public class FreeFlowAnalysis extends PeriodicAnalysis { private static final Logger LOG = LoggerFactory.getLogger(FreeFlowAnalysis.class); private final AppStatisticsService statisticsService; private BoundingBox areaToBeAnalysed = null; /** Major axis of ellipse is xL times vessel's length-over-all */ private final int xL; /** Minor axis of ellipse is xB times vessel's beam */ private final int xB; /** Vessels' courses over ground must be within this no. of degrees to be paired in analysis */ private final float dCog; /** A vessel pair can only be reported this often */ private final int minReportingIntervalMillis; /** Name of the CSV file to which freeflow events will be appended */ private final String csvFileName; @Inject public FreeFlowAnalysis(Configuration configuration, AppStatisticsService statisticsService, EventEmittingTracker trackingService, EventRepository eventRepository) { super(eventRepository, trackingService, null); this.statisticsService = statisticsService; this.xL = configuration.getInt(CONFKEY_ANALYSIS_FREEFLOW_XL, 8); this.xB = configuration.getInt(CONFKEY_ANALYSIS_FREEFLOW_XB, 8); this.dCog = configuration.getFloat(CONFKEY_ANALYSIS_FREEFLOW_DCOG, 15f); this.minReportingIntervalMillis = configuration.getInt(CONFKEY_ANALYSIS_FREEFLOW_MIN_REPORTING_PERIOD_MINUTES, 60) * 60 * 1000; String csvFileNameTmp = configuration.getString(CONFKEY_ANALYSIS_FREEFLOW_CSVFILE, null); if (csvFileNameTmp == null || isBlank(csvFileNameTmp)) { this.csvFileName = null; LOG.warn("Writing of free flow events to CSV file is disabled"); } else { this.csvFileName = csvFileNameTmp.trim(); LOG.info("Free flow events are appended to CSV file: " + this.csvFileName); } List<Object> bboxConfig = configuration.getList(CONFKEY_ANALYSIS_FREEFLOW_BBOX); if (bboxConfig != null) { final double n = Double.valueOf(bboxConfig.get(0).toString()); final double e = Double.valueOf(bboxConfig.get(1).toString()); final double s = Double.valueOf(bboxConfig.get(2).toString()); final double w = Double.valueOf(bboxConfig.get(3).toString()); this.areaToBeAnalysed = BoundingBox.create(Position.create(n, e), Position.create(s, w), CoordinateSystem.CARTESIAN); } setTrackPredictionTimeMax(configuration.getInteger(CONFKEY_ANALYSIS_FREEFLOW_PREDICTIONTIME_MAX, -1)); setAnalysisPeriodMillis(configuration.getInt(CONFKEY_ANALYSIS_FREEFLOW_RUN_PERIOD, 30000) * 1000); LOG.info(this.getClass().getSimpleName() + " created (" + this + ")."); } @Override public String toString() { return "FreeFlowAnalysis{" + "areaToBeAnalysed=" + areaToBeAnalysed + ", xL=" + xL + ", xB=" + xB + ", dCog=" + dCog + ", minReportingIntervalMillis=" + minReportingIntervalMillis + "} " + super.toString(); } protected void performAnalysis() { LOG.debug("Starting " + getAnalysisName() + " " + getCurrentRunTime()); final long systemTimeNanosBeforeAnalysis = nanoTime(); Collection<Track> allTracks = getTrackingService().getTracks(); List<Track> allRelevantTracksPredictedToNow = allTracks .stream() .filter(this::isVesselTypeToBeAnalysed) .filter(this::isInsideAreaToBeAnalysed) .filter(this::isMinimumSpeedOverGround) .map(this::predictToCurrentTime) .filter(this::isPredictedToCurrentTime) .collect(Collectors.toList()); analyseFreeFlow(allRelevantTracksPredictedToNow); statisticsService.incAnalysisStatistics(getAnalysisName(), "Analyses performed"); final long systemTimeNanosAfterAnalysis = nanoTime(); LOG.debug(getAnalysisName() + " of " + allTracks.size() + " tracks completed in " + (systemTimeNanosAfterAnalysis - systemTimeNanosBeforeAnalysis) + " nsecs."); } private void analyseFreeFlow(Collection<Track> tracks) { LOG.debug("Performing analysis of " + tracks.size() + " tracks"); final long t0 = nanoTime(); tracks .stream() .forEach(t -> analyseFreeFlow(tracks, t)); final long t1 = nanoTime(); LOG.debug("Analysis performed in " + (t1-t0)/1000 + " msecs"); } private void analyseFreeFlow(Collection<Track> allTracks, Track t0) { LOG.debug("Performing free flow analysis of " + t0.getMmsi()); final float cog0 = t0.getCourseOverGround(); final Position pc0 = centerOfVessel(t0.getPosition(), t0.getTrueHeading(), t0.getShipDimensionStern(), t0.getShipDimensionBow(), t0.getShipDimensionPort(), t0.getShipDimensionStarboard()); Set<Track> tracksSailingSameDirection = allTracks .stream() .filter(t -> t.getMmsi() != t0.getMmsi()) .filter(t -> !isLastAisTrackingReportTooOld(t, t.getTimeOfLastPositionReport())) .filter(t -> absoluteDirectionalDifference(cog0, t.getCourseOverGround()) < dCog) .collect(Collectors.toSet()); if (tracksSailingSameDirection.size() > 0) { Ellipse ellipse = createEllipse( pc0, pc0, cog0, t0.getVesselLength(), t0.getVesselBeam(), t0.getShipDimensionStern(), t0.getShipDimensionStarboard(), xL, xB, 1 ); LOG.debug("ellipse: " + ellipse); List<Track> tracksSailingSameDirectionAndContainedInEllipse = tracksSailingSameDirection .stream() .filter(t -> ellipse.contains(centerOfVessel(t.getPosition(), t.getTrueHeading(), t.getShipDimensionStern(), t.getShipDimensionBow(), t.getShipDimensionPort(), t.getShipDimensionStarboard()))) .collect(Collectors.toList()); if (tracksSailingSameDirectionAndContainedInEllipse.size() > 0) { LOG.debug("There are " + tracksSailingSameDirectionAndContainedInEllipse.size() + " tracks inside ellipse of " + t0.getMmsi() + " " + t0.getShipName()); LOG.debug(new DateTime(t0.getTimeOfLastPositionReport()) + " " + "MMSI " + t0.getMmsi() + " " + t0.getShipName() + " " + t0.getShipType()); List<FreeFlowData.TrackInsideEllipse> tracksInsideEllipse = Lists.newArrayList(); for (Track t1 : tracksSailingSameDirectionAndContainedInEllipse) { if (! reportedRecently(t0, t1, t0.getTimeOfLastPositionReport())) { final Position pc1 = centerOfVessel(t1.getPosition(), t1.getTrueHeading(), t1.getShipDimensionStern(), t1.getShipDimensionBow(), t1.getShipDimensionPort(), t1.getShipDimensionStarboard()); try { tracksInsideEllipse.add(new FreeFlowData.TrackInsideEllipse(t1.clone(), pc1)); markReported(t0, t1, t0.getTimeOfLastPositionReport()); } catch (CloneNotSupportedException e) { LOG.error(e.getMessage(), e); } } } if (tracksInsideEllipse.size() > 0) { try { writeToCSVFile(new FreeFlowData(t0.clone(), pc0, tracksInsideEllipse)); } catch (CloneNotSupportedException e) { LOG.error(e.getMessage(), e); } } else { LOG.debug("Nothing new to report."); } } } } private Map<String, Long> reported = new HashMap<>(); private void markReported(Track t0, Track t1, long timestamp) { String key = String.valueOf(t0.getMmsi()) + "/" + String.valueOf(t1.getMmsi()); reported.put(key, timestamp); } private boolean reportedRecently(Track t0, Track t1, long timestamp) { String key = String.valueOf(t0.getMmsi()) + "/" + String.valueOf(t1.getMmsi()); Long lastReport = reported.get(key); return lastReport != null && timestamp-lastReport < minReportingIntervalMillis; } private boolean isVesselTypeToBeAnalysed(Track track) { return isVeryLongVessel.test(track) && (isTankerVessel.test(track) || isCargoVessel.test(track)); } private boolean isInsideAreaToBeAnalysed(Track track) { Position position = track.getPosition(); return position != null && areaToBeAnalysed != null && areaToBeAnalysed.contains(position); } private boolean isMinimumSpeedOverGround(Track track) { Float speedOverGround = track.getSpeedOverGround(); return speedOverGround == null || speedOverGround >= 1f; } private boolean isPredictedToCurrentTime(Track track) { return track.getTimeOfLastPositionReport() == getCurrentRunTime(); } private Track predictToCurrentTime(Track track) { // TODO clone track if (track.getTimeOfLastPositionReport() < getCurrentRunTime()) { try { track.predict(getCurrentRunTime()); } catch (IllegalStateException e) { // java.lang.IllegalStateException: No enough data to predict future position. } } return track; } /** * Given an AIS position, the vessel's heading and dimensions from position sensor to bow, stern, starboard, and port * compute the vessel's center point. * * @param aisPosition * @param hdg * @param dimStern * @param dimBow * @param dimPort * @param dimStarboard * @return */ static Position centerOfVessel(Position aisPosition, float hdg, int dimStern, int dimBow, int dimPort, int dimStarboard) { // Compute direction of half axis alpha final double thetaDeg = compass2cartesian(hdg); // Transform latitude/longitude to cartesian coordinates final Position geodeticReference = aisPosition; final CoordinateConverter coordinateConverter = new CoordinateConverter(geodeticReference.getLongitude(), geodeticReference.getLatitude()); final double trackLatitude = aisPosition.getLatitude(); final double trackLongitude = aisPosition.getLongitude(); final double x = coordinateConverter.lon2x(trackLongitude, trackLatitude); final double y = coordinateConverter.lat2y(trackLongitude, trackLatitude); // Cartesion point of AIS position final Point pAis = new Point(x, y); // Compute cartesian center of vessel final Point pc = new Point((dimBow + dimStern)/2 - dimStern, (dimPort+dimStarboard)/2 - dimStarboard); // Rotate to comply with hdg final Point pcr = pc.rotate(pAis, thetaDeg); // Convert back to geodesic coordinates return Position.create(coordinateConverter.y2Lat(pcr.getX(), pcr.getY()), coordinateConverter.x2Lon(pcr.getX(), pcr.getY())); } @Override protected Event buildEvent(Track primaryTrack, Track... otherTracks) { return null; } private ReentrantLock lock = new ReentrantLock(); @GuardedBy("lock") private FileWriter fileWriter = null; @GuardedBy("lock") private CSVPrinter csvFilePrinter = null; DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"); private void writeToCSVFile(FreeFlowData freeFlowData) { if (csvFileName == null) return; final File csvFile = new File(csvFileName); final boolean fileExists = csvFile.exists() == true || csvFile.length() > 0; lock.lock(); if (!fileExists) { try { if (csvFilePrinter != null) csvFilePrinter.close(); if (fileWriter != null) fileWriter.close(); } catch (IOException e) { LOG.warn(e.getMessage(), e); } csvFilePrinter = null; fileWriter = null; } try { if (fileWriter == null) { try { fileWriter = new FileWriter(csvFile, true); if (csvFilePrinter == null) { try { csvFilePrinter = new CSVPrinter(fileWriter, CSVFormat.RFC4180.withCommentMarker('#')); } catch (IOException e) { csvFilePrinter = null; LOG.error(e.getMessage(), e); LOG.error("Failed to write line to CSV file: " + freeFlowData); return; } } } catch (IOException e) { fileWriter = null; LOG.error(e.getMessage(), e); LOG.error("Failed to write line to CSV file: " + freeFlowData); return; } } if (!fileExists) { LOG.info("Created new CSV file: " + csvFile.getAbsolutePath()); csvFilePrinter.printComment("Generated by AIS Abnormal Behaviour Analyzer"); csvFilePrinter.printComment("File created: " + LocalDateTime.now().format(fmt)); csvFilePrinter.printRecord("TIMESTAMP (GMT)", "MMSI1", "NAME1", "TP1", "LOA1", "BM1", "COG1", "HDG1", "SOG1", "LAT1", "LON1", "MMSI2", "NAME2", "TP2", "LOA2", "BM2", "COG2", "HDG2", "SOG2", "LAT2", "LON2", "BRG", "DST"); } final Track t0 = freeFlowData.getTrackSnapshot(); final Position p0 = freeFlowData.getTrackCenterPosition(); List<FreeFlowData.TrackInsideEllipse> tracks = freeFlowData.getTracksInsideEllipse(); for (FreeFlowData.TrackInsideEllipse track : tracks) { final Track t1 = track.getTrackSnapshot(); final Position p1 = track.getTrackCenterPosition(); final int d = (int) p0.distanceTo(p1, CoordinateSystem.CARTESIAN); final int b = (int) p0.rhumbLineBearingTo(p1); List csvRecord = new ArrayList<>(); csvRecord.add(String.format(Locale.ENGLISH, "%s", t0.getTimeOfLastPositionReportTyped().format(fmt))); csvRecord.add(String.format(Locale.ENGLISH, "%d", t0.getMmsi())); csvRecord.add(String.format(Locale.ENGLISH, "%s", trimAisString(t0.getShipName()).replace(',', ' '))); csvRecord.add(String.format(Locale.ENGLISH, "%d", t0.getShipType())); csvRecord.add(String.format(Locale.ENGLISH, "%d", t0.getVesselLength())); csvRecord.add(String.format(Locale.ENGLISH, "%d", t0.getVesselBeam())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t0.getCourseOverGround())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t0.getTrueHeading())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t0.getSpeedOverGround())); csvRecord.add(String.format(Locale.ENGLISH, "%.4f", p0.getLatitude())); csvRecord.add(String.format(Locale.ENGLISH, "%.4f", p0.getLongitude())); csvRecord.add(String.format(Locale.ENGLISH, "%d", t1.getMmsi())); csvRecord.add(String.format(Locale.ENGLISH, "%s", trimAisString(t1.getShipName()).replace(',', ' '))); csvRecord.add(String.format(Locale.ENGLISH, "%d", t1.getShipType())); csvRecord.add(String.format(Locale.ENGLISH, "%d", t1.getVesselLength())); csvRecord.add(String.format(Locale.ENGLISH, "%d", t1.getVesselBeam())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t1.getCourseOverGround())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t1.getTrueHeading())); csvRecord.add(String.format(Locale.ENGLISH, "%.0f", t1.getSpeedOverGround())); csvRecord.add(String.format(Locale.ENGLISH, "%.4f", p1.getLatitude())); csvRecord.add(String.format(Locale.ENGLISH, "%.4f", p1.getLongitude())); csvRecord.add(String.format(Locale.ENGLISH, "%d", b)); csvRecord.add(String.format(Locale.ENGLISH, "%d", d)); try { csvFilePrinter.printRecord(csvRecord); } catch (IOException e) { LOG.error(e.getMessage(), e); LOG.error("Failed to write line to CSV file: " + freeFlowData); } } csvFilePrinter.flush(); } catch (IOException e) { LOG.error(e.getMessage(), e); } finally { lock.unlock(); } } public static class FreeFlowData { private final Track trackSnapshot; private final Position trackCenterPosition; private final List<TrackInsideEllipse> tracksInsideEllipse; public static class TrackInsideEllipse { private final Track trackSnapshot; private final Position trackCenterPosition; private TrackInsideEllipse(Track trackSnapshot, Position trackCenterPosition) { this.trackSnapshot = trackSnapshot; this.trackCenterPosition = trackCenterPosition; } public Track getTrackSnapshot() { return trackSnapshot; } public Position getTrackCenterPosition() { return trackCenterPosition; } @Override public String toString() { return "TrackInsideEllipse{" + "trackSnapshot=" + trackSnapshot + ", trackCenterPosition=" + trackCenterPosition + '}'; } } private FreeFlowData(Track trackSnapshot, Position trackCenterPosition, List<TrackInsideEllipse> tracksInsideEllipse) { this.trackSnapshot = trackSnapshot; this.trackCenterPosition = trackCenterPosition; this.tracksInsideEllipse = tracksInsideEllipse; } public Track getTrackSnapshot() { return trackSnapshot; } public Position getTrackCenterPosition() { return trackCenterPosition; } public List<TrackInsideEllipse> getTracksInsideEllipse() { return tracksInsideEllipse; } @Override public String toString() { return "FreeFlowData{" + "trackSnapshot=" + trackSnapshot + ", trackCenterPosition=" + trackCenterPosition + ", tracksInsideEllipse=" + tracksInsideEllipse + '}'; } } }