/* 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.tracker.eventEmittingTracker; import com.google.common.eventbus.DeadEvent; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import dk.dma.ais.message.AisMessage; import dk.dma.ais.message.AisTargetType; import dk.dma.ais.message.IVesselPositionMessage; import dk.dma.ais.packet.AisPacket; import dk.dma.ais.proprietary.IProprietarySourceTag; import dk.dma.ais.tracker.Target; import dk.dma.ais.tracker.eventEmittingTracker.events.CellChangedEvent; import dk.dma.ais.tracker.eventEmittingTracker.events.PositionChangedEvent; import dk.dma.ais.tracker.eventEmittingTracker.events.TimeEvent; import dk.dma.ais.tracker.eventEmittingTracker.events.TrackStaleEvent; import dk.dma.enav.model.geometry.Position; import dk.dma.enav.model.geometry.PositionTime; import dk.dma.enav.model.geometry.grid.Cell; import dk.dma.enav.model.geometry.grid.Grid; import net.jcip.annotations.GuardedBy; import net.jcip.annotations.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoField; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import static java.time.temporal.ChronoUnit.MINUTES; import static java.time.temporal.ChronoUnit.SECONDS; /** * EventEmittingTracker is a tracker which receives a (potentially never-ending) series of AisPackets. It uses these * to maintain in-memory a complete state of the "situation" (e.g. positions, speeds, static information, * and historic movements) of the targets it has detected. * * The tracker will emit events to its subscribes when certain tracking related events occur. Examples of such * events are cell changes (a target entered a new grid cell), position changes, track goes stale (has not been * updated for a duration of time), heart beats (TimeEvents). * * The heart beat mechanism is based on timestamps from the data stream and therefore quite unprecise. * Significant slack must be expected. */ @ThreadSafe public class EventEmittingTrackerImpl implements EventEmittingTracker { static final Logger LOG = LoggerFactory.getLogger(EventEmittingTrackerImpl.class); { LOG.info(this.getClass().getSimpleName() + " created (" + this + ")."); } private final EventBus eventBus = new EventBus(); @GuardedBy("tracksLock") final HashMap<Integer, Track> tracks = new HashMap<>(256); private final Lock tracksLock = new ReentrantLock(); final Grid grid; static final long TRACK_STALE_MILLIS = Duration.of(30, MINUTES).toMillis(); static final long TRACK_INTERPOLATION_REQUIRED_MILLIS = Duration.of(30, SECONDS).toMillis(); static final long INTERPOLATION_TIME_STEP_MILLIS = Duration.of(10, SECONDS).toMillis(); /** * A set of mmsi no.'s for which no messages are processed. */ private final TreeSet<Integer> mmsiBlacklist = new TreeSet<>(); /** * A set of mmsi no.'s for which to output detailed debugging/observation data. */ private final Set<Integer> mmsiToObserve = new TreeSet<>(); /** * A simple counter which is used to reduce CPU load by only performing more complicated * calculations when this markTrigger has reached certain values. */ private int markTrigger; /** * The last hour-of-the-day when a timestamp message was log to the LOG. */ private int markLastHourLogged = -1; /** * The time since Epoch when the most recent TimeEvent was posted to the EventBus. */ private long lastTimeEventMillis = 0; /** * The approximate no. of milliseconds between each TimeEvent. */ static final int TIME_EVENT_PERIOD_MILLIS = 1000; /** * Initialize the tracker. * @param grid */ public EventEmittingTrackerImpl(Grid grid) { this(grid, null); } /** * Initialize the tracker. * @param grid */ public EventEmittingTrackerImpl(Grid grid, int... blackListedMmsis) { this.grid = grid; if (blackListedMmsis != null) { for (int blackListedMmsi : blackListedMmsis) { this.mmsiBlacklist.add(blackListedMmsi); } } eventBus.register(this); // to catch dead events } /** * {@inheritDoc} */ @Override public void update(AisPacket packet) { performUpdate(packet.getBestTimestamp(), packet.tryGetAisMessage(), track -> track.update(packet)); } @Override public Target get(int mmsi) { Track track; tracksLock.lock(); try { track = tracks.get(mmsi); } finally { tracksLock.unlock(); } return track; } void update(long timeOfCurrentUpdate, AisMessage aisMessage) { performUpdate(timeOfCurrentUpdate, aisMessage, track -> track.update(timeOfCurrentUpdate, aisMessage)); } private void performUpdate(long timeOfCurrentUpdate, AisMessage aisMessage, Consumer<Track> trackUpdater) { final int mmsi = aisMessage.getUserId(); if (LOG.isDebugEnabled()) { IProprietarySourceTag sourceTag = aisMessage.getSourceTag(); if (sourceTag != null) { LOG.debug("Received " + sourceTag.getTimestamp() + " " + aisMessage); } else { LOG.debug("Received " + aisMessage); } } if (! isOnBlackList(mmsi)) { if (isOnObservationList(mmsi)) { outputMessageSummary(aisMessage); } final AisTargetType targetType = aisMessage.getTargetType(); if (targetType == AisTargetType.A || targetType == AisTargetType.B) { Track track = getOrCreateTrack(mmsi); long timeOfLastUpdate = track.getTimeOfLastUpdate(); long timeOfLastPositionUpdate = track.getTimeOfLastPositionReport(); // Rebirth track if stale if (isTrackStale(timeOfLastUpdate, timeOfLastPositionUpdate, timeOfCurrentUpdate)) { removeTrack(mmsi); track = getOrCreateTrack(mmsi); timeOfLastUpdate = 0L; timeOfLastPositionUpdate = 0L; } if (aisMessage instanceof IVesselPositionMessage) { IVesselPositionMessage positionMessage = (IVesselPositionMessage) aisMessage; if (positionMessage.getPos().getGeoLocation() != null) { if (isInterpolationRequired(timeOfLastPositionUpdate, timeOfCurrentUpdate)) { interpolateTrackUpToNewMessage(track, timeOfCurrentUpdate, aisMessage); } TrackingReport oldTrackingReport = track.getNewestTrackingReport(); trackUpdater.accept(track); track.setProperty(Track.CELL_ID, grid.getCell(positionMessage.getPos().getGeoLocation()).getCellId()); TrackingReport newTrackingReport = track.getNewestTrackingReport(); firePositionRelatedEvents(track, oldTrackingReport, newTrackingReport); } } else { trackUpdater.accept(track); } } } mark(timeOfCurrentUpdate); tryFireTimeEvent(timeOfCurrentUpdate); } private void firePositionRelatedEvents(Track track, TrackingReport oldTrackingReport, TrackingReport newTrackingReport) { Position oldPosition = null; Cell oldCell = null; if (oldTrackingReport != null) { oldPosition = oldTrackingReport.getPosition(); oldCell = grid.getCell(oldPosition); } Position newPosition = newTrackingReport.getPosition(); Cell newCell = grid.getCell(newPosition); if (hasChanged(oldPosition, newPosition)) { eventBus.post(new PositionChangedEvent(track, oldPosition)); } if (hasChanged(oldCell, newCell)) { eventBus.post(new CellChangedEvent(track, oldCell == null ? null : oldCell.getCellId())); } } private static <T> boolean hasChanged(T oldValue, T newValue) { boolean hasChanged; if (oldValue == null) { hasChanged = newValue != null; } else { hasChanged = !oldValue.equals(newValue); } return hasChanged; } private static void outputMessageSummary(AisMessage aisMessage) { if (aisMessage instanceof IVesselPositionMessage) { IVesselPositionMessage positionMessage = (IVesselPositionMessage) aisMessage; System.out.println(/*p.getBestTimestamp() + ": " + */ aisMessage.getUserId() + ": " + positionMessage.getSog() / 10.0 + " kts"); } } private void interpolateTrackUpToNewMessage(Track track, long timestamp, AisMessage message) { if (! (message instanceof IVesselPositionMessage)) { throw new IllegalArgumentException(); } IVesselPositionMessage posMessage = (IVesselPositionMessage) message; Position p1 = track.getPosition(); long t1 = track.getNewestTrackingReport().getTimestamp(); Position p2 = posMessage.getPos().getGeoLocation(); long t2 = timestamp; Map<Long, Position> interpolatedPositions = calculateInterpolatedPositions(PositionTime.create(p1, t1), PositionTime.create(p2, t2)); interpolatedPositions.forEach((t, p) -> { Position oldPosition = track.getPosition(); track.update(t, p, (float) (posMessage.getCog() / 10.0), (float) (posMessage.getSog() / 10.0), posMessage.getTrueHeading()); eventBus.post(new PositionChangedEvent(track, oldPosition)); }); } /** * Calculate a map of <timestamp, Position>-pairs which are interpolated positions at regular, fixed time-intervals * between the two positions p1 and p2 known at time t1 and t2 respectively. * * The set of interpolated positions contains positions up to - but not including - t2/p2. * * @param pt1 The first of the two positions to interpolate between. * @param pt2 The last of the two positions to interpolate between. * @return a Map of interpolated positions */ static final Map<Long, Position> calculateInterpolatedPositions(PositionTime pt1, PositionTime pt2) { TreeMap<Long, Position> interpolatedPositions = new TreeMap<>(); if (pt2.getTime() < pt1.getTime()) { LOG.error("Cannot interpolate backwards: " + pt1.getTime() + " " + pt2.getTime()); return interpolatedPositions; } final long t1 = pt1.getTime(); final long t2 = pt2.getTime(); for (long t = t1 + INTERPOLATION_TIME_STEP_MILLIS; t < t2; t += INTERPOLATION_TIME_STEP_MILLIS) { interpolatedPositions.put(t, PositionTime.createInterpolated(pt1, pt2, t)); } return interpolatedPositions; } static boolean isTrackStale(long lastAnyUpdate, long lastPositionUpdate, long currentUpdate) { long lastUpdate = Math.max(lastAnyUpdate, lastPositionUpdate); boolean trackStale = lastUpdate > 0L && currentUpdate - lastUpdate >= TRACK_STALE_MILLIS; if (trackStale) { LOG.debug("Track is stale (" + currentUpdate + ", " + lastUpdate + ")"); } return trackStale; } static boolean isInterpolationRequired(long lastPositionUpdate, long currentPositionUpdate) { return lastPositionUpdate > 0L && currentPositionUpdate - lastPositionUpdate >= TRACK_INTERPOLATION_REQUIRED_MILLIS; } private void removeTrack(int mmsi) { tracksLock.lock(); try { Track track = tracks.get(mmsi); tracks.remove(mmsi); eventBus.post(new TrackStaleEvent(track)); } finally { tracksLock.unlock(); } } private Track getOrCreateTrack(int mmsi) { Track track; tracksLock.lock(); try { track = tracks.get(mmsi); if (track == null) { track = new Track(mmsi); tracks.put(mmsi, track); } } finally { tracksLock.unlock(); } return track; } /** * {@inheritDoc} */ @Subscribe @SuppressWarnings("unused") public void listen(DeadEvent event) { LOG.trace("No subscribers were interested in this event: " + event.getEvent()); } /** * {@inheritDoc} */ public void registerSubscriber(Object subscriber) { eventBus.register(subscriber); } /** * {@inheritDoc} */ public Collection<Track> getTracks() { tracksLock.lock(); Collection<Track> trackCollection = null; try { trackCollection = tracks.values(); } finally { tracksLock.unlock(); } return trackCollection; } /** {@inheritDoc} */ @Override public int size() { int n; tracksLock.lock(); try { n = tracks.size(); } finally { tracksLock.unlock(); } return n; } /** * {@inheritDoc} * @deprecated Use #size() instead */ public int getNumberOfTracks() { return size(); } /** * Check if this MMSI no. is on the list of vessels to be closely observed (for programming and debugging purposes). * @param mmsi * @return true if the MMSO is on the observation list; false if not. */ private final boolean isOnObservationList(int mmsi) { return mmsiToObserve.contains(mmsi); } /** * Check if this MMSI no. corresponds to a black listed vessel. * @param mmsi * @return true if the MMSI is black listed; false otherwise. */ private final boolean isOnBlackList(int mmsi) { return mmsiBlacklist.contains(mmsi); } /** * Occasionally write a mark to show how far we have come in the data stream. * * @param timestampMillis current time. */ private void mark(long timestampMillis) { if ((markTrigger & 0xffff) == 0) { Instant instant = Instant.ofEpochMilli(timestampMillis); LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneOffset.UTC); int t = dateTime.get(ChronoField.HOUR_OF_DAY); if (t != markLastHourLogged) { markLastHourLogged = t; LOG.info("Now processing stream at time " + dateTime.format(DateTimeFormatter.ISO_DATE_TIME)); } } markTrigger++; } /** * Fire a TimeEvent event to the EventBus if at least TIME_EVENT_PERIOD_MILLIS msecs have * passed since the last TimeEvent was emitted. * * @param timestampMillis current time. */ private void tryFireTimeEvent(long timestampMillis) { long millisSinceLastTimeEvent = timestampMillis - lastTimeEventMillis; if (millisSinceLastTimeEvent >= TIME_EVENT_PERIOD_MILLIS) { TimeEvent timeEvent = new TimeEvent(Instant.ofEpochMilli(timestampMillis), lastTimeEventMillis == 0 ? null : Duration.ofMillis(millisSinceLastTimeEvent)); eventBus.post(timeEvent); lastTimeEventMillis = timestampMillis; if (LOG.isDebugEnabled()) { LOG.debug("TimeEvent emitted at time " + timeEvent.getTimestamp() + " msecs (" + timeEvent.getMillisSinceLastMark() + " msecs since last)."); } } } }