Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d98f474ae | ||
|
|
15baafc9be |
@@ -0,0 +1,667 @@
|
||||
package com.wytehat.btlogger;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GPS history cleanup for the map screen.
|
||||
*
|
||||
* Recorded history is noisy: the same fix gets logged twice, two fixes claim
|
||||
* the same timestamp from different places, and a bad fix occasionally puts a
|
||||
* device a kilometre away and back within a second. This walks a device's
|
||||
* history in time order and drops the points that cannot be real, keeping a
|
||||
* Decision for every removal so the map and the CSV export can show what was
|
||||
* taken out and why.
|
||||
*
|
||||
* One aggressiveness dial (0 = very light, 100 = very aggressive) drives every
|
||||
* threshold; see the getters below for what each end of the range means.
|
||||
*
|
||||
* Split out of MapActivity, which owns the UI, not this.
|
||||
*/
|
||||
public class HistoryNormalizer {
|
||||
|
||||
public static final int DEFAULT_AGGRESSIVENESS = 50;
|
||||
|
||||
private static final double MIN_MAX_SPEED_KMH = 20.0;
|
||||
private static final double MAX_MAX_SPEED_KMH = 250.0;
|
||||
|
||||
private static final double MIN_DUPLICATE_DISTANCE_METERS = 0.75;
|
||||
private static final double MAX_DUPLICATE_DISTANCE_METERS = 5.0;
|
||||
|
||||
private static final double MIN_SAME_TIME_DISTANCE_METERS = 10.0;
|
||||
private static final double MAX_SAME_TIME_DISTANCE_METERS = 50.0;
|
||||
|
||||
private static final double MIN_ACCURACY_MULTIPLIER = 0.75;
|
||||
private static final double MAX_ACCURACY_MULTIPLIER = 3.0;
|
||||
|
||||
private static final double EARTH_RADIUS_METERS = 6371008.8;
|
||||
|
||||
/** A point that normalization removed, and the reasoning behind it. */
|
||||
public static class Decision {
|
||||
|
||||
public LocationPoint point;
|
||||
public LocationPoint previousAccepted;
|
||||
|
||||
public String reason;
|
||||
|
||||
public double distanceMeters;
|
||||
public double elapsedSeconds;
|
||||
public double speedKmh;
|
||||
public double threshold;
|
||||
|
||||
public Decision(
|
||||
LocationPoint point,
|
||||
LocationPoint previousAccepted,
|
||||
String reason,
|
||||
double distanceMeters,
|
||||
double elapsedSeconds,
|
||||
double speedKmh,
|
||||
double threshold) {
|
||||
|
||||
this.point = point;
|
||||
this.previousAccepted = previousAccepted;
|
||||
this.reason = reason;
|
||||
this.distanceMeters = distanceMeters;
|
||||
this.elapsedSeconds = elapsedSeconds;
|
||||
this.speedKmh = speedKmh;
|
||||
this.threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
/* 0..100. */
|
||||
private int aggressiveness = DEFAULT_AGGRESSIVENESS;
|
||||
|
||||
private final HashMap<String, List<LocationPoint>> historyCache =
|
||||
new HashMap<String, List<LocationPoint>>();
|
||||
|
||||
/* Points removed by normalization, kept so they can be drawn/exported. */
|
||||
private final HashMap<String, List<Decision>> decisionCache =
|
||||
new HashMap<String, List<Decision>>();
|
||||
|
||||
private final HashSet<String> duplicateKeys = new HashSet<String>();
|
||||
private final HashSet<String> rejectedKeys = new HashSet<String>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Settings
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public int getAggressiveness() {
|
||||
return aggressiveness;
|
||||
}
|
||||
|
||||
/** Clamps to 0..100 and drops cached results, which are now stale. */
|
||||
public void setAggressiveness(int value) {
|
||||
|
||||
int clamped = value;
|
||||
|
||||
if (clamped < 0) clamped = 0;
|
||||
if (clamped > 100) clamped = 100;
|
||||
|
||||
if (clamped == aggressiveness) return;
|
||||
|
||||
aggressiveness = clamped;
|
||||
|
||||
clear();
|
||||
}
|
||||
|
||||
public String levelName() {
|
||||
return levelName(aggressiveness);
|
||||
}
|
||||
|
||||
public static String levelName(int aggressiveness) {
|
||||
|
||||
if (aggressiveness <= 20) return "Very Light";
|
||||
if (aggressiveness <= 40) return "Light";
|
||||
if (aggressiveness <= 60) return "Normal";
|
||||
if (aggressiveness <= 80) return "Strong";
|
||||
|
||||
return "Very Aggressive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Light normalization allows up to 250 km/h, very aggressive bottoms out
|
||||
* at 20 km/h. An ordinary 5 m/s walk (~18 km/h) survives every setting.
|
||||
*/
|
||||
public double maxSpeedKmh() {
|
||||
return scale(MIN_MAX_SPEED_KMH, MAX_MAX_SPEED_KMH);
|
||||
}
|
||||
|
||||
public double maxSpeedMps() {
|
||||
return maxSpeedKmh() / 3.6;
|
||||
}
|
||||
|
||||
public double duplicateDistance() {
|
||||
return scale(
|
||||
MIN_DUPLICATE_DISTANCE_METERS,
|
||||
MAX_DUPLICATE_DISTANCE_METERS);
|
||||
}
|
||||
|
||||
public double sameTimeDistance() {
|
||||
return scale(
|
||||
MIN_SAME_TIME_DISTANCE_METERS,
|
||||
MAX_SAME_TIME_DISTANCE_METERS);
|
||||
}
|
||||
|
||||
public double accuracyMultiplier() {
|
||||
return scale(MIN_ACCURACY_MULTIPLIER, MAX_ACCURACY_MULTIPLIER);
|
||||
}
|
||||
|
||||
/** Maps aggressiveness 0..100 onto max..min of a threshold range. */
|
||||
private double scale(double minimum, double maximum) {
|
||||
|
||||
double fraction = aggressiveness / 100.0;
|
||||
|
||||
return maximum - ((maximum - minimum) * fraction);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cached results
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void clear() {
|
||||
historyCache.clear();
|
||||
decisionCache.clear();
|
||||
duplicateKeys.clear();
|
||||
rejectedKeys.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes every distinct device in the list up front, so the map and
|
||||
* the export summary can read counts without re-running the engine.
|
||||
*/
|
||||
public void prepare(TrackerDatabase db, List<DeviceRecord> rows) {
|
||||
|
||||
if (db == null || rows == null) return;
|
||||
|
||||
HashSet<String> seen = new HashSet<String>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
|
||||
DeviceRecord record = rows.get(i);
|
||||
|
||||
if (record == null) continue;
|
||||
|
||||
if (record.address == null || record.address.trim().length() == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.contains(record.address)) continue;
|
||||
|
||||
seen.add(record.address);
|
||||
|
||||
historyOf(db, record.address);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized history for one device, running the engine (and caching the
|
||||
* result) the first time it is asked for.
|
||||
*/
|
||||
public List<LocationPoint> historyOf(TrackerDatabase db, String address) {
|
||||
|
||||
List<LocationPoint> cached = historyCache.get(address);
|
||||
|
||||
if (cached != null) return cached;
|
||||
|
||||
List<LocationPoint> normalized =
|
||||
normalize(address, db.history(address, 0L));
|
||||
|
||||
historyCache.put(address, normalized);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Removed points for one device; never null. */
|
||||
public List<Decision> decisionsOf(String address) {
|
||||
|
||||
List<Decision> result = decisionCache.get(address);
|
||||
|
||||
return result == null
|
||||
? new ArrayList<Decision>()
|
||||
: result;
|
||||
}
|
||||
|
||||
public int duplicateCount() {
|
||||
return duplicateKeys.size();
|
||||
}
|
||||
|
||||
public int rejectedCount() {
|
||||
return rejectedKeys.size();
|
||||
}
|
||||
|
||||
/** Points kept across every device normalized so far. */
|
||||
public int retainedCount() {
|
||||
|
||||
int retained = 0;
|
||||
|
||||
for (List<LocationPoint> list : historyCache.values()) {
|
||||
if (list != null) retained += list.size();
|
||||
}
|
||||
|
||||
return retained;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points examined so far: the decision cache holds only removals and the
|
||||
* history cache only survivors, so the raw total is the two combined.
|
||||
*/
|
||||
public int examinedCount() {
|
||||
|
||||
int removed = 0;
|
||||
|
||||
for (List<Decision> list : decisionCache.values()) {
|
||||
if (list != null) removed += list.size();
|
||||
}
|
||||
|
||||
return removed + retainedCount();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Engine
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public List<LocationPoint> normalize(
|
||||
String deviceAddress,
|
||||
List<LocationPoint> input) {
|
||||
|
||||
ArrayList<LocationPoint> rows = new ArrayList<LocationPoint>();
|
||||
ArrayList<Decision> decisions = new ArrayList<Decision>();
|
||||
|
||||
if (input == null) {
|
||||
|
||||
decisionCache.put(deviceAddress, decisions);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
for (int i = 0; i < input.size(); i++) {
|
||||
|
||||
LocationPoint point = input.get(i);
|
||||
|
||||
if (point != null) rows.add(point);
|
||||
}
|
||||
|
||||
Collections.sort(rows, new Comparator<LocationPoint>() {
|
||||
|
||||
@Override
|
||||
public int compare(LocationPoint a, LocationPoint b) {
|
||||
|
||||
long ta = pointTime(a);
|
||||
long tb = pointTime(b);
|
||||
|
||||
if (ta < tb) return -1;
|
||||
if (ta > tb) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Pass 1: drop points identical to one already seen.
|
||||
*/
|
||||
|
||||
ArrayList<LocationPoint> unique = new ArrayList<LocationPoint>();
|
||||
|
||||
HashSet<String> exact = new HashSet<String>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
|
||||
LocationPoint point = rows.get(i);
|
||||
|
||||
String key = buildKey(deviceAddress, point);
|
||||
|
||||
if (exact.contains(key)) {
|
||||
|
||||
duplicateKeys.add(key);
|
||||
|
||||
decisions.add(new Decision(
|
||||
point,
|
||||
unique.size() > 0
|
||||
? unique.get(unique.size() - 1)
|
||||
: null,
|
||||
"Exact duplicate",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
duplicateDistance()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
exact.add(key);
|
||||
unique.add(point);
|
||||
}
|
||||
|
||||
/*
|
||||
* Pass 2: walk the timeline and judge each point against the last
|
||||
* one that was trusted.
|
||||
*/
|
||||
|
||||
ArrayList<LocationPoint> normalized = new ArrayList<LocationPoint>();
|
||||
|
||||
LocationPoint previousAccepted = null;
|
||||
|
||||
for (int i = 0; i < unique.size(); i++) {
|
||||
|
||||
LocationPoint point = unique.get(i);
|
||||
|
||||
long timestamp = pointTime(point);
|
||||
|
||||
double lat = point.latitude;
|
||||
double lon = point.longitude;
|
||||
|
||||
double accuracy = pointDouble(point, "accuracy", -1);
|
||||
|
||||
/* Nothing to compare against - keep it and move on. */
|
||||
|
||||
if (timestamp <= 0L) {
|
||||
normalized.add(point);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isValidCoordinate(lat, lon)) {
|
||||
normalized.add(point);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previousAccepted == null) {
|
||||
normalized.add(point);
|
||||
previousAccepted = point;
|
||||
continue;
|
||||
}
|
||||
|
||||
long previousTimestamp = pointTime(previousAccepted);
|
||||
|
||||
double previousLat = previousAccepted.latitude;
|
||||
double previousLon = previousAccepted.longitude;
|
||||
|
||||
if (previousTimestamp <= 0L
|
||||
|| !isValidCoordinate(previousLat, previousLon)) {
|
||||
|
||||
normalized.add(point);
|
||||
previousAccepted = point;
|
||||
continue;
|
||||
}
|
||||
|
||||
double elapsed = (timestamp - previousTimestamp) / 1000.0;
|
||||
|
||||
double distance =
|
||||
haversineMeters(previousLat, previousLon, lat, lon);
|
||||
|
||||
if (elapsed < 0) {
|
||||
normalized.add(point);
|
||||
previousAccepted = point;
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* SAME TIMESTAMP
|
||||
* ========================================================
|
||||
*/
|
||||
|
||||
if (elapsed == 0) {
|
||||
|
||||
double previousAccuracy =
|
||||
pointDouble(previousAccepted, "accuracy", 0);
|
||||
|
||||
double allowedDistance = Math.max(
|
||||
sameTimeDistance(),
|
||||
(Math.max(accuracy, 0) + Math.max(previousAccuracy, 0))
|
||||
* accuracyMultiplier());
|
||||
|
||||
String key = buildKey(deviceAddress, point);
|
||||
|
||||
if (distance <= allowedDistance) {
|
||||
|
||||
duplicateKeys.add(key);
|
||||
|
||||
decisions.add(new Decision(
|
||||
point,
|
||||
previousAccepted,
|
||||
"Same timestamp / duplicate GPS fix",
|
||||
distance,
|
||||
elapsed,
|
||||
0,
|
||||
allowedDistance));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
rejectedKeys.add(key);
|
||||
|
||||
decisions.add(new Decision(
|
||||
point,
|
||||
previousAccepted,
|
||||
"Conflicting GPS point at same timestamp",
|
||||
distance,
|
||||
elapsed,
|
||||
0,
|
||||
allowedDistance));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* NORMAL MOVEMENT
|
||||
* ========================================================
|
||||
*/
|
||||
|
||||
double previousAccuracy =
|
||||
pointDouble(previousAccepted, "accuracy", 0);
|
||||
|
||||
double combinedAccuracy = Math.max(previousAccuracy, accuracy);
|
||||
|
||||
double movementTolerance = Math.max(
|
||||
duplicateDistance(),
|
||||
combinedAccuracy * accuracyMultiplier());
|
||||
|
||||
if (distance <= movementTolerance) {
|
||||
normalized.add(point);
|
||||
previousAccepted = point;
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* PHYSICAL SPEED TEST
|
||||
* ========================================================
|
||||
*/
|
||||
|
||||
double speedKmh = (distance / elapsed) * 3.6;
|
||||
|
||||
double physicallyPossibleDistance = maxSpeedMps() * elapsed;
|
||||
|
||||
if (distance > physicallyPossibleDistance) {
|
||||
|
||||
rejectedKeys.add(buildKey(deviceAddress, point));
|
||||
|
||||
decisions.add(new Decision(
|
||||
point,
|
||||
previousAccepted,
|
||||
"Impossible GPS movement",
|
||||
distance,
|
||||
elapsed,
|
||||
speedKmh,
|
||||
maxSpeedKmh()));
|
||||
|
||||
/*
|
||||
* IMPORTANT:
|
||||
*
|
||||
* Do not advance previousAccepted when a point is
|
||||
* rejected. The next point is still compared against
|
||||
* the last trustworthy point.
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.add(point);
|
||||
previousAccepted = point;
|
||||
}
|
||||
|
||||
decisionCache.put(deviceAddress, decisions);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String buildKey(String deviceAddress, LocationPoint point) {
|
||||
|
||||
if (point == null) return "";
|
||||
|
||||
return String.valueOf(deviceAddress)
|
||||
+ "|" + pointTime(point)
|
||||
+ "|" + Double.toString(point.latitude)
|
||||
+ "|" + Double.toString(point.longitude);
|
||||
}
|
||||
|
||||
public static boolean isValidCoordinate(double latitude, double longitude) {
|
||||
|
||||
return !Double.isNaN(latitude)
|
||||
&& !Double.isNaN(longitude)
|
||||
&& !Double.isInfinite(latitude)
|
||||
&& !Double.isInfinite(longitude)
|
||||
&& latitude >= -90.0
|
||||
&& latitude <= 90.0
|
||||
&& longitude >= -180.0
|
||||
&& longitude <= 180.0;
|
||||
}
|
||||
|
||||
public static double haversineMeters(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2) {
|
||||
|
||||
double rLat1 = Math.toRadians(lat1);
|
||||
double rLon1 = Math.toRadians(lon1);
|
||||
double rLat2 = Math.toRadians(lat2);
|
||||
double rLon2 = Math.toRadians(lon2);
|
||||
|
||||
double dLat = rLat2 - rLat1;
|
||||
double dLon = rLon2 - rLon1;
|
||||
|
||||
double a =
|
||||
Math.sin(dLat / 2.0) * Math.sin(dLat / 2.0)
|
||||
+ Math.cos(rLat1)
|
||||
* Math.cos(rLat2)
|
||||
* Math.sin(dLon / 2.0)
|
||||
* Math.sin(dLon / 2.0);
|
||||
|
||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a));
|
||||
|
||||
return EARTH_RADIUS_METERS * c;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// LocationPoint reflection
|
||||
//
|
||||
// The point class has changed shape more than once (timestamp vs time
|
||||
// vs recordedAt, and rssi/battery only exist on some builds), so these
|
||||
// read whatever field is actually there instead of assuming.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static final String[] TIME_FIELDS = {
|
||||
"timestamp",
|
||||
"time",
|
||||
"recordedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"timeMillis",
|
||||
"timestampMillis",
|
||||
"recordedTime"
|
||||
};
|
||||
|
||||
public static long pointTime(LocationPoint point) {
|
||||
|
||||
if (point == null) return 0L;
|
||||
|
||||
for (int i = 0; i < TIME_FIELDS.length; i++) {
|
||||
|
||||
Object value = readField(point, TIME_FIELDS[i]);
|
||||
|
||||
if (value == null) continue;
|
||||
|
||||
if (value instanceof Number) {
|
||||
return millis(((Number) value).longValue());
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return ((Date) value).getTime();
|
||||
}
|
||||
|
||||
if (value instanceof String) {
|
||||
|
||||
try {
|
||||
return millis(Long.parseLong((String) value));
|
||||
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/** Promotes a seconds-since-epoch value to milliseconds. */
|
||||
private static long millis(long value) {
|
||||
|
||||
return value > 0 && value < 100000000000L
|
||||
? value * 1000L
|
||||
: value;
|
||||
}
|
||||
|
||||
public static double pointDouble(
|
||||
LocationPoint point,
|
||||
String name,
|
||||
double fallback) {
|
||||
|
||||
Object value = readField(point, name);
|
||||
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
|
||||
if (value instanceof String) {
|
||||
|
||||
try {
|
||||
return Double.parseDouble((String) value);
|
||||
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static Object readField(LocationPoint point, String name) {
|
||||
|
||||
if (point == null) return null;
|
||||
|
||||
Class<?> cls = point.getClass();
|
||||
|
||||
while (cls != null) {
|
||||
|
||||
try {
|
||||
|
||||
Field field = cls.getDeclaredField(name);
|
||||
|
||||
field.setAccessible(true);
|
||||
|
||||
return field.get(point);
|
||||
|
||||
} catch (Exception ignored) {
|
||||
|
||||
cls = cls.getSuperclass();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
package com.wytehat.btlogger;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* CSV export for whatever the map is currently showing.
|
||||
*
|
||||
* Two flavours: RAW writes the history exactly as recorded, NORMALIZED runs
|
||||
* it through {@link HistoryNormalizer} first and also writes out the points
|
||||
* that were removed, so the file explains its own cleanup.
|
||||
*
|
||||
* Saving is a two-step dance with the system file picker - {@link #start}
|
||||
* asks for a destination, the activity hands the chosen Uri back to
|
||||
* {@link #write} from onActivityResult.
|
||||
*/
|
||||
public class MapExporter {
|
||||
|
||||
public static final int REQUEST_EXPORT_RAW = 7401;
|
||||
public static final int REQUEST_EXPORT_NORMALIZED = 7402;
|
||||
|
||||
private static final String HEADER_ROW =
|
||||
"Record Type,Device Name,Address,Category,"
|
||||
+ "Manufacturer,Latitude,Longitude,Accuracy,"
|
||||
+ "Battery,RSSI,Last Seen,Status,"
|
||||
+ "Location Locked,Map Enabled,Show Trail,Photo URI";
|
||||
|
||||
/** Snapshot of the map's filter bar at the moment of export. */
|
||||
public static class Filters {
|
||||
|
||||
public String device = "All";
|
||||
public String category = "All";
|
||||
|
||||
public long startMillis;
|
||||
public long endMillis;
|
||||
|
||||
public boolean includeHistory;
|
||||
|
||||
/** Same rule the map uses: an undated point is never filtered out. */
|
||||
public boolean contains(long time) {
|
||||
|
||||
if (time <= 0L) return true;
|
||||
|
||||
return time >= startMillis && time <= endMillis;
|
||||
}
|
||||
}
|
||||
|
||||
private final Activity activity;
|
||||
private final TrackerDatabase db;
|
||||
private final HistoryNormalizer normalizer;
|
||||
|
||||
public MapExporter(
|
||||
Activity activity,
|
||||
TrackerDatabase db,
|
||||
HistoryNormalizer normalizer) {
|
||||
|
||||
this.activity = activity;
|
||||
this.db = db;
|
||||
this.normalizer = normalizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the system where to save. The caller passes the devices already
|
||||
* matching the filter bar, so the file picker is never opened for an
|
||||
* export that would come back empty.
|
||||
*
|
||||
* @return false if there was nothing to export, or no app to save with.
|
||||
*/
|
||||
public boolean start(
|
||||
List<DeviceRecord> devices,
|
||||
boolean normalized,
|
||||
Filters filters) {
|
||||
|
||||
if (devices == null || devices.size() == 0) {
|
||||
|
||||
toast("No database records match the current filters.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
|
||||
normalizer.clear();
|
||||
normalizer.prepare(db, devices);
|
||||
}
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("text/csv");
|
||||
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
buildFilename(filters, normalized));
|
||||
|
||||
try {
|
||||
|
||||
activity.startActivityForResult(
|
||||
intent,
|
||||
normalized
|
||||
? REQUEST_EXPORT_NORMALIZED
|
||||
: REQUEST_EXPORT_RAW);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (ActivityNotFoundException error) {
|
||||
|
||||
toast("No file-saving application is available.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the export to the destination the user picked. */
|
||||
public void write(
|
||||
Uri uri,
|
||||
List<DeviceRecord> devices,
|
||||
boolean normalized,
|
||||
Filters filters) {
|
||||
|
||||
OutputStream output = null;
|
||||
BufferedWriter writer = null;
|
||||
|
||||
try {
|
||||
|
||||
ContentResolver resolver = activity.getContentResolver();
|
||||
|
||||
output = resolver.openOutputStream(uri);
|
||||
|
||||
if (output == null) {
|
||||
throw new IOException("Unable to open output file.");
|
||||
}
|
||||
|
||||
writer = new BufferedWriter(
|
||||
new OutputStreamWriter(output, "UTF-8"));
|
||||
|
||||
writeHeader(writer, normalized, filters);
|
||||
|
||||
int deviceCount = 0;
|
||||
int pointCount = 0;
|
||||
|
||||
for (int i = 0; i < devices.size(); i++) {
|
||||
|
||||
DeviceRecord record = devices.get(i);
|
||||
|
||||
if (record == null) continue;
|
||||
|
||||
writeDeviceRow(writer, record, normalized);
|
||||
|
||||
deviceCount++;
|
||||
|
||||
if (!filters.includeHistory) continue;
|
||||
|
||||
if (record.address == null
|
||||
|| record.address.trim().length() == 0) {
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
pointCount += writeHistoryRows(
|
||||
writer,
|
||||
record,
|
||||
normalized,
|
||||
filters);
|
||||
|
||||
if (normalized) {
|
||||
writeRemovedRows(writer, record, filters);
|
||||
}
|
||||
}
|
||||
|
||||
writeSummary(writer, normalized, deviceCount, pointCount);
|
||||
|
||||
writer.flush();
|
||||
|
||||
toast(summaryMessage(normalized, deviceCount, pointCount));
|
||||
|
||||
} catch (Exception error) {
|
||||
|
||||
toast("Export failed: " + error.getMessage());
|
||||
|
||||
} finally {
|
||||
|
||||
try {
|
||||
|
||||
if (writer != null) writer.close();
|
||||
else if (output != null) output.close();
|
||||
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Sections
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void writeHeader(
|
||||
BufferedWriter writer,
|
||||
boolean normalized,
|
||||
Filters filters) throws IOException {
|
||||
|
||||
line(writer, MapFormat.csv(
|
||||
normalized
|
||||
? "BT Logger Normalized Database Export"
|
||||
: "BT Logger Raw Database Export"));
|
||||
|
||||
pair(writer, "Exported", MapFormat.date(System.currentTimeMillis()));
|
||||
pair(writer, "Data Mode", normalized ? "Normalized" : "Raw");
|
||||
pair(writer, "Selected Device", filters.device);
|
||||
pair(writer, "Category", filters.category);
|
||||
pair(writer, "Start", MapFormat.date(filters.startMillis));
|
||||
pair(writer, "End", MapFormat.date(filters.endMillis));
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Historical Points",
|
||||
filters.includeHistory ? "Included" : "Excluded");
|
||||
|
||||
if (normalized) {
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Normalization Aggressiveness",
|
||||
normalizer.getAggressiveness() + "%");
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Maximum Speed",
|
||||
normalizer.maxSpeedKmh() + " km/h");
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Duplicate Distance",
|
||||
normalizer.duplicateDistance() + " m");
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Same Time Distance",
|
||||
normalizer.sameTimeDistance() + " m");
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Accuracy Multiplier",
|
||||
Double.toString(normalizer.accuracyMultiplier()));
|
||||
}
|
||||
|
||||
writer.newLine();
|
||||
|
||||
line(writer, HEADER_ROW);
|
||||
}
|
||||
|
||||
private void writeDeviceRow(
|
||||
BufferedWriter writer,
|
||||
DeviceRecord record,
|
||||
boolean normalized) throws IOException {
|
||||
|
||||
double latitude = record.latitude;
|
||||
double longitude = record.longitude;
|
||||
|
||||
long time = record.updatedAt;
|
||||
|
||||
/*
|
||||
* In normalized mode the device's own last-known fix may be one of
|
||||
* the points that got thrown out, so report the newest surviving
|
||||
* point instead.
|
||||
*/
|
||||
if (normalized) {
|
||||
|
||||
List<LocationPoint> history =
|
||||
normalizer.historyOf(db, record.address);
|
||||
|
||||
if (history.size() > 0) {
|
||||
|
||||
LocationPoint latest = history.get(history.size() - 1);
|
||||
|
||||
if (latest != null) {
|
||||
|
||||
latitude = latest.latitude;
|
||||
longitude = latest.longitude;
|
||||
|
||||
long latestTime = HistoryNormalizer.pointTime(latest);
|
||||
|
||||
if (latestTime > 0) time = latestTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line(writer,
|
||||
MapFormat.csv("DEVICE") + ","
|
||||
+ MapFormat.csv(record.displayName()) + ","
|
||||
+ MapFormat.csv(record.address) + ","
|
||||
+ MapFormat.csv(record.category) + ","
|
||||
+ MapFormat.csv(record.vendorName) + ","
|
||||
+ MapFormat.csv(Double.toString(latitude)) + ","
|
||||
+ MapFormat.csv(Double.toString(longitude)) + ","
|
||||
+ MapFormat.csv(
|
||||
record.accuracy > 0
|
||||
? Double.toString(record.accuracy)
|
||||
: "") + ","
|
||||
+ MapFormat.csv(
|
||||
record.battery >= 0
|
||||
? Integer.toString(record.battery)
|
||||
: "") + ","
|
||||
+ MapFormat.csv(
|
||||
record.rssi != 0
|
||||
? Integer.toString(record.rssi)
|
||||
: "") + ","
|
||||
+ MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + ","
|
||||
+ MapFormat.csv(MapFormat.reason(record.reason)) + ","
|
||||
+ MapFormat.csv(Integer.toString(record.locationLocked)) + ","
|
||||
+ MapFormat.csv(Integer.toString(record.mapEnabled)) + ","
|
||||
+ MapFormat.csv(Integer.toString(record.showTrail)) + ","
|
||||
+ MapFormat.csv(record.photoUri));
|
||||
}
|
||||
|
||||
private int writeHistoryRows(
|
||||
BufferedWriter writer,
|
||||
DeviceRecord record,
|
||||
boolean normalized,
|
||||
Filters filters) throws IOException {
|
||||
|
||||
List<LocationPoint> history =
|
||||
normalized
|
||||
? normalizer.historyOf(db, record.address)
|
||||
: db.history(record.address, 0L);
|
||||
|
||||
int written = 0;
|
||||
|
||||
for (int p = 0; p < history.size(); p++) {
|
||||
|
||||
LocationPoint point = history.get(p);
|
||||
|
||||
if (point == null) continue;
|
||||
|
||||
long time = HistoryNormalizer.pointTime(point);
|
||||
|
||||
if (time > 0L && !filters.contains(time)) continue;
|
||||
|
||||
double accuracy =
|
||||
HistoryNormalizer.pointDouble(point, "accuracy", -1);
|
||||
|
||||
double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0);
|
||||
|
||||
double battery =
|
||||
HistoryNormalizer.pointDouble(point, "battery", -1);
|
||||
|
||||
line(writer,
|
||||
MapFormat.csv(
|
||||
normalized
|
||||
? "NORMALIZED_POINT"
|
||||
: "HISTORICAL_POINT") + ","
|
||||
+ MapFormat.csv(record.displayName()) + ","
|
||||
+ MapFormat.csv(record.address) + ","
|
||||
+ MapFormat.csv(record.category) + ","
|
||||
+ MapFormat.csv(record.vendorName) + ","
|
||||
+ MapFormat.csv(Double.toString(point.latitude)) + ","
|
||||
+ MapFormat.csv(Double.toString(point.longitude)) + ","
|
||||
+ MapFormat.csv(
|
||||
accuracy > 0 ? Double.toString(accuracy) : "") + ","
|
||||
+ MapFormat.csv(
|
||||
battery >= 0 ? Double.toString(battery) : "") + ","
|
||||
+ MapFormat.csv(
|
||||
rssi != 0 ? Double.toString(rssi) : "") + ","
|
||||
+ MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv(""));
|
||||
|
||||
written++;
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
/** The points normalization threw away, with the reason in the Status column. */
|
||||
private void writeRemovedRows(
|
||||
BufferedWriter writer,
|
||||
DeviceRecord record,
|
||||
Filters filters) throws IOException {
|
||||
|
||||
List<HistoryNormalizer.Decision> decisions =
|
||||
normalizer.decisionsOf(record.address);
|
||||
|
||||
for (HistoryNormalizer.Decision decision : decisions) {
|
||||
|
||||
if (decision == null || decision.point == null) continue;
|
||||
|
||||
long time = HistoryNormalizer.pointTime(decision.point);
|
||||
|
||||
if (time > 0 && !filters.contains(time)) continue;
|
||||
|
||||
line(writer,
|
||||
MapFormat.csv("REMOVED_POINT") + ","
|
||||
+ MapFormat.csv(record.displayName()) + ","
|
||||
+ MapFormat.csv(record.address) + ","
|
||||
+ MapFormat.csv(record.category) + ","
|
||||
+ MapFormat.csv(record.vendorName) + ","
|
||||
+ MapFormat.csv(
|
||||
Double.toString(decision.point.latitude)) + ","
|
||||
+ MapFormat.csv(
|
||||
Double.toString(decision.point.longitude)) + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + ","
|
||||
+ MapFormat.csv(decision.reason) + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv("") + ","
|
||||
+ MapFormat.csv(""));
|
||||
}
|
||||
}
|
||||
|
||||
private void writeSummary(
|
||||
BufferedWriter writer,
|
||||
boolean normalized,
|
||||
int deviceCount,
|
||||
int pointCount) throws IOException {
|
||||
|
||||
writer.newLine();
|
||||
|
||||
line(writer, MapFormat.csv("Export Summary"));
|
||||
|
||||
pair(writer, "Devices Exported", Integer.toString(deviceCount));
|
||||
|
||||
pair(
|
||||
writer,
|
||||
normalized
|
||||
? "Normalized Points Exported"
|
||||
: "Historical Points Exported",
|
||||
Integer.toString(pointCount));
|
||||
|
||||
if (normalized) {
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"Duplicates Removed",
|
||||
Integer.toString(normalizer.duplicateCount()));
|
||||
|
||||
pair(
|
||||
writer,
|
||||
"GPS Jumps Rejected",
|
||||
Integer.toString(normalizer.rejectedCount()));
|
||||
}
|
||||
}
|
||||
|
||||
private String summaryMessage(
|
||||
boolean normalized,
|
||||
int deviceCount,
|
||||
int pointCount) {
|
||||
|
||||
if (!normalized) {
|
||||
|
||||
return "Exported "
|
||||
+ deviceCount + " device(s) and "
|
||||
+ pointCount + " historical point(s).";
|
||||
}
|
||||
|
||||
return "Exported "
|
||||
+ deviceCount + " device(s), "
|
||||
+ pointCount + " normalized point(s), "
|
||||
+ normalizer.duplicateCount() + " duplicate(s) removed, and "
|
||||
+ normalizer.rejectedCount() + " GPS jump(s) rejected.";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private String buildFilename(Filters filters, boolean normalized) {
|
||||
|
||||
String devicePart = sanitizeFilename(
|
||||
"All".equals(filters.device)
|
||||
? "all_devices"
|
||||
: filters.device);
|
||||
|
||||
String datePart =
|
||||
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
.format(new Date());
|
||||
|
||||
return "btlogger_"
|
||||
+ devicePart
|
||||
+ "_"
|
||||
+ (normalized ? "normalized_" : "raw_")
|
||||
+ datePart
|
||||
+ ".csv";
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String value) {
|
||||
|
||||
if (value == null || value.trim().length() == 0) {
|
||||
return "export";
|
||||
}
|
||||
|
||||
return value
|
||||
.replaceAll("[\\\\/:*?\"<>|]", "_")
|
||||
.replaceAll("\\s+", "_");
|
||||
}
|
||||
|
||||
private void line(BufferedWriter writer, String text) throws IOException {
|
||||
|
||||
writer.write(text);
|
||||
writer.newLine();
|
||||
}
|
||||
|
||||
private void pair(
|
||||
BufferedWriter writer,
|
||||
String label,
|
||||
String value) throws IOException {
|
||||
|
||||
line(writer, MapFormat.csv(label) + "," + MapFormat.csv(value));
|
||||
}
|
||||
|
||||
private void toast(String message) {
|
||||
|
||||
Toast.makeText(activity, message, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.wytehat.btlogger;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Formatting and escaping helpers shared by the map screen, the HTML it
|
||||
* feeds the WebView and the CSV exporter.
|
||||
*
|
||||
* These used to be private methods on MapActivity, which meant every piece
|
||||
* of code that wanted to escape a popup string or stamp a timestamp had to
|
||||
* live in that one class.
|
||||
*/
|
||||
public final class MapFormat {
|
||||
|
||||
private MapFormat() {
|
||||
}
|
||||
|
||||
public static boolean hasValue(String value) {
|
||||
return value != null && value.trim().length() > 0;
|
||||
}
|
||||
|
||||
/** Human readable timestamp, or "Unknown" for a missing/zero time. */
|
||||
public static String date(long time) {
|
||||
return time <= 0
|
||||
? "Unknown"
|
||||
: DateFormat.getDateTimeInstance().format(new Date(time));
|
||||
}
|
||||
|
||||
/** Turns a raw disconnect reason into something worth showing a user. */
|
||||
public static String reason(String value) {
|
||||
|
||||
if (value == null) return "Status unknown";
|
||||
|
||||
if (value.indexOf("Range") >= 0) return "Out of range";
|
||||
|
||||
if (value.indexOf("Battery") >= 0 || value.indexOf("Power") >= 0) {
|
||||
return "Possible dead battery";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Falls back to the default pin red when a stored colour is unusable. */
|
||||
public static String validColor(String value) {
|
||||
|
||||
if (value != null && value.matches("#[0-9a-fA-F]{6}")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "#E53935";
|
||||
}
|
||||
|
||||
public static String number(double value) {
|
||||
return String.format(Locale.US, "%.2f", value);
|
||||
}
|
||||
|
||||
public static String htmlText(String value) {
|
||||
|
||||
if (value == null) return "";
|
||||
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/** Escapes a string for use inside a single-quoted JavaScript literal. */
|
||||
public static String js(String value) {
|
||||
|
||||
if (value == null) return "";
|
||||
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\n", "\\n")
|
||||
.replace("
", "\\u2028")
|
||||
.replace("
", "\\u2029");
|
||||
}
|
||||
|
||||
/** Quotes a CSV field, doubling quotes and flattening newlines. */
|
||||
public static String csv(String value) {
|
||||
|
||||
if (value == null) return "\"\"";
|
||||
|
||||
return "\"" +
|
||||
value
|
||||
.replace("\"", "\"\"")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ") +
|
||||
"\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package com.wytehat.btlogger;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Builds the Leaflet page the map WebView loads: one marker per device, an
|
||||
* optional trail of history points, and - in normalized mode - the points
|
||||
* normalization removed, drawn in red (impossible jump) or amber (duplicate)
|
||||
* with a dashed line back to the last point that was trusted.
|
||||
*
|
||||
* Everything the page needs is pushed in through {@link Options} and
|
||||
* {@link DataSource}, so this knows nothing about the activity's widgets.
|
||||
*/
|
||||
public final class MapHtmlBuilder {
|
||||
|
||||
/** Where the per-device history and removal lists come from. */
|
||||
public interface DataSource {
|
||||
|
||||
/** History for this device, already date-filtered. */
|
||||
List<LocationPoint> historyFor(DeviceRecord record);
|
||||
|
||||
/** Points normalization removed, already date-filtered. */
|
||||
List<HistoryNormalizer.Decision> removedPointsFor(String address);
|
||||
}
|
||||
|
||||
/** What the filter bar currently says. */
|
||||
public static class Options {
|
||||
|
||||
public boolean normalizedMode;
|
||||
public boolean showRemovedPoints;
|
||||
public boolean showHistory;
|
||||
|
||||
public int aggressiveness = HistoryNormalizer.DEFAULT_AGGRESSIVENESS;
|
||||
}
|
||||
|
||||
private MapHtmlBuilder() {
|
||||
}
|
||||
|
||||
public static String build(
|
||||
List<DeviceRecord> rows,
|
||||
String selectedAddress,
|
||||
Options options,
|
||||
DataSource data) {
|
||||
|
||||
StringBuilder script = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
|
||||
DeviceRecord record = rows.get(i);
|
||||
|
||||
if (!record.hasLocation) continue;
|
||||
|
||||
String color = MapFormat.validColor(record.pinColor);
|
||||
|
||||
List<LocationPoint> history = data.historyFor(record);
|
||||
|
||||
if (options.normalizedMode && options.showRemovedPoints) {
|
||||
appendRemovedPoints(script, record, options, data);
|
||||
}
|
||||
|
||||
double markerLatitude = record.latitude;
|
||||
double markerLongitude = record.longitude;
|
||||
|
||||
long markerTime = record.updatedAt;
|
||||
|
||||
/*
|
||||
* The device's stored fix may itself have been normalized away,
|
||||
* so pin it to the newest surviving point instead.
|
||||
*/
|
||||
if (options.normalizedMode && history.size() > 0) {
|
||||
|
||||
LocationPoint latest = history.get(history.size() - 1);
|
||||
|
||||
if (latest != null) {
|
||||
|
||||
markerLatitude = latest.latitude;
|
||||
markerLongitude = latest.longitude;
|
||||
|
||||
long normalizedTime =
|
||||
HistoryNormalizer.pointTime(latest);
|
||||
|
||||
if (normalizedTime > 0) markerTime = normalizedTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.showHistory && history.size() > 0) {
|
||||
|
||||
if (record.showTrail == 1 && history.size() > 1) {
|
||||
appendTrail(script, i, history, color);
|
||||
}
|
||||
|
||||
appendHistoryPoints(script, record, history, color, options);
|
||||
}
|
||||
|
||||
appendDeviceMarker(
|
||||
script,
|
||||
i,
|
||||
record,
|
||||
markerLatitude,
|
||||
markerLongitude,
|
||||
markerTime,
|
||||
color,
|
||||
selectedAddress,
|
||||
options);
|
||||
}
|
||||
|
||||
return page(script.toString(), selectedAddress);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Layers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void appendRemovedPoints(
|
||||
StringBuilder script,
|
||||
DeviceRecord record,
|
||||
Options options,
|
||||
DataSource data) {
|
||||
|
||||
List<HistoryNormalizer.Decision> decisions =
|
||||
data.removedPointsFor(record.address);
|
||||
|
||||
for (int d = 0; d < decisions.size(); d++) {
|
||||
|
||||
HistoryNormalizer.Decision decision = decisions.get(d);
|
||||
|
||||
if (decision == null || decision.point == null) continue;
|
||||
|
||||
LocationPoint removed = decision.point;
|
||||
|
||||
/*
|
||||
* Red = rejected GPS jump
|
||||
* Yellow = duplicate/suppressed point
|
||||
*/
|
||||
boolean jump =
|
||||
decision.reason != null
|
||||
&& decision.reason.indexOf("Impossible") >= 0;
|
||||
|
||||
String removedColor = jump ? "#E53935" : "#F9A825";
|
||||
|
||||
if (decision.previousAccepted != null) {
|
||||
|
||||
script.append("L.polyline([[")
|
||||
.append(decision.previousAccepted.latitude)
|
||||
.append(',')
|
||||
.append(decision.previousAccepted.longitude)
|
||||
.append("],[")
|
||||
.append(removed.latitude)
|
||||
.append(',')
|
||||
.append(removed.longitude)
|
||||
.append("]],{color:'")
|
||||
.append(removedColor)
|
||||
.append("',weight:2,dashArray:'6,6',opacity:.8})")
|
||||
.append(".addTo(map);");
|
||||
}
|
||||
|
||||
script.append("L.circleMarker([")
|
||||
.append(removed.latitude)
|
||||
.append(',')
|
||||
.append(removed.longitude)
|
||||
.append("],{radius:8,color:'")
|
||||
.append(removedColor)
|
||||
.append("',fillColor:'")
|
||||
.append(removedColor)
|
||||
.append("',fillOpacity:.85,weight:3})")
|
||||
.append(".addTo(map).bindPopup('")
|
||||
.append(MapFormat.js(removedPopup(record, decision, options)))
|
||||
.append("');");
|
||||
|
||||
script.append("bounds.push([")
|
||||
.append(removed.latitude)
|
||||
.append(',')
|
||||
.append(removed.longitude)
|
||||
.append("]);");
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendTrail(
|
||||
StringBuilder script,
|
||||
int index,
|
||||
List<LocationPoint> history,
|
||||
String color) {
|
||||
|
||||
script.append("var trail").append(index).append("=[");
|
||||
|
||||
for (int p = 0; p < history.size(); p++) {
|
||||
|
||||
if (p > 0) script.append(',');
|
||||
|
||||
LocationPoint point = history.get(p);
|
||||
|
||||
script.append('[')
|
||||
.append(point.latitude)
|
||||
.append(',')
|
||||
.append(point.longitude)
|
||||
.append(']');
|
||||
}
|
||||
|
||||
script.append("];L.polyline(trail")
|
||||
.append(index)
|
||||
.append(",{color:'")
|
||||
.append(color)
|
||||
.append("',weight:4,opacity:.65}).addTo(map);");
|
||||
}
|
||||
|
||||
private static void appendHistoryPoints(
|
||||
StringBuilder script,
|
||||
DeviceRecord record,
|
||||
List<LocationPoint> history,
|
||||
String color,
|
||||
Options options) {
|
||||
|
||||
for (int p = 0; p < history.size(); p++) {
|
||||
|
||||
LocationPoint point = history.get(p);
|
||||
|
||||
if (point == null) continue;
|
||||
|
||||
script.append("L.circleMarker([")
|
||||
.append(point.latitude)
|
||||
.append(',')
|
||||
.append(point.longitude)
|
||||
.append("],{radius:5,color:'")
|
||||
.append(color)
|
||||
.append("',fillColor:'")
|
||||
.append(color)
|
||||
.append("',fillOpacity:.9,weight:2})")
|
||||
.append(".addTo(map).bindPopup('")
|
||||
.append(MapFormat.js(historyPopup(record, point, options)))
|
||||
.append("');");
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendDeviceMarker(
|
||||
StringBuilder script,
|
||||
int index,
|
||||
DeviceRecord record,
|
||||
double latitude,
|
||||
double longitude,
|
||||
long time,
|
||||
String color,
|
||||
String selectedAddress,
|
||||
Options options) {
|
||||
|
||||
boolean locked = record.locationLocked == 1;
|
||||
|
||||
int markerSize = locked ? 10 : 22;
|
||||
int iconSize = markerSize + 6;
|
||||
int anchor = iconSize / 2;
|
||||
|
||||
String markerColor = locked ? "#00A86B" : color;
|
||||
|
||||
script.append("var icon").append(index)
|
||||
.append("=L.divIcon({className:'',html:\"")
|
||||
.append("<div style='width:")
|
||||
.append(markerSize)
|
||||
.append("px;height:")
|
||||
.append(markerSize)
|
||||
.append("px;border-radius:50%;background:")
|
||||
.append(markerColor)
|
||||
.append(";border:2px solid white;box-shadow:0 1px 4px #333'>")
|
||||
.append("</div>\",iconSize:[")
|
||||
.append(iconSize)
|
||||
.append(',')
|
||||
.append(iconSize)
|
||||
.append("],iconAnchor:[")
|
||||
.append(anchor)
|
||||
.append(',')
|
||||
.append(anchor)
|
||||
.append("]});");
|
||||
|
||||
script.append("var marker").append(index)
|
||||
.append("=L.marker([")
|
||||
.append(latitude)
|
||||
.append(',')
|
||||
.append(longitude)
|
||||
.append("],{icon:icon")
|
||||
.append(index)
|
||||
.append("}).addTo(map).bindPopup('")
|
||||
.append(MapFormat.js(
|
||||
devicePopup(record, latitude, longitude, time, options)))
|
||||
.append("');");
|
||||
|
||||
script.append("bounds.push([")
|
||||
.append(latitude)
|
||||
.append(',')
|
||||
.append(longitude)
|
||||
.append("]);");
|
||||
|
||||
if (record.address != null
|
||||
&& record.address.equals(selectedAddress)) {
|
||||
|
||||
script.append("marker").append(index)
|
||||
.append(".openPopup();map.setView([")
|
||||
.append(latitude)
|
||||
.append(',')
|
||||
.append(longitude)
|
||||
.append("],21);");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Popups
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static String devicePopup(
|
||||
DeviceRecord record,
|
||||
double latitude,
|
||||
double longitude,
|
||||
long time,
|
||||
Options options) {
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
|
||||
if (record.photoUri != null && new File(record.photoUri).exists()) {
|
||||
|
||||
html.append("<img src='file://")
|
||||
.append(MapFormat.htmlText(record.photoUri))
|
||||
.append("' style='width:72px;height:72px;")
|
||||
.append("object-fit:cover;border-radius:10px'><br>");
|
||||
}
|
||||
|
||||
html.append("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b><br>");
|
||||
|
||||
html.append("<a href='geo:")
|
||||
.append(latitude)
|
||||
.append(",")
|
||||
.append(longitude)
|
||||
.append("?q=")
|
||||
.append(latitude)
|
||||
.append(",")
|
||||
.append(longitude)
|
||||
.append("'>GPS: ")
|
||||
.append(latitude)
|
||||
.append(", ")
|
||||
.append(longitude)
|
||||
.append("</a>");
|
||||
|
||||
if (record.accuracy > 0) {
|
||||
|
||||
html.append(" (±")
|
||||
.append(Math.round(record.accuracy))
|
||||
.append(" m)");
|
||||
}
|
||||
|
||||
if (MapFormat.hasValue(record.category)
|
||||
&& !"Other".equalsIgnoreCase(record.category)) {
|
||||
|
||||
html.append("<br>Category: ")
|
||||
.append(MapFormat.htmlText(record.category));
|
||||
}
|
||||
|
||||
if (MapFormat.hasValue(record.vendorName)
|
||||
&& !"Unknown".equalsIgnoreCase(record.vendorName)
|
||||
&& !"Manufacturer unknown"
|
||||
.equalsIgnoreCase(record.vendorName)) {
|
||||
|
||||
html.append("<br>Manufacturer: ")
|
||||
.append(MapFormat.htmlText(record.vendorName));
|
||||
}
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Last seen: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
if (record.battery >= 0) {
|
||||
|
||||
html.append("<br>Battery: ")
|
||||
.append(record.battery)
|
||||
.append("%");
|
||||
}
|
||||
|
||||
if (record.rssi != 0 && record.lastRssiAt > 0) {
|
||||
|
||||
html.append("<br>RSSI: ")
|
||||
.append(record.rssi)
|
||||
.append(" dBm");
|
||||
}
|
||||
|
||||
String status = MapFormat.reason(record.reason);
|
||||
|
||||
if (MapFormat.hasValue(status)
|
||||
&& !"Status unknown".equalsIgnoreCase(status)) {
|
||||
|
||||
html.append("<br>Status: ")
|
||||
.append(MapFormat.htmlText(status));
|
||||
}
|
||||
|
||||
if (options.normalizedMode) {
|
||||
|
||||
html.append("<br><b>View: Normalized</b>");
|
||||
|
||||
html.append("<br>Normalization: ")
|
||||
.append(MapFormat.htmlText(
|
||||
HistoryNormalizer.levelName(options.aggressiveness)));
|
||||
}
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private static String removedPopup(
|
||||
DeviceRecord record,
|
||||
HistoryNormalizer.Decision decision,
|
||||
Options options) {
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
|
||||
html.append("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b>");
|
||||
|
||||
html.append("<br><span style='color:#E53935'><b>")
|
||||
.append(MapFormat.htmlText(decision.reason))
|
||||
.append("</b></span>");
|
||||
|
||||
long time = HistoryNormalizer.pointTime(decision.point);
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Date/time: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
html.append("<br>GPS: ")
|
||||
.append(decision.point.latitude)
|
||||
.append(", ")
|
||||
.append(decision.point.longitude);
|
||||
|
||||
html.append("<br>Distance from previous accepted point: ")
|
||||
.append(MapFormat.number(decision.distanceMeters))
|
||||
.append(" m");
|
||||
|
||||
html.append("<br>Elapsed time: ")
|
||||
.append(MapFormat.number(decision.elapsedSeconds))
|
||||
.append(" s");
|
||||
|
||||
if (decision.elapsedSeconds > 0) {
|
||||
|
||||
html.append("<br>Calculated speed: ")
|
||||
.append(MapFormat.number(decision.speedKmh))
|
||||
.append(" km/h");
|
||||
}
|
||||
|
||||
if (decision.threshold > 0) {
|
||||
|
||||
boolean jump =
|
||||
decision.reason != null
|
||||
&& decision.reason.indexOf("Impossible") >= 0;
|
||||
|
||||
if (jump) {
|
||||
|
||||
html.append("<br>Allowed speed at current setting: ")
|
||||
.append(MapFormat.number(decision.threshold))
|
||||
.append(" km/h");
|
||||
|
||||
} else {
|
||||
|
||||
html.append("<br>Allowed distance: ")
|
||||
.append(MapFormat.number(decision.threshold))
|
||||
.append(" m");
|
||||
}
|
||||
}
|
||||
|
||||
html.append("<br>Normalization strength: ")
|
||||
.append(options.aggressiveness)
|
||||
.append("%");
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private static String historyPopup(
|
||||
DeviceRecord record,
|
||||
LocationPoint point,
|
||||
Options options) {
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
|
||||
html.append("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b>");
|
||||
|
||||
html.append("<br><b>")
|
||||
.append(
|
||||
options.normalizedMode
|
||||
? "Normalized recorded point"
|
||||
: "Previous recorded point")
|
||||
.append("</b>");
|
||||
|
||||
long time = HistoryNormalizer.pointTime(point);
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Date/time: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
html.append("<br>GPS: ")
|
||||
.append(point.latitude)
|
||||
.append(", ")
|
||||
.append(point.longitude);
|
||||
|
||||
double accuracy =
|
||||
HistoryNormalizer.pointDouble(point, "accuracy", -1);
|
||||
|
||||
if (accuracy > 0) {
|
||||
|
||||
html.append("<br>Accuracy: ±")
|
||||
.append(Math.round(accuracy))
|
||||
.append(" m");
|
||||
}
|
||||
|
||||
double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0);
|
||||
|
||||
if (rssi != 0) {
|
||||
|
||||
html.append("<br>RSSI: ")
|
||||
.append(Math.round(rssi))
|
||||
.append(" dBm");
|
||||
}
|
||||
|
||||
double battery = HistoryNormalizer.pointDouble(point, "battery", -1);
|
||||
|
||||
if (battery >= 0) {
|
||||
|
||||
html.append("<br>Battery: ")
|
||||
.append(Math.round(battery))
|
||||
.append("%");
|
||||
}
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Page shell
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static String page(String script, String selectedAddress) {
|
||||
|
||||
return "<!doctype html><html><head>"
|
||||
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
|
||||
+ "<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'>"
|
||||
|
||||
+ "<style>"
|
||||
+ "html,body,#map{height:100%;margin:0}"
|
||||
+ ".leaflet-popup-content{font:14px sans-serif;line-height:1.5}"
|
||||
|
||||
+ ".phone-bearing{"
|
||||
+ "position:relative;"
|
||||
+ "width:38px;"
|
||||
+ "height:38px;"
|
||||
+ "transform-origin:19px 19px"
|
||||
+ "}"
|
||||
|
||||
+ ".phone-dot{"
|
||||
+ "position:absolute;"
|
||||
+ "left:9px;"
|
||||
+ "top:9px;"
|
||||
+ "width:16px;"
|
||||
+ "height:16px;"
|
||||
+ "border-radius:50%;"
|
||||
+ "background:#4285F4;"
|
||||
+ "border:3px solid white;"
|
||||
+ "box-shadow:0 1px 4px #555"
|
||||
+ "}"
|
||||
|
||||
+ ".phone-tip{"
|
||||
+ "position:absolute;"
|
||||
+ "left:15px;"
|
||||
+ "top:1px;"
|
||||
+ "width:0;"
|
||||
+ "height:0;"
|
||||
+ "border-left:5px solid transparent;"
|
||||
+ "border-right:5px solid transparent;"
|
||||
+ "border-bottom:10px solid #4285F4"
|
||||
+ "}"
|
||||
|
||||
+ "</style></head><body>"
|
||||
|
||||
+ "<div id='map'></div>"
|
||||
|
||||
+ "<script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script>"
|
||||
|
||||
+ "<script>"
|
||||
|
||||
+ "var streets=L.tileLayer("
|
||||
+ "'https://tile.openstreetmap.org/{z}/{x}/{y}.png',"
|
||||
+ "{maxNativeZoom:19,maxZoom:22,"
|
||||
+ "attribution:'© OpenStreetMap contributors'}),"
|
||||
|
||||
+ "sat=L.tileLayer("
|
||||
+ "'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',"
|
||||
+ "{maxNativeZoom:19,maxZoom:22,"
|
||||
+ "attribution:'Esri World Imagery'}),"
|
||||
|
||||
+ "map=L.map('map',{layers:[streets]}).setView([20,0],2);"
|
||||
|
||||
+ "L.control.layers({"
|
||||
+ "'Streets':streets,"
|
||||
+ "'Satellite':sat"
|
||||
+ "},null,{collapsed:false}).addTo(map);"
|
||||
|
||||
+ "var phone=null,phoneShown=false,"
|
||||
|
||||
+ "phoneIcon=L.divIcon({"
|
||||
+ "className:'',"
|
||||
+ "html:'<div id=phoneBearing class=phone-bearing><div class=phone-dot></div><div class=phone-tip></div></div>',"
|
||||
+ "iconSize:[38,38],"
|
||||
+ "iconAnchor:[19,19]"
|
||||
+ "});"
|
||||
|
||||
+ "function updatePhone(a,b,h){"
|
||||
+ "var p=[a,b];"
|
||||
|
||||
+ "if(!phone)"
|
||||
+ "phone=L.marker(p,{icon:phoneIcon,zIndexOffset:1100}).addTo(map);"
|
||||
+ "else phone.setLatLng(p);"
|
||||
|
||||
+ "if(!phoneShown){"
|
||||
+ "phoneShown=true;"
|
||||
+ "var visible=bounds.slice(0);"
|
||||
+ "visible.push(p);"
|
||||
|
||||
+ "if(visible.length>1)"
|
||||
+ "map.fitBounds(visible,{padding:[35,35],maxZoom:20});"
|
||||
+ "else map.setView(p,20);"
|
||||
+ "}"
|
||||
|
||||
+ "var e=document.getElementById('phoneBearing');"
|
||||
+ "if(e)e.style.transform='rotate('+h+'deg)';"
|
||||
+ "}"
|
||||
|
||||
+ "var bounds=[];"
|
||||
|
||||
+ script
|
||||
|
||||
+ "if(bounds.length>0&&"
|
||||
+ "'"
|
||||
+ MapFormat.js(selectedAddress == null ? "" : selectedAddress)
|
||||
+ "'=='')"
|
||||
+ "map.fitBounds(bounds,{padding:[30,30],maxZoom:20});"
|
||||
|
||||
+ "</script></body></html>";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user