package au.gov.amsa.navigation; import java.util.concurrent.TimeUnit; import com.github.davidmoten.rx.StateMachine.Transition; import com.github.davidmoten.rx.Transformers; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import au.gov.amsa.risky.format.Fix; import au.gov.amsa.risky.format.HasFix; import au.gov.amsa.risky.format.NavigationalStatus; import rx.Observable; import rx.Observable.Transformer; import rx.Subscriber; import rx.functions.Func1; public class DriftDetector { public static Observable<DriftCandidate> getCandidates(Observable<HasFix> o, Options options) { return o.compose(new DriftDetectorTransformer(options)); } public static DriftDetectorTransformer detectDrift() { return new DriftDetectorTransformer(Options.instance()); } public static DriftDetectorTransformer detectDrift(Options options) { return new DriftDetectorTransformer(options); } public static class DriftDetectorTransformer implements Transformer<HasFix, DriftCandidate> { // because many of these are expected to be in existence simultaneously // (one per vessel and between 30,000 and 40,000 vessels may appear in // our coverage annually, we need to be nice and careful with how much // memory this operator uses. private static final long NOT_DRIFTING = Long.MAX_VALUE; private static final long MMSI_NOT_SET = 0; private final Options options; private final Func1<Fix, Boolean> isCandidate; public DriftDetectorTransformer(Options options) { this.options = options; this.isCandidate = isCandidate(options); } @Override public Observable<DriftCandidate> call(Observable<HasFix> o) { Transformer<HasFix, DriftCandidate> t = Transformers.stateMachine() .initialState(new State(options)) // .transition(new Transition<State, HasFix, DriftCandidate>() { @Override public State call(State state, HasFix value, Subscriber<DriftCandidate> subscriber) { state.onNext(value, subscriber, isCandidate); return state; } }) // .build(); return o.compose(t); } // mutable class but is mutated serially (google Observable contract) private static final class State { Item a; Item b; long driftingSince = NOT_DRIFTING; long mmsi = 0; private final Options options; public State(Options options) { this.options = options; } public void onNext(HasFix f, Subscriber<DriftCandidate> subscriber, Func1<Fix, Boolean> isCandidate) { try { // Note that it is assumed that the input stream is grouped // by // mmsi and sorted by ascending time. Fix fix = f.fix(); if (mmsi != MMSI_NOT_SET && fix.mmsi() != mmsi) { // reset for a new vessel a = null; b = null; driftingSince = NOT_DRIFTING; } mmsi = fix.mmsi(); if (outOfTimeOrder(fix)) { return; } final Item item; if (isCandidate.call(fix)) { item = new Drifter(f, false); } else item = new NonDrifter(fix.time()); if (a == null) { a = item; processAB(subscriber); } else if (b == null) { b = item; processAB(subscriber); } else { processABC(item, subscriber); } } catch (RuntimeException e) { subscriber.onError(e); } } private boolean outOfTimeOrder(Fix fix) { if (b != null && fix.time() < b.time()) return true; else if (a != null && fix.time() < a.time()) return true; else return false; } private void processABC(Item c, Subscriber<DriftCandidate> subscriber) { if (isDrifter(a) && !isDrifter(b) && !isDrifter(c)) { // ignore c // rule 4, 5 } else if (isDrifter(a) && !isDrifter(b) && isDrifter(c)) { // rule 6, 7 if (withinNonDriftingThreshold(b, c)) { b = c; processAB(subscriber); } else { a = c; b = null; } } else { System.out.println(a + "," + b + "," + c); unexpected(); } } private void unexpected() { throw new RuntimeException("unexpected"); } private void processAB(Subscriber<DriftCandidate> subscriber) { if (!isDrifter(a)) { // rule 1 a = null; if (b != null) unexpected(); } else if (b == null) { // do nothing } else if (!a.emitted()) { if (isDrifter(b)) { // rule 2 if (!expired(a, b)) { driftingSince = a.time(); subscriber.onNext(new DriftCandidate(a.fix(), a.time())); subscriber.onNext(new DriftCandidate(b.fix(), a.time())); // mark as emitted a = new Drifter(a.fix(), true); b = null; } else { a = b; b = null; } } } else { // a has been emitted // rule 3 if (isDrifter(b)) { if (!expired(a, b)) { subscriber.onNext(new DriftCandidate(b.fix(), driftingSince)); a = new Drifter(b.fix(), true); b = null; } else { a = b; b = null; } } } } private boolean expired(Item a, Item b) { return b.time() - a.time() >= options.expiryAgeMs(); } private boolean withinNonDriftingThreshold(Item a, Item b) { return b.time() - a.time() < options.nonDriftingThresholdMs(); } } private static boolean isDrifter(Item item) { return item instanceof Drifter; } private static interface Item { long time(); HasFix fix(); boolean emitted(); } private static class Drifter implements Item { private final HasFix fix; private final boolean emitted; Drifter(HasFix fix, boolean emitted) { this.fix = fix; this.emitted = emitted; } @Override public long time() { return fix.fix().time(); } @Override public HasFix fix() { return fix; } @Override public boolean emitted() { return emitted; } } private static class NonDrifter implements Item { private final long time; NonDrifter(long time) { this.time = time; } @Override public long time() { return time; } @Override public Fix fix() { throw new RuntimeException("unexpected"); } @Override public boolean emitted() { // never gets emitted return false; } } } @VisibleForTesting static Func1<Fix, Boolean> isCandidate(Options options) { return f -> { if (f.courseOverGroundDegrees().isPresent() && f.headingDegrees().isPresent() && f.speedOverGroundKnots().isPresent() && (!f.navigationalStatus().isPresent() || (f.navigationalStatus().get() != NavigationalStatus.AT_ANCHOR && f .navigationalStatus().get() != NavigationalStatus.MOORED))) { double diff = diff(f.courseOverGroundDegrees().get(), f.headingDegrees().get()); return diff >= options.minHeadingCogDifference() && diff <= options.maxHeadingCogDifference() && f.speedOverGroundKnots().get() <= options.maxDriftingSpeedKnots() && f.speedOverGroundKnots().get() > options.minDriftingSpeedKnots(); } else return false; }; } static double diff(double a, double b) { Preconditions.checkArgument(a >= 0 && a < 360); Preconditions.checkArgument(b >= 0 && b < 360); double value; if (a < b) value = a + 360 - b; else value = a - b; if (value > 180) return 360 - value; else return value; }; public static final class Options { @VisibleForTesting static final int DEFAULT_HEADING_COG_DIFFERENCE_MIN = 45; @VisibleForTesting static final int DEFAULT_HEADING_COG_DIFFERENCE_MAX = 135; @VisibleForTesting static final float DEFAULT_MIN_DRIFTING_SPEED_KNOTS = 0.25f; @VisibleForTesting static final float DEFAULT_MAX_DRIFTING_SPEED_KNOTS = 20; private static final long DEFAULT_EXPIRY_AGE_MS = TimeUnit.HOURS.toMillis(6); private static final long DEFAULT_NON_DRIFTING_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5); private final int minHeadingCogDifference; private final int maxHeadingCogDifference; private final float minDriftingSpeedKnots; private final float maxDriftingSpeedKnots; private final long expiryAgeMs; private final long nonDriftingThresholdMs; private static class Holder { static Options INSTANCE = new Options(DEFAULT_HEADING_COG_DIFFERENCE_MIN, DEFAULT_HEADING_COG_DIFFERENCE_MAX, DEFAULT_MIN_DRIFTING_SPEED_KNOTS, DEFAULT_MAX_DRIFTING_SPEED_KNOTS, DEFAULT_EXPIRY_AGE_MS, DEFAULT_NON_DRIFTING_THRESHOLD_MS); } public static Options instance() { return Holder.INSTANCE; } public Options(int minHeadingCogDifference, int maxHeadingCogDifference, float minDriftingSpeedKnots, float maxDriftingSpeedKnots, long expiryAgeMs, long nonDriftingThresholdMs) { Preconditions.checkArgument(minHeadingCogDifference >= 0); Preconditions.checkArgument(minDriftingSpeedKnots >= 0); Preconditions.checkArgument(minHeadingCogDifference <= maxHeadingCogDifference); Preconditions.checkArgument(minDriftingSpeedKnots <= maxDriftingSpeedKnots); Preconditions.checkArgument(expiryAgeMs > 0); Preconditions.checkArgument(nonDriftingThresholdMs >= 0); this.minHeadingCogDifference = minHeadingCogDifference; this.maxHeadingCogDifference = maxHeadingCogDifference; this.minDriftingSpeedKnots = minDriftingSpeedKnots; this.maxDriftingSpeedKnots = maxDriftingSpeedKnots; this.expiryAgeMs = expiryAgeMs; this.nonDriftingThresholdMs = nonDriftingThresholdMs; } public int maxHeadingCogDifference() { return maxHeadingCogDifference; } public int minHeadingCogDifference() { return minHeadingCogDifference; } public float maxDriftingSpeedKnots() { return maxDriftingSpeedKnots; } public float minDriftingSpeedKnots() { return minDriftingSpeedKnots; } public long expiryAgeMs() { return expiryAgeMs; } public long nonDriftingThresholdMs() { return nonDriftingThresholdMs; } @Override public String toString() { StringBuilder b = new StringBuilder(); b.append("Options [minHeadingCogDifference="); b.append(minHeadingCogDifference); b.append(", maxHeadingCogDifference="); b.append(maxHeadingCogDifference); b.append(", minDriftingSpeedKnots="); b.append(minDriftingSpeedKnots); b.append(", maxDriftingSpeedKnots="); b.append(maxDriftingSpeedKnots); b.append(", expiryAgeMs="); b.append(expiryAgeMs); b.append(", nonDriftingThresholdMs="); b.append(nonDriftingThresholdMs); b.append("]"); return b.toString(); } } }