fix(live): stop the item pin from tracking the bearing arrow

The pin sat dead ahead of the heading arrow no matter which way the
user turned, so the arrow looked like it was aiming at the item and
the item looked like it was always straight down the path of travel.

The cause is geometric, not a UI link. addSample() only accepts a
sample after 5 m of movement, so every sample lies on the line the
user walked. gradientBearing() correlates RSSI against centred
position, and on a collinear track each centred position is t*u for
a single unit vector u along the walk - so the sum is exactly
parallel to u regardless of the signal. The "estimated" bearing was
the direction of travel, echoed back.

That bearing then placed the pin in three separate paths:
preliminaryEstimate() projects along it, solve() overrides a
converged trilateration with it, and consensusEstimate() averages
the result. All three produced a pin straight ahead.

Refuse the bearing until the track has real width across its own
axis - the smaller eigenvalue of the position covariance, against
max(4 m, meanAccuracy/2). A straight walk with GPS jitter measures
about 1 m of spread and is rejected; an L of two 20 m legs measures
about 5 m and is accepted. Distances still place the item off-axis
on a straight walk, but which side is a real mirror ambiguity, so
the UI now says so and asks for a leg at 90 degrees instead of
inventing a side.

Also drops showFallbackPin(), which projected the pin along the raw
compass heading whenever no estimate was solved - a fourth route to
the same wrong place. With no estimate the pin now shows the item's
last known GPS fix, or nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
n0tst3v3
2026-08-19 09:10:26 -06:00
co-authored by Claude Opus 5
parent 15baafc9be
commit 5d98f474ae
2 changed files with 73 additions and 31 deletions
@@ -61,8 +61,6 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
private float heading; private float heading;
/** Magnetic-to-true north correction for the current position. */ /** Magnetic-to-true north correction for the current position. */
private float declination; 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. */ /** Smoothed compass azimuth, kept in sin/cos form to survive the 0/360 wrap. */
private float headingSin, headingCos; private float headingSin, headingCos;
private boolean headingPrimed; private boolean headingPrimed;
@@ -518,12 +516,13 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
} }
SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate(); SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate();
if (estimate != null) { if (estimate != null) {
if (!Float.isNaN(estimate.gradientBearing))
lastGradientBearing = estimate.gradientBearing;
estimateText.setText(String.format(Locale.US, estimateText.setText(String.format(Locale.US,
"%s • %d stations • ±%.1f m • %.0f°", "%s • %d stations • ±%.1f m • %s",
estimate.preliminary ? "Estimate" : "Solved", 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)", js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)",
estimate.latitude, estimate.longitude, estimate.confidenceMeters, estimate.latitude, estimate.longitude, estimate.confidenceMeters,
estimate.gradientBearing, estimate.preliminary ? "true" : "false")); estimate.gradientBearing, estimate.preliminary ? "true" : "false"));
@@ -531,43 +530,35 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
} else { } else {
SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate(); SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate();
if (hint != null) { if (hint != null) {
if (!Float.isNaN(hint.gradientBearing))
lastGradientBearing = hint.gradientBearing;
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)", js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing)); hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing));
js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state)); js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state));
estimateText.setText("Likely " + sector(hint.gradientBearing) + "" + spatial.size() + " stations • refining"); estimateText.setText("Likely " + sector(hint.gradientBearing) + "" + spatial.size() + " stations • refining");
} else { } else {
showFallbackPin(location, direction, distance); showLastKnownPin();
estimateText.setText(spatial.size() + estimateText.setText(spatial.size() +
" stations • Walk 815 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 * With no solved estimate the only honest target is the item's last known
* known GPS fix. Projecting a pin along the phone's own heading - which * GPS fix, if it has one at all.
* 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.
* *
* It aims at the last bearing the signal gradient produced, so it holds * The bearing arrow reports which way the phone is facing and nothing
* a real direction instead of swinging around with the compass; only * else - it is never a pointer at the item. Projecting the pin along that
* before any gradient exists does it fall back to the phone's heading. * 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) { private void showLastKnownPin() {
float bearing = Float.isNaN(lastGradientBearing) ? direction : lastGradientBearing; if (target.hasLocation) {
double projected = Math.max(1.5, Math.min(20.0, distance)); js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,0,true)",
double radians = Math.toRadians(bearing); target.latitude, target.longitude, Math.max(1.5f, target.accuracy)));
double latitude = phone.getLatitude() + Math.cos(radians) * projected / 111319.49; } else {
double cosine = Math.max(0.2, Math.cos(Math.toRadians(phone.getLatitude()))); js("clearTarget()");
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 String sector(float bearing) { private String sector(float bearing) {
@@ -4,6 +4,13 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public class SpatialGradientEngine { 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 static class Estimate {
public double latitude; public double latitude;
public double longitude; public double longitude;
@@ -150,12 +157,34 @@ public class SpatialGradientEngine {
return result; 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) { 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++) { for (int i = 0; i < samples.size(); i++) {
meanX += xs[i]; meanY += ys[i]; meanRssi += samples.get(i).rssi; 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(); 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; double east = 0, north = 0;
for (int i = 0; i < samples.size(); i++) { for (int i = 0; i < samples.size(); i++) {
double signal = samples.get(i).rssi - meanRssi; double signal = samples.get(i).rssi - meanRssi;
@@ -168,6 +197,28 @@ public class SpatialGradientEngine {
return (float) degrees; 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() { public synchronized Estimate preliminaryEstimate() {
if (samples.size() < 2) return null; if (samples.size() < 2) return null;
Sample origin = samples.get(0); Sample origin = samples.get(0);