diff --git a/app/src/main/java/com/wytehat/btlogger/LiveRangeFinderActivity.java b/app/src/main/java/com/wytehat/btlogger/LiveRangeFinderActivity.java index 3504f53..8b5cb10 100644 --- a/app/src/main/java/com/wytehat/btlogger/LiveRangeFinderActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/LiveRangeFinderActivity.java @@ -61,8 +61,6 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene private float heading; /** Magnetic-to-true north correction for the current position. */ private float declination; - /** Last bearing the signal gradient produced; NaN until one is solved. */ - private float lastGradientBearing = Float.NaN; /** Smoothed compass azimuth, kept in sin/cos form to survive the 0/360 wrap. */ private float headingSin, headingCos; private boolean headingPrimed; @@ -518,12 +516,13 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene } SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate(); if (estimate != null) { - if (!Float.isNaN(estimate.gradientBearing)) - lastGradientBearing = estimate.gradientBearing; estimateText.setText(String.format(Locale.US, - "%s • %d stations • ±%.1f m • %.0f°", + "%s • %d stations • ±%.1f m • %s", estimate.preliminary ? "Estimate" : "Solved", - estimate.sampleCount, estimate.confidenceMeters, estimate.gradientBearing)); + estimate.sampleCount, estimate.confidenceMeters, + Float.isNaN(estimate.gradientBearing) + ? "side unresolved — turn 90° and walk" + : String.format(Locale.US, "%.0f°", estimate.gradientBearing))); js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)", estimate.latitude, estimate.longitude, estimate.confidenceMeters, estimate.gradientBearing, estimate.preliminary ? "true" : "false")); @@ -531,43 +530,35 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene } else { SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate(); if (hint != null) { - if (!Float.isNaN(hint.gradientBearing)) - lastGradientBearing = hint.gradientBearing; js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)", hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing)); js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state)); estimateText.setText("Likely " + sector(hint.gradientBearing) + " • " + spatial.size() + " stations • refining"); } else { - showFallbackPin(location, direction, distance); + showLastKnownPin(); estimateText.setText(spatial.size() + - " stations • Walk 8–15 m toward stronger signal"); + " stations • Walk 10 m, then turn 90° and walk again"); } } } /** - * With no solved estimate the only honest target is the item's last - * known GPS fix. Projecting a pin along the phone's own heading - which - * is what this used to do - just parks the pin wherever the user points, - * so the arrow always appears to aim at it and it is useless for walking. - */ - /** - * Projects the item pin the estimated distance ahead so there is always - * something to walk toward, and it moves as the range estimate changes. + * With no solved estimate the only honest target is the item's last known + * GPS fix, if it has one at all. * - * It aims at the last bearing the signal gradient produced, so it holds - * a real direction instead of swinging around with the compass; only - * before any gradient exists does it fall back to the phone's heading. + * The bearing arrow reports which way the phone is facing and nothing + * else - it is never a pointer at the item. Projecting the pin along that + * heading (which this used to do) parks the item wherever the user happens + * to be aiming, so the arrow always appears to point straight at it and + * the pin swings around with the compass. Show nothing rather than that. */ - private void showFallbackPin(Location phone, float direction, double distance) { - float bearing = Float.isNaN(lastGradientBearing) ? direction : lastGradientBearing; - double projected = Math.max(1.5, Math.min(20.0, distance)); - double radians = Math.toRadians(bearing); - double latitude = phone.getLatitude() + Math.cos(radians) * projected / 111319.49; - double cosine = Math.max(0.2, Math.cos(Math.toRadians(phone.getLatitude()))); - double longitude = phone.getLongitude() + Math.sin(radians) * projected / (111319.49 * cosine); - js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)", - latitude, longitude, Math.max(1.5, distance), bearing)); + private void showLastKnownPin() { + if (target.hasLocation) { + js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,0,true)", + target.latitude, target.longitude, Math.max(1.5f, target.accuracy))); + } else { + js("clearTarget()"); + } } private String sector(float bearing) { diff --git a/app/src/main/java/com/wytehat/btlogger/SpatialGradientEngine.java b/app/src/main/java/com/wytehat/btlogger/SpatialGradientEngine.java index 7e1b849..74f3391 100644 --- a/app/src/main/java/com/wytehat/btlogger/SpatialGradientEngine.java +++ b/app/src/main/java/com/wytehat/btlogger/SpatialGradientEngine.java @@ -4,6 +4,13 @@ import java.util.ArrayList; import java.util.List; public class SpatialGradientEngine { + + /** + * Metres of spread the sample track needs across its own axis before a + * signal gradient means anything. See {@link #gradientBearing}. + */ + private static final double MIN_TRACK_WIDTH_METERS = 4.0; + public static class Estimate { public double latitude; public double longitude; @@ -150,12 +157,34 @@ public class SpatialGradientEngine { return result; } + /** + * A bearing solved from samples that all sit on one line is not a + * measurement, it is an echo of the walk. + * + * Centre the positions and the correlation below is a sum of + * (position - mean) * signal. On a straight walk every centred position + * is t*u for one unit vector u along the track, so the result is exactly + * parallel to u no matter what the RSSI does - the "estimated" bearing + * comes back as the direction the user is already walking, the pin gets + * projected dead ahead, and the compass arrow appears to point at it. + * + * Distances alone still place the item off-axis, but which side of the + * track it lies on is a genuine mirror ambiguity. It takes a leg at an + * angle to resolve, so report NaN until the track has real width and let + * the caller ask the user to turn. + */ private float gradientBearing(double[] xs, double[] ys) { - double meanX = 0, meanY = 0, meanRssi = 0; + double meanX = 0, meanY = 0, meanRssi = 0, meanAccuracy = 0; for (int i = 0; i < samples.size(); i++) { meanX += xs[i]; meanY += ys[i]; meanRssi += samples.get(i).rssi; + meanAccuracy += samples.get(i).accuracy; } meanX /= samples.size(); meanY /= samples.size(); meanRssi /= samples.size(); + meanAccuracy /= samples.size(); + if (perpendicularSpread(xs, ys, meanX, meanY) < + Math.max(MIN_TRACK_WIDTH_METERS, meanAccuracy * 0.5)) { + return Float.NaN; + } double east = 0, north = 0; for (int i = 0; i < samples.size(); i++) { double signal = samples.get(i).rssi - meanRssi; @@ -168,6 +197,28 @@ public class SpatialGradientEngine { return (float) degrees; } + /** + * Spread of the sample track across its own dominant axis, in metres: + * the smaller eigenvalue of the position covariance, square-rooted. + * + * Straight walk plus GPS jitter lands around 1-3 m. An L of two 20 m + * legs lands near 6 m. + */ + private double perpendicularSpread(double[] xs, double[] ys, + double meanX, double meanY) { + int count = samples.size(); + if (count < 3) return 0; + double sxx = 0, syy = 0, sxy = 0; + for (int i = 0; i < count; i++) { + double dx = xs[i] - meanX, dy = ys[i] - meanY; + sxx += dx * dx; syy += dy * dy; sxy += dx * dy; + } + sxx /= count; syy /= count; sxy /= count; + double half = (sxx + syy) / 2.0; + double gap = Math.sqrt(Math.pow((sxx - syy) / 2.0, 2) + sxy * sxy); + return Math.sqrt(Math.max(0, half - gap)); + } + public synchronized Estimate preliminaryEstimate() { if (samples.size() < 2) return null; Sample origin = samples.get(0);