From f4c45eb62bbfd60391869ce768eaeb1ae86e2bae Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Sat, 28 Mar 2026 20:06:45 +0000 Subject: [PATCH 01/14] Initial estimation logic, local co-ordinate frame for WGS84 centre, easting/northing space --- .../PositionMe/sensors/ParticleFilter.java | 200 ++++++++++++++++++ secrets.properties | 6 +- 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java new file mode 100644 index 00000000..f026dd58 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -0,0 +1,200 @@ +package com.openpositioning.PositionMe.sensors; + +import com.google.android.gms.maps.model.LatLng; + +import android.util.Log; + +import java.util.Random; + +/** + * The Particle Filter class represents a sensor fusion algorithm + */ + +public class ParticleFilter { + + // Number of particles maintained by the filter + private static final int Num_Particles = 200; + + // Meters conversion constant for WGS84 to easting, northing space + private static final double Meters_Per_Degree = 111111.0; + + // Initial standard deviation spread from GNSS/WiFi fix. + private static final float GNSS_Init_STD = 15.0f; + private static final float WiFi_Init_STD = 8.0f; + + // The defined particle set + private final Particle[] particles; + + // WGS84 reference point used as the local coordinate frame origin + private LatLng originLatLng; + + // Weighted estimate easting and northing of the current position estimate + private float estimatedX; + private float estimatedY; + + // True once initial position estimation has been established + private boolean initialised; + + private final Random random; + private final SensorFusion sensorFusion; + + // Defines a single hypothesis about the user's position + private static class Particle{ + // Easting offset from local origin + float x; + + //Northing offset from local origin + float y; + + // Normalised importance weight + float weight; + + Particle(float x, float y, float weight) { + this.x = x; + this.y = y; + this.weight = weight; + } + } + + /** + * Constructor for the Particle Filter class. + * + * Attempts to initialise position and particles estimate from available WiFi and GNSS data. + * If neither are available at construction, tryInitialise() attempts again on later call. + */ + public ParticleFilter(){ + this.sensorFusion = SensorFusion.getInstance(); + this.particles = new Particle[Num_Particles]; + this.random = new Random(); + this.initialised = false; + + initialisePosition(); + } + + /** + * Automatically estimates the first available position from WiFi or GNSS + * and defines the particle set around this WGS84 point as an isotropic 2D + * Gaussian distribution. + * + * WiFi estimation prioritised for its greater indoor positioning accuracy. + * If unavailable, GNSS is used for the initial estimation instead. + * If neither is available, tryIinitialise() is called later. + */ + private void initialisePosition() { + float[] gnssLatLon = sensorFusion.getGNSSLatitude(false); + LatLng wifiLatLng = sensorFusion.getLatLngWifiPositioning(); + + // GNSS positioning returns null if no reading is available + boolean hasGNSS = gnssLatLon != null + && (gnssLatLon[0] != 0f || gnssLatLon[1] != 0f); + + // WiFi positioning returns null if no reading is available + boolean hasWifi = wifiLatLng != null; + + // Defer initialisation if neither GNSS or WiFi have a valid reading + if (!hasGNSS && !hasWifi) { + return; // Initialisation retried via tryInitialise() on later call + } + + // Initialisation source and particle spread based on priority + LatLng initLatLng; + float spreadStd; + + // Uses WiFi positioning if available, otherwise GNSS as fallback + if (hasWifi) { + initLatLng = wifiLatLng; + spreadStd = WiFi_Init_STD; + } else { + initLatLng = new LatLng(gnssLatLon[0], gnssLatLon[1]); + spreadStd = GNSS_Init_STD; + } + + // Set local frame origin at initial estimated position + originLatLng = initLatLng; + Log.d("ParticleFilter", "Origin set to: " + + originLatLng.latitude + ", " + + originLatLng.longitude); + + // Distribute particles around (0 , 0) in local frame + spreadParticles(0.0f, 0.0f, spreadStd); + initialised = true; + } + + /** + * Function to retry initial position estimation if it was deferred at construction + */ + public void tryInitialise() { + if (!initialised) { + initialisePosition(); + } + } + + /** + * Distributes particles as an isotropic 2D gaussian distribution around + * (centerX, centerY) and assigns equal weights to each particle (1/N). + */ + private void spreadParticles(float centerX, float centerY, float stdM) { + float initWeight = 1.0f / Num_Particles; + for (int i = 0; i < Num_Particles; i++) { + float x = centerX + (float) (random.nextGaussian() * stdM); + float y = centerY + (float) (random.nextGaussian() * stdM); + particles[i] = new Particle(x, y, initWeight); + } + + estimatedX = centerX; + estimatedY = centerY; + } + + /** + * Converts a WGS84 LatLng to a local easting/northing offset in meters + * relative to the local frame origin (originLatLng). + */ + public float[] latLngToLocal(LatLng point) { + if (originLatLng == null) return new float[]{0f, 0f}; + + double latDiff = point.latitude - originLatLng.latitude; + double lonDiff = point.longitude - originLatLng.longitude; + + float northing = (float) (latDiff * Meters_Per_Degree); + float easting = (float) (lonDiff * Meters_Per_Degree + * Math.cos(Math.toRadians(originLatLng.latitude))); + + return new float[]{easting, northing}; + } + + /** + * Converts a local easting/northing offset in meters back to WGS84. + */ + public LatLng localToLatLng(float eastingM, float northingM) { + if (originLatLng == null) return null; + + double lat = originLatLng.latitude + + northingM / Meters_Per_Degree; + double lon = originLatLng.longitude + + eastingM / (Meters_Per_Degree + * Math.cos(Math.toRadians(originLatLng.latitude))); + + return new LatLng(lat, lon); + } + + /** + * State accessors defined below. + */ + + public boolean isInitialised() { + return initialised; + } + + public LatLng getEstimatedPosition() { + if (!initialised) return null; + return localToLatLng(estimatedX, estimatedY); + } + + public float[] getEstimatedLocalPosition() { + return new float[]{estimatedX, estimatedY}; + } + + public LatLng getOrigin() { + return originLatLng; + } +} diff --git a/secrets.properties b/secrets.properties index f0dc54fd..893161db 100644 --- a/secrets.properties +++ b/secrets.properties @@ -1,6 +1,6 @@ # # Modify the variables to set your keys # -MAPS_API_KEY= -OPENPOSITIONING_API_KEY= -OPENPOSITIONING_MASTER_KEY= +MAPS_API_KEY=AIzaSyC13ios2NevfsRVS6yIj1arAM29ecy0GxQ +OPENPOSITIONING_API_KEY=c64dIaytmbCb84kU1-uuXw +OPENPOSITIONING_MASTER_KEY=ewireless From 9cc07b2063c3b92218bbdbea83b953b9bd88e841 Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Sun, 29 Mar 2026 14:59:49 +0100 Subject: [PATCH 02/14] PDR predict functionality on local WPG84 frame --- .../PositionMe/sensors/ParticleFilter.java | 37 +++++++++++++++++++ .../sensors/SensorEventHandler.java | 18 ++++++++- .../PositionMe/sensors/SensorFusion.java | 7 +++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index f026dd58..0b9d7f98 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -22,6 +22,9 @@ public class ParticleFilter { private static final float GNSS_Init_STD = 15.0f; private static final float WiFi_Init_STD = 8.0f; + // PDR process noise standard deviation approximation (adjustable) + public static final float PDR_Process_Noise_STD = 0.4f; + // The defined particle set private final Particle[] particles; @@ -145,6 +148,40 @@ private void spreadParticles(float centerX, float centerY, float stdM) { estimatedY = centerY; } + /** + * PDR motion model prediction + * + * Moves all defined particles according to PDR displacement with + * added Gaussian process noise to account for uncertainty in step length + * and heading. + */ + public void predict(float deltaEast, float deltaNorth, float PDRstd) { + // Early return if filter not initialised + if (!initialised) { + return; + } + + // Shift each particle with Gaussian noise considered + for (int i = 0; i < Num_Particles; i++) { + float noiseX = (float) (random.nextGaussian() * PDRstd); + float noiseY = (float) (random.nextGaussian() * PDRstd); + particles[i].x += deltaEast + noiseX; + particles[i].y += deltaNorth + noiseY; + } + + // Update weighted mean estimate + estimatedX = 0f; + estimatedY = 0f; + for (int i = 0; i < Num_Particles; i++) { + estimatedX += particles[i].x * particles[i].weight; + estimatedY += particles[i].y * particles[i].weight; + } + + //Debug test + Log.d("ParticleFilter", "Delta: (" + deltaEast + ", " + deltaNorth + + " ; Estimate: ("+ estimatedX + ", " + estimatedY +")"); + } + /** * Converts a WGS84 LatLng to a local easting/northing offset in meters * relative to the local frame origin (originLatLng). diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java index 639fc5c2..bbe65bb7 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java @@ -29,6 +29,7 @@ public class SensorEventHandler { private final PdrProcessing pdrProcessing; private final PathView pathView; private final TrajectoryRecorder recorder; + private final ParticleFilter particleFilter; // Timestamp tracking private final HashMap lastEventTimestamps = new HashMap<>(); @@ -39,6 +40,10 @@ public class SensorEventHandler { // Acceleration magnitude buffer between steps private final List accelMagnitude = new ArrayList<>(); + // Previous PDR position for displacement deltas computation + private float previousPDRX = 0f; + private float previousPDRY = 0f; + /** * Creates a new SensorEventHandler. * @@ -46,15 +51,17 @@ public class SensorEventHandler { * @param pdrProcessing PDR processor for step-length and position calculation * @param pathView path drawing view for trajectory visualisation * @param recorder trajectory recorder for checking recording state and writing PDR data + * @param particleFilter particle filter sensor fusion algorithm * @param bootTime initial boot time offset */ public SensorEventHandler(SensorState state, PdrProcessing pdrProcessing, PathView pathView, TrajectoryRecorder recorder, - long bootTime) { + ParticleFilter particleFilter, long bootTime) { this.state = state; this.pdrProcessing = pdrProcessing; this.pathView = pathView; this.recorder = recorder; + this.particleFilter = particleFilter; this.bootTime = bootTime; } @@ -174,6 +181,15 @@ public void handleSensorEvent(SensorEvent sensorEvent) { this.accelMagnitude.clear(); if (recorder.isRecording()) { + // Compute PDR displacement and shift particles + float deltaX = newCords[0] - previousPDRX; + float deltaY = newCords[1] - previousPDRY; + particleFilter.predict(deltaX, deltaY, ParticleFilter.PDR_Process_Noise_STD); + + // Update previous PDR position for next step + previousPDRX = newCords[0]; + previousPDRY = newCords[1]; + this.pathView.drawTrajectory(newCords); state.stepCounter++; recorder.addPdrData( diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index aeb6386a..27f6926d 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -63,6 +63,7 @@ public class SensorFusion implements SensorEventListener { private SensorEventHandler eventHandler; private TrajectoryRecorder recorder; private WifiPositionManager wifiPositionManager; + private ParticleFilter particleFilter; // Movement sensor instances (lifecycle managed here) private MovementSensor accelerometerSensor; @@ -160,9 +161,12 @@ public void setContext(Context context) { this.wifiPositionManager = new WifiPositionManager(wiFiPositioning, recorder); + // Initialise particle filter + this.particleFilter = new ParticleFilter(); + long bootTime = SystemClock.uptimeMillis(); this.eventHandler = new SensorEventHandler( - state, pdrProcessing, pathView, recorder, bootTime); + state, pdrProcessing, pathView, recorder, particleFilter, bootTime); // Register WiFi observer on WifiPositionManager (not on SensorFusion) this.wifiProcessor = new WifiDataProcessor(context); @@ -328,6 +332,7 @@ private void stopWirelessCollectors() { public void startRecording() { recorder.startRecording(pdrProcessing); eventHandler.resetBootTime(recorder.getBootTime()); + particleFilter.tryInitialise(); // Handover WiFi/BLE scan lifecycle from activity callbacks to foreground service. stopWirelessCollectors(); From 16ec151770a18d416637912f3e808985e4f7c71c Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Sun, 29 Mar 2026 20:46:28 +0100 Subject: [PATCH 03/14] GNSS update + particle weighting --- .../PositionMe/sensors/ParticleFilter.java | 56 +++++++++++++++++++ .../PositionMe/sensors/SensorFusion.java | 7 +++ 2 files changed, 63 insertions(+) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 0b9d7f98..6714c757 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -25,6 +25,10 @@ public class ParticleFilter { // PDR process noise standard deviation approximation (adjustable) public static final float PDR_Process_Noise_STD = 0.4f; + // Min and max GNSS measurment accuracy for weight update (define rejected measurements) + private static final float GNSS_Min_Accuracy = 5.0f; + private static final float GNSS_Max_Accuracy = 50.0f; + // The defined particle set private final Particle[] particles; @@ -182,6 +186,58 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { + " ; Estimate: ("+ estimatedX + ", " + estimatedY +")"); } + /** + * Particle weight update + */ + public void updateWeights(LatLng measurementLatLng, float accuracy) { + // Early return if not initialised + if (!initialised || measurementLatLng == null) { + return; + } + + // Early return if out of accuracy range (poor measurement) + if (accuracy <= 0 || accuracy >= GNSS_Max_Accuracy) { + return; + } + float sigma = Math.max(accuracy, GNSS_Min_Accuracy); + + // Convert received measurement to local frame (meters) + float[] Measurement_Local = latLngToLocal(measurementLatLng); + float mx = Measurement_Local[0]; + float my = Measurement_Local[1]; + + // Calculate each particle weighting + float twoSigmaSquared = 2.0f * sigma * sigma; + for (int i = 0; i < Num_Particles; i++) { + // Calculate distance in meters from particle to measurement + float dx = particles[i].x - mx; + float dy = particles[i].y - my; + + // Gaussian likelihood equation + float d = (float) Math.sqrt(dx * dx + dy * dy); + float dSquared = d * d; + float likelihood = (float) Math.exp(-dSquared / twoSigmaSquared); + particles[i].weight *= likelihood; + } + + // Normalise weights and to sum = 1.0 + float weightSum = 0f; + for (int i = 0; i < Num_Particles; i++) { + weightSum += particles[i].weight; + } + for (int i = 0; i < Num_Particles; i++) { + particles[i].weight /= weightSum; + } + + // Recalculate weighted mean estimate of position + estimatedX = 0f; + estimatedY = 0f; + for (int i = 0; i < Num_Particles; i++) { + estimatedX += particles[i].x * particles[i].weight; + estimatedY += particles[i].y * particles[i].weight; + } + } + /** * Converts a WGS84 LatLng to a local easting/northing offset in meters * relative to the local frame origin (originLatLng). diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index 27f6926d..281e86b2 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -650,6 +650,13 @@ public void onLocationChanged(@NonNull Location location) { state.latitude = (float) location.getLatitude(); state.longitude = (float) location.getLongitude(); recorder.addGnssData(location); + + // Update particle weights with GNSS measurement + if (particleFilter.isInitialised()) { + LatLng gnssLatLng = new LatLng(location.getLatitude(), location.getLongitude()); + float accuracy = location.getAccuracy(); + particleFilter.updateWeights(gnssLatLng, accuracy); + } } } From 0cbc7c56b0beb5cb7871bb36d572fadd359cfe3f Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Mon, 30 Mar 2026 13:03:52 +0100 Subject: [PATCH 04/14] WiFi update + weighting --- .../PositionMe/sensors/ParticleFilter.java | 18 ++++++++++++- .../PositionMe/sensors/SensorFusion.java | 8 +++--- .../PositionMe/sensors/WiFiPositioning.java | 25 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 6714c757..900d44f9 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -25,7 +25,7 @@ public class ParticleFilter { // PDR process noise standard deviation approximation (adjustable) public static final float PDR_Process_Noise_STD = 0.4f; - // Min and max GNSS measurment accuracy for weight update (define rejected measurements) + // Min and max GNSS measurement accuracy for weight update (define rejected measurements) private static final float GNSS_Min_Accuracy = 5.0f; private static final float GNSS_Max_Accuracy = 50.0f; @@ -236,6 +236,22 @@ public void updateWeights(LatLng measurementLatLng, float accuracy) { estimatedX += particles[i].x * particles[i].weight; estimatedY += particles[i].y * particles[i].weight; } + + // Debugging code for terminal output and observation + float maxWeight = 0f; + int maxIndex = 0; + for (int i = 0; i < Num_Particles; i++) { + if (particles[i].weight > maxWeight) { + maxWeight = particles[i].weight; + maxIndex = i; + } + } + + Log.d("ParticleFilter", "Update called - " + + "Measurement: (" + mx + ", " + my + ") " + + "Estimate: (" + estimatedX + ", " + estimatedY + ") " + + "Best particle: (" + particles[maxIndex].x + ", " + particles[maxIndex].y + ") " + + "Max weight: " + maxWeight); } /** diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index 281e86b2..d9af5ed9 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -151,7 +151,7 @@ public void setContext(Context context) { SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); this.pdrProcessing = new PdrProcessing(context); this.pathView = new PathView(context, null); - WiFiPositioning wiFiPositioning = new WiFiPositioning(context); + WiFiPositioning wiFiPositioning = new WiFiPositioning(context, null); // Create internal modules this.recorder = new TrajectoryRecorder(appContext, state, serverCommunications, settings); @@ -164,6 +164,8 @@ public void setContext(Context context) { // Initialise particle filter this.particleFilter = new ParticleFilter(); + wiFiPositioning.setParticleFilter(particleFilter); + long bootTime = SystemClock.uptimeMillis(); this.eventHandler = new SensorEventHandler( state, pdrProcessing, pathView, recorder, particleFilter, bootTime); @@ -651,8 +653,8 @@ public void onLocationChanged(@NonNull Location location) { state.longitude = (float) location.getLongitude(); recorder.addGnssData(location); - // Update particle weights with GNSS measurement - if (particleFilter.isInitialised()) { + // Update particle weights with GNSS measurement + if (particleFilter.isInitialised()) { LatLng gnssLatLng = new LatLng(location.getLatitude(), location.getLongitude()); float accuracy = location.getAccuracy(); particleFilter.updateWeights(gnssLatLng, accuracy); diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java index dbf809dd..baceea8d 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java @@ -54,6 +54,7 @@ public int getFloor() { // Store current floor of user, default value 0 (ground floor) private int floor=0; + private ParticleFilter particleFilter; /** * Constructor to create the WiFi positioning object @@ -62,9 +63,14 @@ public int getFloor() { * * @param context Context of object calling */ - public WiFiPositioning(Context context){ + public WiFiPositioning(Context context, ParticleFilter particleFilter){ // Initialising the Request queue this.requestQueue = Volley.newRequestQueue(context.getApplicationContext()); + this.particleFilter = particleFilter; + } + + public void setParticleFilter(ParticleFilter particleFilter) { + this.particleFilter = particleFilter; } /** @@ -89,6 +95,14 @@ public void request(JSONObject jsonWifiFeatures) { try { wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon")); floor = response.getInt("floor"); + + //Update particle filter weights with WiFi + if (particleFilter != null && particleFilter.isInitialised()) { + // Update particle filter weights with WiFi measurement + Log.d("ParticleFilter", "WiFi weight update: lat=" + wifiLocation.latitude + + ", lon=" + wifiLocation.longitude); + particleFilter.updateWeights(wifiLocation, 8.0f); + } } catch (JSONException e) { // Error log to keep record of errors (for secure programming and maintainability) Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response); @@ -140,6 +154,15 @@ public void request( JSONObject jsonWifiFeatures, final VolleyCallback callback) Log.d("jsonObject",response.toString()); wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon")); floor = response.getInt("floor"); + + //Update particle filter weights with WiFi + if (particleFilter != null && particleFilter.isInitialised()) { + // Update particle filter weights with WiFi measurement + Log.d("ParticleFilter", "WiFi weight update: lat=" + wifiLocation.latitude + + ", lon=" + wifiLocation.longitude); + particleFilter.updateWeights(wifiLocation, 8.0f); + } + callback.onSuccess(wifiLocation,floor); } catch (JSONException e) { Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response); From 01ad8be3ebad879ffd8adde1835b35fa4e68a5d1 Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Mon, 30 Mar 2026 17:11:05 +0100 Subject: [PATCH 05/14] Initial resampling attempt --- .../PositionMe/sensors/ParticleFilter.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 900d44f9..3d39ddc0 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -252,6 +252,58 @@ public void updateWeights(LatLng measurementLatLng, float accuracy) { + "Estimate: (" + estimatedX + ", " + estimatedY + ") " + "Best particle: (" + particles[maxIndex].x + ", " + particles[maxIndex].y + ") " + "Max weight: " + maxWeight); + + resample(); + } + + /** + * Systematic resampling of particles for weight degeneration solution (SIR) + */ + private void resample() { + // Calculate effective sample size as 1 / sum of particle weights squared + float sumWeightsSquared = 0f; + for (int i = 0; i < Num_Particles; i++) { + sumWeightsSquared += particles[i].weight * particles[i].weight; + } + float N_eff = 1.0f / sumWeightsSquared; + + // Early return if effective sample size >= set threshold + float threshold = Num_Particles / 2.0f; + if (N_eff >= threshold) { + Log.d("ParticleFilter", "Resampling skipped"); + return; + } + + // Build cumulative sum array from normalised weights + float[] cumulativeSum = new float[Num_Particles]; + cumulativeSum[0] = particles[0].weight; + for (int i = 1; i < Num_Particles; i++) { + cumulativeSum[i] = cumulativeSum[i-1] + particles[i].weight; + } + + // Set uniform staring point + float overN = 1.0f / Num_Particles; + float u1 = random.nextFloat() * overN; + + Particle[] resampledParticles = new Particle[Num_Particles]; + float uniformWeighting = overN; + int j = 0; + + for (int i = 0; i < Num_Particles; i++) { + float u = u1 + i * overN; + + while (j < Num_Particles - 1 && u > cumulativeSum[j]) { + j++; + } + + resampledParticles[i] = new Particle( + particles[j].x, particles[j].y, uniformWeighting); + } + + // Replace particle array with resampled particles + System.arraycopy(resampledParticles, 0, particles, 0, Num_Particles); + + Log.d("ParticleFilter", "Resampling complete: all weights reset to " + uniformWeighting); } /** From ee705c88fbb742d2f04ef351dce02b7185211c3b Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 30 Mar 2026 21:05:20 +0100 Subject: [PATCH 06/14] Add map matching bullet 1 - wall-based particle rejection --- .../PositionMe/sensors/MapGeometry.java | 90 ++++++ .../PositionMe/sensors/MapMatcher.java | 293 ++++++++++++++++++ .../PositionMe/sensors/ParticleFilter.java | 120 ++++++- .../PositionMe/sensors/SensorFusion.java | 35 ++- .../PositionMe/utils/PdrProcessing.java | 12 +- 5 files changed, 539 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java new file mode 100644 index 00000000..246f29e4 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java @@ -0,0 +1,90 @@ +package com.openpositioning.PositionMe.sensors; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +// NOTE: doesSegmentCross* methods are intentionally omitted — wall-crossing +// correction belongs to the separate "movement model / wall constraint" requirement. + +/** + * Pure static geometry helpers for map-matching. + * + * No instance state. No Android dependencies beyond Log. + * All coordinates are in the local easting/northing meter frame used by + * ParticleFilter. + * + * Logcat tag: MapGeometry (self-test results only) + */ +class MapGeometry { + + private static final String TAG = "MapGeometry"; + + // No instances needed + private MapGeometry() {} + + // ------------------------------------------------------------------------- + // Point-in-polygon + // ------------------------------------------------------------------------- + + /** + * Ray-casting point-in-polygon test. + * + * @param x easting (meters, local frame) + * @param y northing (meters, local frame) + * @param polygon list of float[]{eastingM, northingM} vertices + * @return true if (x,y) is strictly inside the polygon + */ + static boolean isPointInsidePolygon(float x, float y, List polygon) { + if (polygon == null || polygon.size() < 3) return false; + + int n = polygon.size(); + boolean inside = false; + int j = n - 1; + + for (int i = 0; i < n; i++) { + float xi = polygon.get(i)[0], yi = polygon.get(i)[1]; + float xj = polygon.get(j)[0], yj = polygon.get(j)[1]; + + if (((yi > y) != (yj > y)) + && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) { + inside = !inside; + } + j = i; + } + return inside; + } + + // ------------------------------------------------------------------------- + // Self-test (Step 2) + // ------------------------------------------------------------------------- + + /** + * Validates isPointInsidePolygon against known-correct answers. + * Called once after building map data is loaded. Tag: MapGeometry. + */ + static void selfTest() { + // Unit square: (0,0) → (10,0) → (10,10) → (0,10) + List square = new ArrayList<>(); + square.add(new float[]{0f, 0f}); + square.add(new float[]{10f, 0f}); + square.add(new float[]{10f, 10f}); + square.add(new float[]{0f, 10f}); + + check("isPointInsidePolygon(5,5) == true", + isPointInsidePolygon(5f, 5f, square), true); + + check("isPointInsidePolygon(15,5) == false", + isPointInsidePolygon(15f, 5f, square), false); + } + + private static void check(String name, boolean result, boolean expected) { + if (result == expected) { + Log.d(TAG, "PASS: " + name); + } else { + Log.e(TAG, "FAIL: " + name + + " (got " + result + ", expected " + expected + ")"); + } + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java new file mode 100644 index 00000000..e9454381 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java @@ -0,0 +1,293 @@ +package com.openpositioning.PositionMe.sensors; + +import android.util.Log; + +import com.google.android.gms.maps.model.LatLng; +import com.openpositioning.PositionMe.data.remote.FloorplanApiClient; +import com.openpositioning.PositionMe.utils.BuildingPolygon; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +class MapMatcher { + + private static final String TAG_LOAD = "MapMatcher"; + + /** Constant: earth meters per degree (matches ParticleFilter). */ + private static final double METERS_PER_DEGREE = 111111.0; + + // ========================================================================= + // Inner data class + // ========================================================================= + + /** + * A single wall polygon/polyline in the local coordinate frame. + */ + static class WallFeature { + /** Vertices as float[]{eastingM, northingM} in the local frame. */ + final List localPoints; + /** True for MultiPolygon/Polygon features; false for LineString features. */ + final boolean isPolygon; + + WallFeature(List pts, boolean isPolygon) { + this.localPoints = pts; + this.isPolygon = isPolygon; + } + } + + // ========================================================================= + // State + // ========================================================================= + + private final SensorFusion sensorFusion; + + // Null until tryLoadBuilding() succeeds + private String loadedBuildingId = null; + private LatLng mapOrigin = null; + private int numFloors = 0; + // Maps floor displayName ("GF", "F1", ...) to its floorShapes index. + // Built at load time; used by getLikelyFloorIndex() to resolve WiFi floor integers. + private Map displayNameToIndex = null; + + // Per-floor wall feature arrays (size = numFloors, indexed 0..numFloors-1) + // Null until tryLoadBuilding() succeeds; getWallsForFloor() guards against this. + @SuppressWarnings("unchecked") + private List[] wallsByFloor = null; + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * Creates a MapMatcher that reads data from the given SensorFusion instance. + * No loading is performed until tryLoadBuilding() is called. + * + * @param sf the SensorFusion singleton + */ + MapMatcher(SensorFusion sf) { + this.sensorFusion = sf; + } + + // ========================================================================= + // Step 1: loading + // ========================================================================= + + /** + * Loads wall/stair/lift features from the floorplan cache for the current building. + * + * Detection order: + * 1. Direct look-up by preferredBuildingId if non-null. + * 2. Ray-cast against API outline polygons using origin. + * 3. Closest building center as last resort. + * + * Silently returns if origin is null or no building is found. + * Runs the MapGeometry self-test (MapGeometry.selfTest()) on success. + * + * @param preferredBuildingId building name key (e.g. "nucleus_building"); may be null + * @param origin local coordinate frame origin from ParticleFilter + */ + @SuppressWarnings("unchecked") + void tryLoadBuilding(String preferredBuildingId, LatLng origin) { + if (origin == null) { + Log.w(TAG_LOAD, "tryLoadBuilding: origin is null — aborting"); + return; + } + + // 1. Preferred building by name + FloorplanApiClient.BuildingInfo building = null; + if (preferredBuildingId != null && !preferredBuildingId.isEmpty()) { + building = sensorFusion.getFloorplanBuilding(preferredBuildingId); + if (building != null) { + Log.d(TAG_LOAD, "Found preferred building: " + preferredBuildingId); + } + } + + // 2. Polygon detection against all cached buildings + if (building == null) { + for (FloorplanApiClient.BuildingInfo b : sensorFusion.getFloorplanBuildings()) { + List outline = b.getOutlinePolygon(); + if (outline != null && outline.size() >= 3 + && BuildingPolygon.pointInPolygon(origin, outline)) { + building = b; + Log.d(TAG_LOAD, "Building detected via polygon: " + b.getName()); + break; + } + } + } + + // 3. Closest center fallback + if (building == null) { + building = closestBuilding(origin); + if (building != null) { + Log.d(TAG_LOAD, "Building selected by closest center: " + building.getName()); + } + } + + if (building == null) { + Log.w(TAG_LOAD, "tryLoadBuilding: no building found in cache — map matching disabled"); + return; + } + + List floorShapes = building.getFloorShapesList(); + if (floorShapes == null || floorShapes.isEmpty()) { + Log.w(TAG_LOAD, "tryLoadBuilding: building has no floor shapes"); + return; + } + + // Allocate per-floor lists + numFloors = floorShapes.size(); + mapOrigin = origin; + loadedBuildingId = building.getName(); + + // Build displayName → floorIndex map for WiFi floor resolution + displayNameToIndex = new HashMap<>(); + for (int f = 0; f < numFloors; f++) { + String name = floorShapes.get(f).getDisplayName(); + if (name != null) displayNameToIndex.put(name, f); + } + + wallsByFloor = new List[numFloors]; + + for (int f = 0; f < numFloors; f++) { + wallsByFloor[f] = new ArrayList<>(); + + FloorplanApiClient.FloorShapes floor = floorShapes.get(f); + for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) { + if (!"wall".equals(feature.getIndoorType())) continue; + + String geoType = feature.getGeometryType(); + boolean isPoly = "MultiPolygon".equals(geoType) || "Polygon".equals(geoType); + + for (List part : feature.getParts()) { + List localPts = new ArrayList<>(part.size()); + for (LatLng ll : part) { + localPts.add(latLngToLocal(ll, origin)); + } + if (localPts.size() < 2) continue; + wallsByFloor[f].add(new WallFeature(localPts, isPoly)); + } + } + + Log.d(TAG_LOAD, String.format("Floor %d (%s): walls=%d", + f, floor.getDisplayName(), wallsByFloor[f].size())); + } + + Log.d(TAG_LOAD, "Loaded: building=" + loadedBuildingId + + " floors=" + numFloors); + + // Step 2: run geometry self-test + MapGeometry.selfTest(); + } + + /** + * Returns true once building data has been successfully loaded. + * All three conditions must hold: building ID set, origin set, and at least one floor parsed. + */ + boolean isInitialised() { + return loadedBuildingId != null && mapOrigin != null && numFloors > 0; + } + + // ========================================================================= + // Step 2: floor accessor + // ========================================================================= + + /** Returns the wall features for the given floor, or an empty list if out of range. */ + List getWallsForFloor(int floorIndex) { + if (wallsByFloor == null || floorIndex < 0 || floorIndex >= numFloors) { + return new ArrayList<>(); + } + return wallsByFloor[floorIndex]; + } + + // ========================================================================= + // Utilities + // ========================================================================= + + /** Returns the loaded building ID, or null if not loaded. */ + String getLoadedBuildingId() { + return loadedBuildingId; + } + + /** + * Returns the floor index most likely occupied by the user, derived from + * the current WiFi floor reading mapped to a floor display name. + * + * WiFi integers are converted to display names per building. The display name is then looked up in the index map built + * at load time, so the result is correct regardless of API floor ordering. + * + * Falls back to 0 if WiFi returns an unrecognised value. + */ + int getLikelyFloorIndex() { + if (displayNameToIndex == null) return 0; + int wifiFloor = sensorFusion.getWifiFloor(); + String name = wifiFloorToDisplayName(wifiFloor); + Integer idx = (name != null) ? displayNameToIndex.get(name) : null; + int result = (idx != null) ? idx : 0; + Log.d(TAG_LOAD, "getLikelyFloorIndex: wifiFloor=" + wifiFloor + + " elevation=" + sensorFusion.getElevation() + + " name=" + name + " index=" + result); + return result; + } + + /** + * Converts a WiFi floor integer to the display name used in the floorplan API + * for the currently loaded building. + * + * Nucleus / Murchison: 0=GF, 1=F1, 2=F2, 3=F3 (LG not available via WiFi). + * Generic fallback: treats the integer as a display-name string ("0", "1", ...). + */ + private String wifiFloorToDisplayName(int wifiFloor) { + if ("nucleus_building".equals(loadedBuildingId) + || "murchison_house".equals(loadedBuildingId)) { + switch (wifiFloor) { + case 0: return "GF"; + case 1: return "F1"; + case 2: return "F2"; + case 3: return "F3"; + default: + // Floor not covered by WiFi (e.g. LG) — infer from barometer elevation + return (sensorFusion.getElevation() < -1.5f) ? "LG" : "GF"; + } + } + return String.valueOf(wifiFloor); + } + + + /** + * Converts a WGS84 LatLng to a local easting/northing offset (meters) + * relative to origin, using the same formula as ParticleFilter. + */ + private static float[] latLngToLocal(LatLng point, LatLng origin) { + double latDiff = point.latitude - origin.latitude; + double lonDiff = point.longitude - origin.longitude; + float northing = (float) (latDiff * METERS_PER_DEGREE); + float easting = (float) (lonDiff * METERS_PER_DEGREE + * Math.cos(Math.toRadians(origin.latitude))); + return new float[]{easting, northing}; + } + + /** + * Returns the cached building whose centre is geographically closest to origin. + * Used as a last-resort fallback when no polygon contains the origin. + + * Distance is computed as squared Euclidean in lat/lon degrees. + */ + private FloorplanApiClient.BuildingInfo closestBuilding(LatLng origin) { + FloorplanApiClient.BuildingInfo best = null; + double bestDist = Double.MAX_VALUE; + for (FloorplanApiClient.BuildingInfo b : sensorFusion.getFloorplanBuildings()) { + LatLng center = b.getCenter(); + double dl = center.latitude - origin.latitude; + double dn = center.longitude - origin.longitude; + double dist = dl * dl + dn * dn; + if (dist < bestDist) { + bestDist = dist; + best = b; + } + } + return best; + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 900d44f9..3c98a543 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -4,6 +4,7 @@ import android.util.Log; +import java.util.List; import java.util.Random; /** @@ -45,6 +46,9 @@ public class ParticleFilter { private final Random random; private final SensorFusion sensorFusion; + // Map matcher for wall-constrained prediction (null-safe: if null, predict runs unchanged) + private MapMatcher mapMatcher; + // Defines a single hypothesis about the user's position private static class Particle{ // Easting offset from local origin @@ -129,13 +133,24 @@ private void initialisePosition() { /** * Function to retry initial position estimation if it was deferred at construction - */ + */ public void tryInitialise() { if (!initialised) { initialisePosition(); } } + /** + * Wires in a MapMatcher for wall-constrained particle prediction. + * When set, predict() will reject movements that enter or cross walls. + * Safe to call with null to disable map matching. + * + * @param mm the MapMatcher instance, or null + */ + public void setMapMatcher(MapMatcher mm) { + this.mapMatcher = mm; + } + /** * Distributes particles as an isotropic 2D gaussian distribution around * (centerX, centerY) and assigns equal weights to each particle (1/N). @@ -173,6 +188,53 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { particles[i].y += deltaNorth + noiseY; } + // Wall rejection: zero weights of particles that land inside a wall polygon + Log.d("ParticleFilter", "predict() reached wall check — mapMatcher=" + + (mapMatcher == null ? "null" : (mapMatcher.isInitialised() ? "ready" : "not initialised"))); + if (mapMatcher == null) { + Log.w("ParticleFilter", "Wall rejection skipped: mapMatcher is null"); + } else if (!mapMatcher.isInitialised()) { + Log.w("ParticleFilter", "Wall rejection skipped: mapMatcher not initialised" + + " (building=" + mapMatcher.getLoadedBuildingId() + ")"); + } else { + int floorIndex = mapMatcher.getLikelyFloorIndex(); + List walls = mapMatcher.getWallsForFloor(floorIndex); + int zeroed = 0; + for (int i = 0; i < Num_Particles; i++) { + for (MapMatcher.WallFeature wall : walls) { + if (!wall.isPolygon) continue; + if (MapGeometry.isPointInsidePolygon( + particles[i].x, particles[i].y, wall.localPoints)) { + particles[i].weight = 0f; + zeroed++; + break; + } + } + } + Log.d("ParticleFilter", "Wall rejection (floor " + floorIndex + "): " + + zeroed + "/" + Num_Particles + " particles zeroed this step"); + + // Renormalise remaining weights + float weightSum = 0f; + for (int i = 0; i < Num_Particles; i++) { + weightSum += particles[i].weight; + } + if (weightSum == 0f) { + Log.w("ParticleFilter", "All particles rejected — recovering with uniform respread around estimate"); + float spread = 2.0f; + float uniform = 1.0f / Num_Particles; + for (int i = 0; i < Num_Particles; i++) { + particles[i].x = estimatedX + (float) (random.nextGaussian() * spread); + particles[i].y = estimatedY + (float) (random.nextGaussian() * spread); + particles[i].weight = uniform; + } + } else { + for (int i = 0; i < Num_Particles; i++) { + particles[i].weight /= weightSum; + } + } + } + // Update weighted mean estimate estimatedX = 0f; estimatedY = 0f; @@ -183,7 +245,7 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { //Debug test Log.d("ParticleFilter", "Delta: (" + deltaEast + ", " + deltaNorth - + " ; Estimate: ("+ estimatedX + ", " + estimatedY +")"); + + " ; Estimate: ("+ estimatedX + ", " + estimatedY +")"); } /** @@ -252,6 +314,58 @@ public void updateWeights(LatLng measurementLatLng, float accuracy) { + "Estimate: (" + estimatedX + ", " + estimatedY + ") " + "Best particle: (" + particles[maxIndex].x + ", " + particles[maxIndex].y + ") " + "Max weight: " + maxWeight); + + resample(); + } + + /** + * Systematic resampling of particles for weight degeneration solution (SIR) + */ + private void resample() { + // Calculate effective sample size as 1 / sum of particle weights squared + float sumWeightsSquared = 0f; + for (int i = 0; i < Num_Particles; i++) { + sumWeightsSquared += particles[i].weight * particles[i].weight; + } + float N_eff = 1.0f / sumWeightsSquared; + + // Early return if effective sample size >= set threshold + float threshold = Num_Particles / 2.0f; + if (N_eff >= threshold) { + Log.d("ParticleFilter", "Resampling skipped"); + return; + } + + // Build cumulative sum array from normalised weights + float[] cumulativeSum = new float[Num_Particles]; + cumulativeSum[0] = particles[0].weight; + for (int i = 1; i < Num_Particles; i++) { + cumulativeSum[i] = cumulativeSum[i-1] + particles[i].weight; + } + + // Set uniform staring point + float overN = 1.0f / Num_Particles; + float u1 = random.nextFloat() * overN; + + Particle[] resampledParticles = new Particle[Num_Particles]; + float uniformWeighting = overN; + int j = 0; + + for (int i = 0; i < Num_Particles; i++) { + float u = u1 + i * overN; + + while (j < Num_Particles - 1 && u > cumulativeSum[j]) { + j++; + } + + resampledParticles[i] = new Particle( + particles[j].x, particles[j].y, uniformWeighting); + } + + // Replace particle array with resampled particles + System.arraycopy(resampledParticles, 0, particles, 0, Num_Particles); + + Log.d("ParticleFilter", "Resampling complete: all weights reset to " + uniformWeighting); } /** @@ -306,4 +420,4 @@ public float[] getEstimatedLocalPosition() { public LatLng getOrigin() { return originLatLng; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index d9af5ed9..42c16ef3 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -11,6 +11,7 @@ import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; @@ -64,6 +65,7 @@ public class SensorFusion implements SensorEventListener { private TrajectoryRecorder recorder; private WifiPositionManager wifiPositionManager; private ParticleFilter particleFilter; + private MapMatcher mapMatcher; // Movement sensor instances (lifecycle managed here) private MovementSensor accelerometerSensor; @@ -164,6 +166,10 @@ public void setContext(Context context) { // Initialise particle filter this.particleFilter = new ParticleFilter(); + // Create map matcher and wire into particle filter + this.mapMatcher = new MapMatcher(this); + particleFilter.setMapMatcher(mapMatcher); + wiFiPositioning.setParticleFilter(particleFilter); long bootTime = SystemClock.uptimeMillis(); @@ -336,6 +342,9 @@ public void startRecording() { eventHandler.resetBootTime(recorder.getBootTime()); particleFilter.tryInitialise(); + // Try to load building map now that particles are initialised + tryTriggerMapMatcher(getSelectedBuildingId()); + // Handover WiFi/BLE scan lifecycle from activity callbacks to foreground service. stopWirelessCollectors(); @@ -429,6 +438,9 @@ public void setFloorplanBuildings(List building } floorplanBuildingCache.put(building.getName(), building); } + + // Attempt to load building map now that the cache is populated + tryTriggerMapMatcher(getSelectedBuildingId()); } /** @@ -453,6 +465,25 @@ public List getFloorplanBuildings() { return new ArrayList<>(floorplanBuildingCache.values()); } + /** + * Attempts to load building map data into the map matcher when all required + * conditions are met: particle filter initialised, origin set, and floorplan + * cache non-empty. + * + *

Called from both {@link #setFloorplanBuildings} and {@link #startRecording} + * because either can fire first; all guards are checked each time.

+ * + * @param preferredBuildingId building name hint; may be null for auto-detection + */ + private void tryTriggerMapMatcher(String preferredBuildingId) { + if (mapMatcher == null) return; + if (!particleFilter.isInitialised()) return; + if (particleFilter.getOrigin() == null) return; + if (floorplanBuildingCache.isEmpty()) return; + mapMatcher.tryLoadBuilding(preferredBuildingId, particleFilter.getOrigin()); + Log.d("Debug", "MapMatcher ready: " + mapMatcher.isInitialised()); + } + /** * Writes the initial position and heading into the trajectory protobuf. * Should be called after startRecording() and setStartGNSSLatitude(). @@ -653,8 +684,8 @@ public void onLocationChanged(@NonNull Location location) { state.longitude = (float) location.getLongitude(); recorder.addGnssData(location); - // Update particle weights with GNSS measurement - if (particleFilter.isInitialised()) { + // Update particle weights with GNSS measurement + if (particleFilter.isInitialised()) { LatLng gnssLatLng = new LatLng(location.getLatitude(), location.getLongitude()); float accuracy = location.getAccuracy(); particleFilter.updateWeights(gnssLatLng, accuracy); diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java b/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java index 9765b044..0784b9ce 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java @@ -142,7 +142,7 @@ public PdrProcessing(Context context) { public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertime, float headingRad) { if (accelMagnitudeOvertime == null || accelMagnitudeOvertime.size() < MIN_REQUIRED_SAMPLES) { return new float[]{this.positionX, this.positionY}; // Return current position without update - // - TODO - temporary solution of the empty list issue + // - TODO - temporary solution of the empty list issue } // Change angle so zero rad is east @@ -153,7 +153,7 @@ public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertim // return current position, do not update return new float[]{this.positionX, this.positionY}; } - + // Calculate step length if(!useManualStep) { //ArrayList accelMagnitudeFiltered = filter(accelMagnitudeOvertime); @@ -308,12 +308,12 @@ public boolean estimateElevator(float[] gravity, float[] acc) { // get horizontal and vertical acceleration magnitude float verticalAcc = (float) Math.sqrt( Math.pow((acc[0] * gravity[0]/g),2) + - Math.pow((acc[1] * gravity[1]/g), 2) + - Math.pow((acc[2] * gravity[2]/g), 2)); + Math.pow((acc[1] * gravity[1]/g), 2) + + Math.pow((acc[2] * gravity[2]/g), 2)); float horizontalAcc = (float) Math.sqrt( Math.pow((acc[0] * (1 - gravity[0]/g)), 2) + - Math.pow((acc[1] * (1 - gravity[1]/g)), 2) + - Math.pow((acc[2] * (1 - gravity[2]/g)), 2)); + Math.pow((acc[1] * (1 - gravity[1]/g)), 2) + + Math.pow((acc[2] * (1 - gravity[2]/g)), 2)); // Save into buffer to compare with past values this.verticalAccel.putNewest(verticalAcc); this.horizontalAccel.putNewest(horizontalAcc); From 9c383c138c94cf734e1e78faa23ddb967b404823 Mon Sep 17 00:00:00 2001 From: Juraj02 Date: Tue, 31 Mar 2026 16:24:41 +0100 Subject: [PATCH 07/14] Add trajectory map data display and UI improvements --- .../fragment/RecordingFragment.java | 93 ++++++- .../fragment/TrajectoryMapFragment.java | 233 +++++++++++++++--- .../PositionMe/sensors/SensorFusion.java | 14 ++ app/src/main/res/drawable/legend_dot.xml | 5 + .../res/layout/fragment_trajectory_map.xml | 105 ++++++++ app/src/main/res/values/strings.xml | 5 + gradlew | 0 7 files changed, 415 insertions(+), 40 deletions(-) create mode 100644 app/src/main/res/drawable/legend_dot.xml mode change 100644 => 100755 gradlew diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java index 340843ca..243f7004 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java @@ -84,6 +84,14 @@ public class RecordingFragment extends Fragment { private float previousPosX = 0f; private float previousPosY = 0f; + // Fused trajectory update loop (1-second interval) + private Handler fusedTrajectoryHandler; + private LatLng lastFusedPos = null; + private LatLng lastGnssObsPos = null; + private LatLng lastWifiObsPos = null; + private float previousObsPosX = 0f; + private float previousObsPosY = 0f; + // References to the child map fragment private TrajectoryMapFragment trajectoryMapFragment; @@ -96,6 +104,15 @@ public void run() { } }; + /** Updates the fused best-estimate marker and trajectory polyline every 1 second. */ + private final Runnable fusedTrajectoryTask = new Runnable() { + @Override + public void run() { + updateFusedDisplay(); + fusedTrajectoryHandler.postDelayed(this, 1000); + } + }; + public RecordingFragment() { // Required empty public constructor } @@ -107,6 +124,7 @@ public void onCreate(Bundle savedInstanceState) { Context context = requireActivity(); this.settings = PreferenceManager.getDefaultSharedPreferences(context); this.refreshDataHandler = new Handler(); + this.fusedTrajectoryHandler = new Handler(); } @Nullable @@ -193,6 +211,9 @@ public void onViewCreated(@NonNull View view, // The blinking effect for recIcon blinkingRecordingIcon(); + // Start the 1-second fused position + trajectory update loop + fusedTrajectoryHandler.postDelayed(fusedTrajectoryTask, 1000); + // Start the timed or indefinite UI refresh if (this.settings.getBoolean("split_trajectory", false)) { // A maximum recording time is set @@ -247,6 +268,30 @@ private void onAddTestPoint() { trajectoryMapFragment.addTestPointMarker(idx, ts, cur); } + /** + * Updates the purple fused trajectory polyline every 1 second. + * Uses the WiFi fix as the best estimate when available; falls back to the current + * PDR-derived position. Raw GNSS is intentionally excluded here to avoid polyline jumps. + * The position marker (arrow) is updated separately in the 200ms loop via + * {@link #updateUIandPosition}. + */ + private void updateFusedDisplay() { + if (trajectoryMapFragment == null) return; + + // Prefer WiFi fix; fall back to PDR-derived current location (no raw GNSS) + LatLng bestEstimate = sensorFusion.getLatLngWifiPositioning(); + if (bestEstimate == null) { + bestEstimate = trajectoryMapFragment.getCurrentLocation(); + } + if (bestEstimate == null) return; + + // Append to fused trajectory only if the position has moved > 0.3 m + if (lastFusedPos == null + || UtilFunctions.distanceBetweenPoints(lastFusedPos, bestEstimate) > 0.3) { + trajectoryMapFragment.updateFusedTrajectory(bestEstimate); + lastFusedPos = bestEstimate; + } + } /** * Update the UI with sensor data and pass map updates to TrajectoryMapFragment. @@ -276,10 +321,14 @@ private void updateUIandPosition() { new float[]{ pdrValues[0] - previousPosX, pdrValues[1] - previousPosY } ); - // Pass the location + orientation to the map + // Update the red PDR polyline and move the position marker (with optional smoothing). + // Prefer the particle filter estimate for the marker; fall back to PDR-derived position. if (trajectoryMapFragment != null) { - trajectoryMapFragment.updateUserLocation(newLocation, - (float) Math.toDegrees(sensorFusion.passOrientation())); + float orientation = (float) Math.toDegrees(sensorFusion.passOrientation()); + trajectoryMapFragment.updateUserLocation(newLocation, orientation); + LatLng fusedPos = sensorFusion.getFusedPosition(); + trajectoryMapFragment.updateFusedPosition( + fusedPos != null ? fusedPos : newLocation, orientation); } } @@ -302,6 +351,42 @@ private void updateUIandPosition() { } } + // --- Colour-coded observation markers --- + + // GNSS observation: add a blue marker whenever the raw GNSS fix changes + float[] gnssRaw = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG); + if (gnssRaw != null) { + LatLng gnssObs = new LatLng(gnssRaw[0], gnssRaw[1]); + if (!gnssObs.equals(lastGnssObsPos)) { + trajectoryMapFragment.addObservationMarker(gnssObs, + TrajectoryMapFragment.ObservationSource.GNSS); + lastGnssObsPos = gnssObs; + } + } + + // WiFi observation: add an orange marker whenever a new WiFi fix arrives + LatLng wifiObs = sensorFusion.getLatLngWifiPositioning(); + if (wifiObs != null && !wifiObs.equals(lastWifiObsPos)) { + trajectoryMapFragment.addObservationMarker(wifiObs, + TrajectoryMapFragment.ObservationSource.WIFI); + lastWifiObsPos = wifiObs; + } + + // PDR observation: add a red marker whenever PDR position has moved ≥ 1 m + LatLng currentLoc = trajectoryMapFragment.getCurrentLocation(); + if (currentLoc != null) { + double pdrDelta = Math.sqrt( + Math.pow(pdrValues[0] - previousObsPosX, 2) + + Math.pow(pdrValues[1] - previousObsPosY, 2)); + if (pdrDelta >= 1.0) { + trajectoryMapFragment.addObservationMarker(currentLoc, + TrajectoryMapFragment.ObservationSource.PDR); + previousObsPosX = pdrValues[0]; + previousObsPosY = pdrValues[1]; + } + } + + // Update previous previousPosX = pdrValues[0]; previousPosY = pdrValues[1]; @@ -323,6 +408,7 @@ private void blinkingRecordingIcon() { public void onPause() { super.onPause(); refreshDataHandler.removeCallbacks(refreshDataTask); + fusedTrajectoryHandler.removeCallbacks(fusedTrajectoryTask); } @Override @@ -331,6 +417,7 @@ public void onResume() { if(!this.settings.getBoolean("split_trajectory", false)) { refreshDataHandler.postDelayed(refreshDataTask, 500); } + fusedTrajectoryHandler.postDelayed(fusedTrajectoryTask, 1000); } private int testPointIndex = 0; diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 479ea51b..89490637 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -36,11 +36,11 @@ /** * A fragment responsible for displaying a trajectory map using Google Maps. - *

+ * * The TrajectoryMapFragment provides a map interface for visualizing movement trajectories, * GNSS tracking, and indoor mapping. It manages map settings, user interactions, and real-time * updates to user location and GNSS markers. - *

+ * * Key Features: * - Displays a Google Map with support for different map types (Hybrid, Normal, Satellite). * - Tracks and visualizes user movement using polylines. @@ -57,6 +57,18 @@ public class TrajectoryMapFragment extends Fragment { + /** Sources for colour-coded position observations on the map. */ + public enum ObservationSource { GNSS, WIFI, PDR } + + // Observation marker colours + private static final int COLOR_GNSS_OBS = 0xFF2196F3; // blue (unused directly; hue used below) + private static final int COLOR_WIFI_OBS = 0xFFFF9800; // orange + private static final int COLOR_PDR_OBS = 0xFFF44336; // red + private static final int COLOR_FUSED = 0xFF9C27B0; // purple – fused trajectory + + /** Weight applied to the newest sample in the EMA smoothing filter (0 < α ≤ 1). */ + private static final float EMA_ALPHA = 0.3f; + private GoogleMap gMap; // Google Maps instance private LatLng currentLocation; // Stores the user's current location private Marker orientationMarker; // Marker representing user's heading @@ -71,6 +83,20 @@ public class TrajectoryMapFragment extends Fragment { private Polyline gnssPolyline; // Polyline for GNSS path private LatLng lastGnssLocation = null; // Stores the last GNSS location + // Fused trajectory – updated every 1 s or on movement + private Polyline fusedTrajectoryPolyline; + private LatLng lastFusedTrajectoryPoint = null; + + // One moving marker per source — each updates in place rather than accumulating + private Marker gnssObsMarker = null; + private Marker wifiObsMarker = null; + private Marker pdrObsMarker = null; + + // EMA smoothing state + private boolean smoothingEnabled = false; + private double smoothedLat = Double.NaN; + private double smoothedLng = Double.NaN; + private LatLng pendingCameraPosition = null; // Stores pending camera movement private boolean hasPendingCameraMove = false; // Tracks if camera needs to move @@ -91,6 +117,7 @@ public class TrajectoryMapFragment extends Fragment { private SwitchMaterial gnssSwitch; private SwitchMaterial autoFloorSwitch; + private SwitchMaterial smoothSwitch; private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton; private TextView floorLabel; @@ -120,6 +147,7 @@ public void onViewCreated(@NonNull View view, switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner); gnssSwitch = view.findViewById(R.id.gnssSwitch); autoFloorSwitch = view.findViewById(R.id.autoFloor); + smoothSwitch = view.findViewById(R.id.smoothSwitch); floorUpButton = view.findViewById(R.id.floorUpButton); floorDownButton = view.findViewById(R.id.floorDownButton); floorLabel = view.findViewById(R.id.floorLabel); @@ -183,6 +211,12 @@ public void onMapReady(@NonNull GoogleMap googleMap) { } }); + // Smoothing toggle + if (smoothSwitch != null) { + smoothSwitch.setOnCheckedChangeListener((btn, isChecked) -> + setSmoothingEnabled(isChecked)); + } + // Auto-floor toggle: start/stop periodic floor evaluation sensorFusion = SensorFusion.getInstance(); autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { @@ -246,6 +280,13 @@ private void initMapSettings(GoogleMap map) { .width(5f) .add() // start empty ); + + // Fused best-estimate trajectory in purple + fusedTrajectoryPolyline = map.addPolyline(new PolylineOptions() + .color(COLOR_FUSED) + .width(8f) + .add() // start empty + ); } @@ -301,60 +342,31 @@ public void onNothingSelected(AdapterView parent) {} } /** - * Update the user's current location on the map, create or move orientation marker, - * and append to polyline if the user actually moved. + * Records the user’s current PDR-derived location and extends the red trajectory polyline. + * Does NOT move the orientation marker — marker updates are handled exclusively by + * {@link #updateFusedPosition} to avoid competing updates from different loops. * - * @param newLocation The new location to plot. - * @param orientation The user’s heading (e.g. from sensor fusion). + * @param newLocation The new PDR-derived location. + * @param orientation Unused here; kept for API compatibility. */ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) { if (gMap == null) return; - // Keep track of current location LatLng oldLocation = this.currentLocation; this.currentLocation = newLocation; - // If no marker, create it - if (orientationMarker == null) { - orientationMarker = gMap.addMarker(new MarkerOptions() - .position(newLocation) - .flat(true) - .title("Current Position") - .icon(BitmapDescriptorFactory.fromBitmap( - UtilFunctions.getBitmapFromVector(requireContext(), - R.drawable.ic_baseline_navigation_24))) - ); - gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f)); - } else { - // Update marker position + orientation - orientationMarker.setPosition(newLocation); - orientationMarker.setRotation(orientation); - // Move camera a bit - gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation)); - } - - // Extend polyline if movement occurred - /*if (oldLocation != null && !oldLocation.equals(newLocation) && polyline != null) { - List points = new ArrayList<>(polyline.getPoints()); - points.add(newLocation); - polyline.setPoints(points); - }*/ - // Extend polyline + // Extend the red PDR polyline if (polyline != null) { List points = new ArrayList<>(polyline.getPoints()); - - // First position fix: add the first polyline point if (oldLocation == null) { points.add(newLocation); polyline.setPoints(points); } else if (!oldLocation.equals(newLocation)) { - // Subsequent movement: append a new polyline point points.add(newLocation); polyline.setPoints(points); } } - // Update indoor map overlay if (indoorMapManager != null) { indoorMapManager.setCurrentLocation(newLocation); @@ -443,6 +455,134 @@ public void updateGNSS(@NonNull LatLng gnssLocation) { } + /** + * Updates the user's best-estimate position on the map. + * When smoothing is enabled an Exponential Moving Average filter (α = {@value #EMA_ALPHA}) + * is applied before moving the orientation marker, producing visibly smoother motion. + * + * @param pos Raw best-estimate position from the fusion / fallback pipeline. + * @param orientation Device heading in degrees (clockwise from north). + */ + public void updateFusedPosition(@NonNull LatLng pos, float orientation) { + if (gMap == null) return; + + LatLng displayPos; + if (smoothingEnabled) { + if (Double.isNaN(smoothedLat)) { + // Seed the filter on first call + smoothedLat = pos.latitude; + smoothedLng = pos.longitude; + } else { + smoothedLat = EMA_ALPHA * pos.latitude + (1.0 - EMA_ALPHA) * smoothedLat; + smoothedLng = EMA_ALPHA * pos.longitude + (1.0 - EMA_ALPHA) * smoothedLng; + } + displayPos = new LatLng(smoothedLat, smoothedLng); + } else { + displayPos = pos; + } + + if (orientationMarker == null) { + orientationMarker = gMap.addMarker(new MarkerOptions() + .position(displayPos) + .flat(true) + .title("Current Position") + .icon(BitmapDescriptorFactory.fromBitmap( + UtilFunctions.getBitmapFromVector(requireContext(), + R.drawable.ic_baseline_navigation_24)))); + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(displayPos, 19f)); + } else { + orientationMarker.setPosition(displayPos); + orientationMarker.setRotation(orientation); + gMap.moveCamera(CameraUpdateFactory.newLatLng(displayPos)); + } + + currentLocation = displayPos; + + if (indoorMapManager != null) { + indoorMapManager.setCurrentLocation(displayPos); + setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE); + } + } + + /** + * Appends a point to the purple fused-trajectory polyline. + * Only adds a point when the position has actually changed to avoid duplicate vertices. + * + * @param pos New fused position to record. + */ + public void updateFusedTrajectory(@NonNull LatLng pos) { + if (gMap == null || fusedTrajectoryPolyline == null) return; + if (pos.equals(lastFusedTrajectoryPoint)) return; + + List points = new ArrayList<>(fusedTrajectoryPolyline.getPoints()); + points.add(pos); + fusedTrajectoryPolyline.setPoints(points); + lastFusedTrajectoryPoint = pos; + } + + /** + * Places or moves a colour-coded observation marker for the given source. + * Each source maintains exactly one marker on the map — it moves to the latest position + * rather than accumulating multiple markers. + * + * @param pos Absolute position of the observation. + * @param source Which positioning source produced this observation. + */ + public void addObservationMarker(@NonNull LatLng pos, @NonNull ObservationSource source) { + if (gMap == null) return; + + Marker existing; + float hue; + String title; + switch (source) { + case GNSS: + existing = gnssObsMarker; + hue = BitmapDescriptorFactory.HUE_AZURE; + title = "GNSS"; + break; + case WIFI: + existing = wifiObsMarker; + hue = BitmapDescriptorFactory.HUE_ORANGE; + title = "WiFi"; + break; + default: // PDR + existing = pdrObsMarker; + hue = BitmapDescriptorFactory.HUE_RED; + title = "PDR"; + break; + } + + if (existing != null) { + existing.setPosition(pos); + } else { + Marker m = gMap.addMarker(new MarkerOptions() + .position(pos) + .title(title) + .anchor(0.5f, 0.5f) + .icon(BitmapDescriptorFactory.defaultMarker(hue))); + switch (source) { + case GNSS: gnssObsMarker = m; break; + case WIFI: wifiObsMarker = m; break; + default: pdrObsMarker = m; break; + } + } + } + + /** + * Enables or disables the EMA position smoothing filter. + * Disabling resets the filter state so the next call to + * {@link #updateFusedPosition} re-seeds it from the raw position. + * + * @param enabled {@code true} to enable smoothing. + */ + public void setSmoothingEnabled(boolean enabled) { + this.smoothingEnabled = enabled; + if (!enabled) { + smoothedLat = Double.NaN; + smoothedLng = Double.NaN; + } + } + /** * Remove GNSS marker if user toggles it off */ @@ -509,6 +649,21 @@ public void clearMapAndReset() { } testPointMarkers.clear(); + // Clear all per-source observation markers + if (gnssObsMarker != null) { gnssObsMarker.remove(); gnssObsMarker = null; } + if (wifiObsMarker != null) { wifiObsMarker.remove(); wifiObsMarker = null; } + if (pdrObsMarker != null) { pdrObsMarker.remove(); pdrObsMarker = null; } + + // Clear fused trajectory + if (fusedTrajectoryPolyline != null) { + fusedTrajectoryPolyline.remove(); + fusedTrajectoryPolyline = null; + } + lastFusedTrajectoryPoint = null; + + // Reset EMA filter + smoothedLat = Double.NaN; + smoothedLng = Double.NaN; // Re-create empty polylines with your chosen colors if (gMap != null) { @@ -520,6 +675,10 @@ public void clearMapAndReset() { .color(Color.BLUE) .width(5f) .add()); + fusedTrajectoryPolyline = gMap.addPolyline(new PolylineOptions() + .color(COLOR_FUSED) + .width(8f) + .add()); } } diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index d9af5ed9..fea3609c 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -621,6 +621,20 @@ public LatLng getLatLngWifiPositioning() { return wifiPositionManager.getLatLngWifiPositioning(); } + /** + * Returns the best estimated position of the user from the particle filter. + * Returns {@code null} if the filter has not yet initialised, in which case + * the caller should fall back to the PDR-derived absolute position. + * + * @return particle filter position estimate, or {@code null} if not yet initialised. + */ + public LatLng getFusedPosition() { + if (particleFilter != null && particleFilter.isInitialised()) { + return particleFilter.getEstimatedPosition(); + } + return null; + } + /** * Returns the current floor the user is on, obtained using WiFi positioning. * diff --git a/app/src/main/res/drawable/legend_dot.xml b/app/src/main/res/drawable/legend_dot.xml new file mode 100644 index 00000000..bb6f7dec --- /dev/null +++ b/app/src/main/res/drawable/legend_dot.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_trajectory_map.xml b/app/src/main/res/layout/fragment_trajectory_map.xml index a72425bf..bcfd77e5 100644 --- a/app/src/main/res/layout/fragment_trajectory_map.xml +++ b/app/src/main/res/layout/fragment_trajectory_map.xml @@ -43,6 +43,12 @@ android:layout_height="wrap_content" android:text="@string/auto_floor" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Floor Up button Choose Map ❇️ Auto Floor + Smooth + GNSS fix + WiFi fix + PDR step + Fused path GNSS error: Satellite Normal diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 2eef39d7c48e7951b070c2f247fd4b302209339e Mon Sep 17 00:00:00 2001 From: Juraj02 Date: Wed, 1 Apr 2026 15:21:55 +0100 Subject: [PATCH 08/14] Add collapsible controls panel and numbered observation circles --- .../fragment/RecordingFragment.java | 9 +- .../fragment/TrajectoryMapFragment.java | 140 ++++++++++++------ .../PositionMe/sensors/SensorFusion.java | 13 ++ .../res/layout/fragment_trajectory_map.xml | 25 ++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 143 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java index 243f7004..cdd20af8 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java @@ -321,12 +321,13 @@ private void updateUIandPosition() { new float[]{ pdrValues[0] - previousPosX, pdrValues[1] - previousPosY } ); - // Update the red PDR polyline and move the position marker (with optional smoothing). - // Prefer the particle filter estimate for the marker; fall back to PDR-derived position. + // Update the red PDR polyline and move the position marker. + // Priority: particle filter → WiFi fix → PDR-derived fallback. if (trajectoryMapFragment != null) { float orientation = (float) Math.toDegrees(sensorFusion.passOrientation()); trajectoryMapFragment.updateUserLocation(newLocation, orientation); LatLng fusedPos = sensorFusion.getFusedPosition(); + if (fusedPos == null) fusedPos = sensorFusion.getLatLngWifiPositioning(); trajectoryMapFragment.updateFusedPosition( fusedPos != null ? fusedPos : newLocation, orientation); } @@ -364,11 +365,13 @@ private void updateUIandPosition() { } } - // WiFi observation: add an orange marker whenever a new WiFi fix arrives + // WiFi observation: add an orange marker and correct the particle filter + // whenever a new WiFi fix arrives LatLng wifiObs = sensorFusion.getLatLngWifiPositioning(); if (wifiObs != null && !wifiObs.equals(lastWifiObsPos)) { trajectoryMapFragment.addObservationMarker(wifiObs, TrajectoryMapFragment.ObservationSource.WIFI); + sensorFusion.correctWithWifiPosition(wifiObs); lastWifiObsPos = wifiObs; } diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 89490637..49e41a31 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -30,7 +30,14 @@ import com.google.android.gms.maps.SupportMapFragment; import com.google.android.gms.maps.model.*; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; + +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; @@ -87,10 +94,11 @@ public enum ObservationSource { GNSS, WIFI, PDR } private Polyline fusedTrajectoryPolyline; private LatLng lastFusedTrajectoryPoint = null; - // One moving marker per source — each updates in place rather than accumulating - private Marker gnssObsMarker = null; - private Marker wifiObsMarker = null; - private Marker pdrObsMarker = null; + // Last 3 observation circle-markers per source; index 0 = newest (label "1") + private static final int OBS_HISTORY = 3; + private final Deque gnssObsMarkers = new ArrayDeque<>(); + private final Deque wifiObsMarkers = new ArrayDeque<>(); + private final Deque pdrObsMarkers = new ArrayDeque<>(); // EMA smoothing state private boolean smoothingEnabled = false; @@ -118,11 +126,14 @@ public enum ObservationSource { GNSS, WIFI, PDR } private SwitchMaterial gnssSwitch; private SwitchMaterial autoFloorSwitch; private SwitchMaterial smoothSwitch; + private SwitchMaterial pdrPathSwitch; private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton; private TextView floorLabel; private Button switchColorButton; private Polygon buildingPolygon; + private android.widget.LinearLayout switchesPanel; + private android.widget.ImageButton toggleControlsButton; public TrajectoryMapFragment() { @@ -148,10 +159,22 @@ public void onViewCreated(@NonNull View view, gnssSwitch = view.findViewById(R.id.gnssSwitch); autoFloorSwitch = view.findViewById(R.id.autoFloor); smoothSwitch = view.findViewById(R.id.smoothSwitch); + pdrPathSwitch = view.findViewById(R.id.pdrPathSwitch); floorUpButton = view.findViewById(R.id.floorUpButton); floorDownButton = view.findViewById(R.id.floorDownButton); floorLabel = view.findViewById(R.id.floorLabel); switchColorButton = view.findViewById(R.id.lineColorButton); + switchesPanel = view.findViewById(R.id.switchesPanel); + toggleControlsButton = view.findViewById(R.id.toggleControlsButton); + toggleControlsButton.setOnClickListener(v -> { + if (switchesPanel.getVisibility() == View.VISIBLE) { + switchesPanel.setVisibility(View.GONE); + toggleControlsButton.setImageResource(android.R.drawable.arrow_down_float); + } else { + switchesPanel.setVisibility(View.VISIBLE); + toggleControlsButton.setImageResource(android.R.drawable.arrow_up_float); + } + }); // Setup floor up/down UI hidden initially until we know there's an indoor map setFloorControlsVisibility(View.GONE); @@ -216,6 +239,13 @@ public void onMapReady(@NonNull GoogleMap googleMap) { smoothSwitch.setOnCheckedChangeListener((btn, isChecked) -> setSmoothingEnabled(isChecked)); } + + // PDR path toggle — hidden by default, shown only when user enables it + if (pdrPathSwitch != null) { + pdrPathSwitch.setOnCheckedChangeListener((btn, isChecked) -> { + if (polyline != null) polyline.setVisible(isChecked); + }); + } // Auto-floor toggle: start/stop periodic floor evaluation sensorFusion = SensorFusion.getInstance(); @@ -267,11 +297,12 @@ private void initMapSettings(GoogleMap map) { // Initialize indoor manager indoorMapManager = new IndoorMapManager(map); - // Initialize an empty polyline + // Initialize an empty polyline — hidden by default, toggled via pdrPathSwitch polyline = map.addPolyline(new PolylineOptions() .color(Color.RED) .width(5f) - .add() // start empty + .visible(false) + .add() ); // GNSS path in blue @@ -520,52 +551,73 @@ public void updateFusedTrajectory(@NonNull LatLng pos) { lastFusedTrajectoryPoint = pos; } - /** - * Places or moves a colour-coded observation marker for the given source. - * Each source maintains exactly one marker on the map — it moves to the latest position - * rather than accumulating multiple markers. - * - * @param pos Absolute position of the observation. - * @param source Which positioning source produced this observation. - */ + // Show a numbered circle marker for the given source, keeping last 3 positions. + // Circle 1 = latest, 3 = oldest. public void addObservationMarker(@NonNull LatLng pos, @NonNull ObservationSource source) { if (gMap == null) return; - Marker existing; - float hue; + Deque deque; + int solidColor; String title; switch (source) { case GNSS: - existing = gnssObsMarker; - hue = BitmapDescriptorFactory.HUE_AZURE; - title = "GNSS"; + deque = gnssObsMarkers; + solidColor = COLOR_GNSS_OBS; + title = "GNSS"; break; case WIFI: - existing = wifiObsMarker; - hue = BitmapDescriptorFactory.HUE_ORANGE; - title = "WiFi"; + deque = wifiObsMarkers; + solidColor = COLOR_WIFI_OBS; + title = "WiFi"; break; default: // PDR - existing = pdrObsMarker; - hue = BitmapDescriptorFactory.HUE_RED; - title = "PDR"; + deque = pdrObsMarkers; + solidColor = COLOR_PDR_OBS; + title = "PDR"; break; } - if (existing != null) { - existing.setPosition(pos); - } else { - Marker m = gMap.addMarker(new MarkerOptions() - .position(pos) - .title(title) - .anchor(0.5f, 0.5f) - .icon(BitmapDescriptorFactory.defaultMarker(hue))); - switch (source) { - case GNSS: gnssObsMarker = m; break; - case WIFI: wifiObsMarker = m; break; - default: pdrObsMarker = m; break; - } + if (deque.size() >= OBS_HISTORY) { + deque.removeLast().remove(); } + + // Bump labels on existing markers + int newLabel = deque.size() + 1; + for (Marker m : deque) { + m.setIcon(BitmapDescriptorFactory.fromBitmap(makeObsCircleBitmap(solidColor, newLabel))); + newLabel--; + } + + Marker newest = gMap.addMarker(new MarkerOptions() + .position(pos) + .title(title) + .anchor(0.5f, 0.5f) + .zIndex(1f) + .icon(BitmapDescriptorFactory.fromBitmap(makeObsCircleBitmap(solidColor, 1)))); + deque.addFirst(newest); + } + + // Creates a semi-transparent filled circle bitmap with a number in the centre + private Bitmap makeObsCircleBitmap(int solidColor, int number) { + int size = 64; + Bitmap bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bmp); + + int fillColor = Color.argb(160, Color.red(solidColor), Color.green(solidColor), Color.blue(solidColor)); + Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + fillPaint.setStyle(Paint.Style.FILL); + fillPaint.setColor(fillColor); + canvas.drawCircle(size / 2f, size / 2f, size / 2f - 2, fillPaint); + + Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(Color.WHITE); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + textPaint.setTextSize(size * 0.45f); + textPaint.setTextAlign(Paint.Align.CENTER); + float textY = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f; + canvas.drawText(String.valueOf(number), size / 2f, textY, textPaint); + + return bmp; } /** @@ -649,10 +701,13 @@ public void clearMapAndReset() { } testPointMarkers.clear(); - // Clear all per-source observation markers - if (gnssObsMarker != null) { gnssObsMarker.remove(); gnssObsMarker = null; } - if (wifiObsMarker != null) { wifiObsMarker.remove(); wifiObsMarker = null; } - if (pdrObsMarker != null) { pdrObsMarker.remove(); pdrObsMarker = null; } + // Clear all per-source observation circle-markers + for (Marker m : gnssObsMarkers) m.remove(); + gnssObsMarkers.clear(); + for (Marker m : wifiObsMarkers) m.remove(); + wifiObsMarkers.clear(); + for (Marker m : pdrObsMarkers) m.remove(); + pdrObsMarkers.clear(); // Clear fused trajectory if (fusedTrajectoryPolyline != null) { @@ -670,6 +725,7 @@ public void clearMapAndReset() { polyline = gMap.addPolyline(new PolylineOptions() .color(Color.RED) .width(5f) + .visible(pdrPathSwitch != null && pdrPathSwitch.isChecked()) .add()); gnssPolyline = gMap.addPolyline(new PolylineOptions() .color(Color.BLUE) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index fea3609c..d1f26f4b 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -635,6 +635,19 @@ public LatLng getFusedPosition() { return null; } + /** + * Feeds a WiFi position fix into the particle filter as a correction measurement. + * WiFi is more reliable indoors than GNSS, so this prevents PDR drift accumulating + * in environments where GNSS accuracy is too poor to pass the filter's threshold. + * + * @param wifiLatLng WiFi-derived position from the OpenPositioning API. + */ + public void correctWithWifiPosition(LatLng wifiLatLng) { + if (particleFilter != null && particleFilter.isInitialised() && wifiLatLng != null) { + particleFilter.updateWeights(wifiLatLng, 8.0f); + } + } + /** * Returns the current floor the user is on, obtained using WiFi positioning. * diff --git a/app/src/main/res/layout/fragment_trajectory_map.xml b/app/src/main/res/layout/fragment_trajectory_map.xml index bcfd77e5..78625d59 100644 --- a/app/src/main/res/layout/fragment_trajectory_map.xml +++ b/app/src/main/res/layout/fragment_trajectory_map.xml @@ -31,6 +31,22 @@ android:padding="8dp" android:gravity="center"> + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f9f1484..ca814cee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ Choose Map ❇️ Auto Floor Smooth + PDR Path GNSS fix WiFi fix PDR step From 56f738ccb7251de7905e2d90d23b43eec61bf73a Mon Sep 17 00:00:00 2001 From: Juraj02 Date: Wed, 1 Apr 2026 17:50:42 +0100 Subject: [PATCH 09/14] Clean up Javadoc and HTML tags in comments --- .../fragment/RecordingFragment.java | 6 +- .../presentation/fragment/ReplayFragment.java | 8 +-- .../fragment/TrajectoryMapFragment.java | 56 ++++--------------- 3 files changed, 15 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java index cdd20af8..427d69c6 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java @@ -278,11 +278,7 @@ private void onAddTestPoint() { private void updateFusedDisplay() { if (trajectoryMapFragment == null) return; - // Prefer WiFi fix; fall back to PDR-derived current location (no raw GNSS) - LatLng bestEstimate = sensorFusion.getLatLngWifiPositioning(); - if (bestEstimate == null) { - bestEstimate = trajectoryMapFragment.getCurrentLocation(); - } + LatLng bestEstimate = sensorFusion.getFusedPosition(); if (bestEstimate == null) return; // Append to fused trajectory only if the position has moved > 0.3 m diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java index d15a4a83..253c84b3 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java @@ -25,17 +25,15 @@ /** * Sub fragment of Replay Activity. Fragment that replays trajectory data on a map. - *

- * The ReplayFragment is responsible for visualizing and replaying trajectory data captured during - * previous recordings. It loads trajectory data from a JSON file, updates the map with user movement, + * Loads trajectory data from a JSON file, updates the map with user movement, * and provides UI controls for playback, pause, and seek functionalities. - *

+ * * Features: * - Loads trajectory data from a file and displays it on a map. * - Provides playback controls including play, pause, restart, and go to end. * - Updates the trajectory dynamically as playback progresses. * - Allows users to manually seek through the recorded trajectory. - * - Integrates with {@link TrajectoryMapFragment} for map visualization. + * - Integrates with TrajectoryMapFragment for map visualization. * * @see TrajectoryMapFragment The map fragment displaying the trajectory. * @see ReplayActivity The activity managing the replay workflow. diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 49e41a31..79f9d042 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -277,11 +277,8 @@ public void onMapReady(@NonNull GoogleMap googleMap) { /** * Initialize the map settings with the provided GoogleMap instance. - *

- * The method sets basic map settings, initializes the indoor map manager, - * and creates an empty polyline for user movement tracking. - * The method also initializes the GNSS polyline for tracking GNSS path. - * The method sets the map type to Hybrid and initializes the map with these settings. + * Sets basic map settings, initializes the indoor map manager, + * and creates an empty polyline for user movement tracking. * * @param map */ @@ -322,19 +319,7 @@ private void initMapSettings(GoogleMap map) { /** - * Initialize the map type spinner with the available map types. - *

- * The spinner allows the user to switch between different map types - * (e.g. Hybrid, Normal, Satellite) to customize their map view. - * The spinner is populated with the available map types and listens - * for user selection to update the map accordingly. - * The map type is updated directly on the GoogleMap instance. - *

- * Note: The spinner is initialized with the default map type (Hybrid). - * The map type is updated on user selection. - *

- *

- * @see com.google.android.gms.maps.GoogleMap The GoogleMap instance to update map type. + * Initialize the map type spinner with the available map types (Hybrid, Normal, Satellite). */ private void initMapTypeSpinner() { if (switchMapSpinner == null) return; @@ -375,7 +360,7 @@ public void onNothingSelected(AdapterView parent) {} /** * Records the user’s current PDR-derived location and extends the red trajectory polyline. * Does NOT move the orientation marker — marker updates are handled exclusively by - * {@link #updateFusedPosition} to avoid competing updates from different loops. + * updateFusedPosition() to avoid competing updates from different loops. * * @param newLocation The new PDR-derived location. * @param orientation Unused here; kept for API compatibility. @@ -409,12 +394,8 @@ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) { /** * Set the initial camera position for the map. - *

- * The method sets the initial camera position for the map when it is first loaded. - * If the map is already ready, the camera is moved immediately. - * If the map is not ready, the camera position is stored until the map is ready. - * The method also tracks if there is a pending camera move. - *

+ * If the map is not ready yet, the position is stored and applied once it is. + * * @param startLocation The initial camera position to set. */ public void setInitialCameraPosition(@NonNull LatLng startLocation) { @@ -488,8 +469,7 @@ public void updateGNSS(@NonNull LatLng gnssLocation) { /** * Updates the user's best-estimate position on the map. - * When smoothing is enabled an Exponential Moving Average filter (α = {@value #EMA_ALPHA}) - * is applied before moving the orientation marker, producing visibly smoother motion. + * When smoothing is enabled an EMA filter is applied before moving the orientation marker. * * @param pos Raw best-estimate position from the fusion / fallback pipeline. * @param orientation Device heading in degrees (clockwise from north). @@ -622,10 +602,9 @@ private Bitmap makeObsCircleBitmap(int solidColor, int number) { /** * Enables or disables the EMA position smoothing filter. - * Disabling resets the filter state so the next call to - * {@link #updateFusedPosition} re-seeds it from the raw position. + * Disabling resets the filter so the next call re-seeds from the raw position. * - * @param enabled {@code true} to enable smoothing. + * @param enabled true to enable smoothing. */ public void setSmoothingEnabled(boolean enabled) { this.smoothingEnabled = enabled; @@ -739,21 +718,8 @@ public void clearMapAndReset() { } /** - * Draw the building polygon on the map - *

- * The method draws a polygon representing the building on the map. - * The polygon is drawn with specific vertices and colors to represent - * different buildings or areas on the map. - * The method removes the old polygon if it exists and adds the new polygon - * to the map with the specified options. - * The method logs the number of vertices in the polygon for debugging. - *

- * - * Note: The method uses hard-coded vertices for the building polygon. - * - *

- * - * See: {@link com.google.android.gms.maps.model.PolygonOptions} The options for the new polygon. + * Draw the building polygon on the map using hard-coded vertices. + * Removes any existing polygon before adding the new one. */ private void drawBuildingPolygon() { if (gMap == null) { From bc3b22e6d707ba1a2f49c38c7209e6325263718a Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 1 Apr 2026 18:03:11 +0100 Subject: [PATCH 10/14] mapmatching_floorchangingandupdates --- .../fragment/TrajectoryMapFragment.java | 30 +- .../PositionMe/sensors/MapGeometry.java | 123 +++++- .../PositionMe/sensors/MapMatcher.java | 357 +++++++++++++----- .../PositionMe/sensors/ParticleFilter.java | 84 ++--- .../PositionMe/sensors/SensorFusion.java | 215 ++++++++--- 5 files changed, 602 insertions(+), 207 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 479ea51b..24380b83 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -584,7 +584,7 @@ private void drawBuildingPolygon() { .add(nkml1, nkml2, nkml3, nkml4, nkml1) .strokeColor(Color.BLUE) // Blue border .strokeWidth(10f) // Border width - // .fillColor(Color.argb(50, 0, 0, 255)) // Semi-transparent blue fill + // .fillColor(Color.argb(50, 0, 0, 255)) // Semi-transparent blue fill .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays PolygonOptions buildingPolygonOptions3 = new PolygonOptions() @@ -653,8 +653,10 @@ private void applyImmediateFloor() { if (!indoorMapManager.getIsIndoorMapSet()) return; int candidateFloor; - if (sensorFusion.getLatLngWifiPositioning() != null) { - candidateFloor = sensorFusion.getWifiFloor(); + // Priority: MapMatcher fused floor + // Falls back to raw WiFi, then barometric estimate if neither is available. Will work when in lift as it detects it. + if (sensorFusion.getLatLngWifiPositioning() != null || sensorFusion.getMapMatcherFloor() >= 0) { + candidateFloor = sensorFusion.getMapMatcherFloor(); } else { float elevation = sensorFusion.getElevation(); float floorHeight = indoorMapManager.getFloorHeight(); @@ -684,7 +686,7 @@ private void stopAutoFloor() { /** * Evaluates the current floor using WiFi positioning (priority) or * barometric elevation (fallback). Applies a 3-second debounce window - * to prevent jittery floor switching. + * to prevent jittery floor switching so it becomes smoother. */ private void evaluateAutoFloor() { if (sensorFusion == null || indoorMapManager == null) return; @@ -692,9 +694,10 @@ private void evaluateAutoFloor() { int candidateFloor; - // Priority 1: WiFi-based floor (only if WiFi positioning has returned data) - if (sensorFusion.getLatLngWifiPositioning() != null) { - candidateFloor = sensorFusion.getWifiFloor(); + // Priority 1: MapMatcher fused floor (barometer override > WiFi > raw barometric). + // getMapMatcherFloor() returns WiFi floor as fallback when MapMatcher not yet loaded. + if (sensorFusion.getLatLngWifiPositioning() != null || sensorFusion.getMapMatcherFloor() >= 0) { + candidateFloor = sensorFusion.getMapMatcherFloor(); } else { // Fallback: barometric elevation estimate float elevation = sensorFusion.getElevation(); @@ -712,12 +715,17 @@ private void evaluateAutoFloor() { } if (now - lastCandidateTime >= AUTO_FLOOR_DEBOUNCE_MS) { - indoorMapManager.setCurrentFloor(candidateFloor, true); - updateFloorLabel(); - // Reset timer so we don't keep re-applying the same floor + // Only apply a floor *change* when near stairs or a lift. + // Same-floor confirmations are always allowed (no movement required). + int currentFloor = indoorMapManager.getCurrentFloor(); + if (candidateFloor == currentFloor || sensorFusion.isNearTransition()) { + indoorMapManager.setCurrentFloor(candidateFloor, true); + updateFloorLabel(); + } + // Reset timer regardless, so we re-evaluate next second lastCandidateTime = now; } } //endregion -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java index 246f29e4..a37dd243 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java @@ -5,17 +5,14 @@ import java.util.ArrayList; import java.util.List; -// NOTE: doesSegmentCross* methods are intentionally omitted — wall-crossing -// correction belongs to the separate "movement model / wall constraint" requirement. - /** - * Pure static geometry helpers for map-matching. + * Geometry utility class for the map matcher. All methods are static - no instances are created. * - * No instance state. No Android dependencies beyond Log. - * All coordinates are in the local easting/northing meter frame used by - * ParticleFilter. + * Provides a point-in-polygon test used to detect which building the user is in, and a + * segment-crossing test used by ParticleFilter to check whether a particle's movement + * crosses a wall. Also runs a self-test on load to verify both work correctly. * - * Logcat tag: MapGeometry (self-test results only) + * All coordinates are local ENU metres matching the frame used by ParticleFilter and MapMatcher. */ class MapGeometry { @@ -24,17 +21,19 @@ class MapGeometry { // No instances needed private MapGeometry() {} - // ------------------------------------------------------------------------- - // Point-in-polygon - // ------------------------------------------------------------------------- + /** - * Ray-casting point-in-polygon test. + * Tests whether point (x, y) is inside the given polygon using ray-casting. * - * @param x easting (meters, local frame) - * @param y northing (meters, local frame) - * @param polygon list of float[]{eastingM, northingM} vertices - * @return true if (x,y) is strictly inside the polygon + * Casts a ray horizontally to the left from (x, y) and counts how many polygon + * edges it crosses. An odd count means inside, even means outside. + * Returns false for null or degenerate polygons (fewer than 3 vertices). + * + * @param x easting in local ENU metres + * @param y northing in local ENU metres + * @param polygon list of float[]{eastingM, northingM} vertices in order + * @return true if the point is inside the polygon */ static boolean isPointInsidePolygon(float x, float y, List polygon) { if (polygon == null || polygon.size() < 3) return false; @@ -56,16 +55,86 @@ static boolean isPointInsidePolygon(float x, float y, List polygon) { return inside; } + + + /** + * Returns true if the segment from (x1,y1) to (x2,y2) crosses any edge of the polygon. + * + * Bullet 3 (Map Matcher): this is the core wall-crossing test. ParticleFilter.predict() calls this + * for every particle after it is displaced. If the move segment crosses a wall edge, + * the particle is snapped back, correcting any estimate that would pass through a wall. + * + * Called by ParticleFilter.predict() after each particle is displaced. If the move + * segment crosses a wall edge, the particle is snapped back to its previous position. + * Iterates all consecutive vertex pairs of the polygon and checks each edge with + * segmentsIntersect(). The last edge joins the final vertex back to the first. + * + * Returns false for null or single-point polygons. + * + * @param x1 start easting in local ENU metres + * @param y1 start northing in local ENU metres + * @param x2 end easting in local ENU metres + * @param y2 end northing in local ENU metres + * @param polygon wall vertices as float[]{eastingM, northingM} + */ + static boolean doesSegmentCrossPolygon( + float x1, float y1, float x2, float y2, + List polygon) { + if (polygon == null || polygon.size() < 2) return false; + int n = polygon.size(); + for (int i = 0; i < n; i++) { + float[] a = polygon.get(i); + float[] b = polygon.get((i + 1) % n); + if (segmentsIntersect(x1, y1, x2, y2, a[0], a[1], b[0], b[1])) { + return true; + } + } + return false; + } + + /** + * Tests whether segment AB and segment CD intersect. + * + * Uses the parametric cross-product method. Each segment is expressed as a + * start point plus a direction vector. The scalar parameters t and u represent + * how far along each segment the potential crossing point lies. If both t and u + * are in [0, 1], the crossing point is within both segment lengths, so they + * intersect. If the cross product of the two direction vectors is near zero, + * the segments are parallel and cannot intersect. + * + * @param ax start of segment A - easting + * @param ay start of segment A - northing + * @param bx end of segment A - easting + * @param by end of segment A - northing + * @param cx start of segment B - easting + * @param cy start of segment B - northing + * @param dx end of segment B - easting + * @param dy end of segment B - northing + */ + private static boolean segmentsIntersect( + float ax, float ay, float bx, float by, + float cx, float cy, float dx, float dy) { + float d1x = bx - ax, d1y = by - ay; // direction of segment A + float d2x = dx - cx, d2y = dy - cy; // direction of segment B + float cross = d1x * d2y - d1y * d2x; + if (Math.abs(cross) < 1e-6f) return false; // parallel or collinear + float t = ((cx - ax) * d2y - (cy - ay) * d2x) / cross; + float u = ((cx - ax) * d1y - (cy - ay) * d1x) / cross; + return t >= 0f && t <= 1f && u >= 0f && u <= 1f; + } + // ------------------------------------------------------------------------- // Self-test (Step 2) // ------------------------------------------------------------------------- /** - * Validates isPointInsidePolygon against known-correct answers. - * Called once after building map data is loaded. Tag: MapGeometry. + * Runs a set of known-answer checks against isPointInsidePolygon and + * doesSegmentCrossPolygon. Called once from MapMatcher.tryLoadBuilding() + * after building data is parsed. Results are written to Logcat under the + * MapGeometry tag so any geometry regression shows up immediately. */ static void selfTest() { - // Unit square: (0,0) → (10,0) → (10,10) → (0,10) + // Test polygon: 10x10 unit square with corners at (0,0), (10,0), (10,10), (0,10) List square = new ArrayList<>(); square.add(new float[]{0f, 0f}); square.add(new float[]{10f, 0f}); @@ -77,8 +146,22 @@ static void selfTest() { check("isPointInsidePolygon(15,5) == false", isPointInsidePolygon(15f, 5f, square), false); + + // Segment crossing the left edge of the square (x=-2 to x=5, y=5): should cross + // Segment entering the square through the left edge - should cross + check("doesSegmentCrossPolygon(-2,5 -> 5,5) == true", + doesSegmentCrossPolygon(-2f, 5f, 5f, 5f, square), true); + + // Segment that starts and ends inside the square - no edge crossing + check("doesSegmentCrossPolygon(2,2 -> 8,8) == false", + doesSegmentCrossPolygon(2f, 2f, 8f, 8f, square), false); + + // Segment completely outside the square - no crossing + check("doesSegmentCrossPolygon(12,0 -> 15,10) == false", + doesSegmentCrossPolygon(12f, 0f, 15f, 10f, square), false); } + // Logs PASS or FAIL for a single self-test case. private static void check(String name, boolean result, boolean expected) { if (result == expected) { Log.d(TAG, "PASS: " + name); @@ -87,4 +170,4 @@ private static void check(String name, boolean result, boolean expected) { + " (got " + result + ", expected " + expected + ")"); } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java index e9454381..9b39162b 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java @@ -11,25 +11,31 @@ import java.util.List; import java.util.Map; - +/** + * Loads and stores the floorplan geometry for the current building. Used by the rest of the + * system for three purposes: supplying per-floor wall data to ParticleFilter so particles + * cannot pass through walls, storing stair and lift centroids so SensorFusion can check + * whether the user is near a transition point before accepting a floor change, and resolving + * the current floor index by combining the WiFi floor reading with any barometer-confirmed + * override. Handles buildings where the API floor list is stored in non-physical order + * (e.g. Nucleus stores GF at index 4 rather than index 0). + * + * Call tryLoadBuilding() once at the start of a recording to initialise. + */ class MapMatcher { private static final String TAG_LOAD = "MapMatcher"; - /** Constant: earth meters per degree (matches ParticleFilter). */ + // Equatorial approximation: metres per degree of lat/lon. Matches the constant in ParticleFilter. private static final double METERS_PER_DEGREE = 111111.0; - // ========================================================================= - // Inner data class - // ========================================================================= - /** - * A single wall polygon/polyline in the local coordinate frame. - */ + + // A single wall polygon or polyline from the floorplan API, converted to local ENU metres. static class WallFeature { - /** Vertices as float[]{eastingM, northingM} in the local frame. */ + // Vertices as {eastingM, northingM} relative to the particle filter origin final List localPoints; - /** True for MultiPolygon/Polygon features; false for LineString features. */ + // True for filled Polygon/MultiPolygon shapes; false for LineString wall segments final boolean isPolygon; WallFeature(List pts, boolean isPolygon) { @@ -38,56 +44,65 @@ static class WallFeature { } } - // ========================================================================= - // State - // ========================================================================= private final SensorFusion sensorFusion; - // Null until tryLoadBuilding() succeeds + // Set by tryLoadBuilding(); null/zero until loading succeeds private String loadedBuildingId = null; private LatLng mapOrigin = null; private int numFloors = 0; - // Maps floor displayName ("GF", "F1", ...) to its floorShapes index. - // Built at load time; used by getLikelyFloorIndex() to resolve WiFi floor integers. + + // displayName ("GF", "F1", "LG" etc.) -> floorShapes list index. + // Built at load time. Used by getAdjacentFloorIndex() for reverse-lookup. private Map displayNameToIndex = null; - // Per-floor wall feature arrays (size = numFloors, indexed 0..numFloors-1) - // Null until tryLoadBuilding() succeeds; getWallsForFloor() guards against this. + // Arrays indexed [0..numFloors-1], one list per floor. + // All three are null until tryLoadBuilding() completes. @SuppressWarnings("unchecked") private List[] wallsByFloor = null; - // ========================================================================= - // Constructor - // ========================================================================= + // Centroids of staircase and lift features on each floor, in local ENU metres. + // Used by SensorFusion.isNearTransition() to gate barometer floor changes. + @SuppressWarnings("unchecked") + private List[] stairCentersByFloor = null; + @SuppressWarnings("unchecked") + private List[] liftCentersByFloor = null; + + // When SensorFusion accepts a barometer floor change, it calls setCurrentFloor() to + // store the confirmed API index here. -1 means no override, fall back to WiFi. + private int currentFloorOverride = -1; + + // Physical floor number (LG=-1, GF=0, F1=1 ...) -> API floorShapes list index. + // Built at load time. Needed because some buildings store floors out of physical order + // (e.g. Nucleus: [LG, F1, F2, F3, GF] so GF is at index 4, not 0). + private Map physicalToApiIndex = null; + + /** - * Creates a MapMatcher that reads data from the given SensorFusion instance. - * No loading is performed until tryLoadBuilding() is called. + * Creates a MapMatcher. No geometry is loaded until tryLoadBuilding() is called. * - * @param sf the SensorFusion singleton + * @param sf the SensorFusion singleton used to access the floorplan cache */ MapMatcher(SensorFusion sf) { this.sensorFusion = sf; } - // ========================================================================= - // Step 1: loading - // ========================================================================= /** - * Loads wall/stair/lift features from the floorplan cache for the current building. + * Loads wall, stair, and lift features from the floorplan cache for the current building. * - * Detection order: - * 1. Direct look-up by preferredBuildingId if non-null. - * 2. Ray-cast against API outline polygons using origin. - * 3. Closest building center as last resort. + * Building selection works in order: preferred name first (if provided), then + * point-in-polygon check against all cached building outlines, then closest center + * as a last resort. Returns early without loading if origin is null or no building + * is found in the cache. * - * Silently returns if origin is null or no building is found. - * Runs the MapGeometry self-test (MapGeometry.selfTest()) on success. + * On success, builds the displayName and physicalFloor index lookup maps, converts + * every floor's wall/stair/lift geometry to local ENU metres, and runs + * MapGeometry.selfTest() to verify the geometry helpers. * - * @param preferredBuildingId building name key (e.g. "nucleus_building"); may be null - * @param origin local coordinate frame origin from ParticleFilter + * @param preferredBuildingId building name from floorplan API (e.g. "nucleus_building"); null to auto-detect + * @param origin local frame origin from ParticleFilter */ @SuppressWarnings("unchecked") void tryLoadBuilding(String preferredBuildingId, LatLng origin) { @@ -142,37 +157,72 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { mapOrigin = origin; loadedBuildingId = building.getName(); - // Build displayName → floorIndex map for WiFi floor resolution + // Build both lookup maps: displayName -> index (for adjacent-floor arithmetic) + // and physicalFloor -> index (for WiFi floor conversion) displayNameToIndex = new HashMap<>(); + physicalToApiIndex = new HashMap<>(); for (int f = 0; f < numFloors; f++) { String name = floorShapes.get(f).getDisplayName(); - if (name != null) displayNameToIndex.put(name, f); + if (name != null) { + displayNameToIndex.put(name, f); + physicalToApiIndex.put(displayNameToPhysical(name), f); + } } - wallsByFloor = new List[numFloors]; + wallsByFloor = new List[numFloors]; + stairCentersByFloor = new List[numFloors]; + liftCentersByFloor = new List[numFloors]; + currentFloorOverride = -1; for (int f = 0; f < numFloors; f++) { - wallsByFloor[f] = new ArrayList<>(); + wallsByFloor[f] = new ArrayList<>(); + stairCentersByFloor[f] = new ArrayList<>(); + liftCentersByFloor[f] = new ArrayList<>(); FloorplanApiClient.FloorShapes floor = floorShapes.get(f); + // Bullet 2 (Map Matcher): the floorplan API gives each feature an indoor_type. We sort every + // feature on this floor into one of the three required categories: + // "wall" -> stored as WallFeature for wall-crossing checks (Bullet 3, Map Matcher) + // "stairs"/"staircase" -> centroid stored for proximity gating (Bullets 4 & 5, Map Matcher) + // "lift"/"elevator" -> centroid stored for proximity gating (Bullets 4 & 5, Map Matcher) + // Walk every feature on this floor and sort it into walls, stairs, or lifts. + // Wall parts are stored as WallFeature objects (polygon or polyline). + // Stair and lift parts have their centroid computed and stored for proximity checks. for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) { - if (!"wall".equals(feature.getIndoorType())) continue; + String type = feature.getIndoorType(); + + if ("wall".equals(type)) { + String geoType = feature.getGeometryType(); + boolean isPoly = "MultiPolygon".equals(geoType) || "Polygon".equals(geoType); + + for (List part : feature.getParts()) { + List localPts = new ArrayList<>(part.size()); + for (LatLng ll : part) { + localPts.add(latLngToLocal(ll, origin)); + } + if (localPts.size() < 2) continue; + wallsByFloor[f].add(new WallFeature(localPts, isPoly)); + } - String geoType = feature.getGeometryType(); - boolean isPoly = "MultiPolygon".equals(geoType) || "Polygon".equals(geoType); + } else if ("stairs".equals(type) || "staircase".equals(type)) { + for (List part : feature.getParts()) { + float[] centroid = computeCentroidLocal(part, origin); + if (centroid != null) stairCentersByFloor[f].add(centroid); + } - for (List part : feature.getParts()) { - List localPts = new ArrayList<>(part.size()); - for (LatLng ll : part) { - localPts.add(latLngToLocal(ll, origin)); + } else if ("lift".equals(type) || "elevator".equals(type)) { + for (List part : feature.getParts()) { + float[] centroid = computeCentroidLocal(part, origin); + if (centroid != null) liftCentersByFloor[f].add(centroid); } - if (localPts.size() < 2) continue; - wallsByFloor[f].add(new WallFeature(localPts, isPoly)); } } - Log.d(TAG_LOAD, String.format("Floor %d (%s): walls=%d", - f, floor.getDisplayName(), wallsByFloor[f].size())); + Log.d(TAG_LOAD, String.format("Floor %d (%s): walls=%d stairs=%d lifts=%d", + f, floor.getDisplayName(), + wallsByFloor[f].size(), + stairCentersByFloor[f].size(), + liftCentersByFloor[f].size())); } Log.d(TAG_LOAD, "Loaded: building=" + loadedBuildingId @@ -183,18 +233,15 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { } /** - * Returns true once building data has been successfully loaded. - * All three conditions must hold: building ID set, origin set, and at least one floor parsed. + * Returns true once tryLoadBuilding() has successfully parsed at least one floor. + * All other public methods guard against being called before this returns true. */ boolean isInitialised() { return loadedBuildingId != null && mapOrigin != null && numFloors > 0; } - // ========================================================================= - // Step 2: floor accessor - // ========================================================================= - /** Returns the wall features for the given floor, or an empty list if out of range. */ + // Returns the wall features for the given API floor index, or an empty list if out of range. List getWallsForFloor(int floorIndex) { if (wallsByFloor == null || floorIndex < 0 || floorIndex >= numFloors) { return new ArrayList<>(); @@ -202,53 +249,129 @@ List getWallsForFloor(int floorIndex) { return wallsByFloor[floorIndex]; } - // ========================================================================= - // Utilities - // ========================================================================= - /** Returns the loaded building ID, or null if not loaded. */ + + // Returns the building name that was loaded, or null if tryLoadBuilding() has not succeeded yet. String getLoadedBuildingId() { return loadedBuildingId; } /** - * Returns the floor index most likely occupied by the user, derived from - * the current WiFi floor reading mapped to a floor display name. + * Returns the best guess for which API floorShapes index the user is currently on. + * Index 0 matches floorShapesList[0], index 1 matches floorShapesList[1], and so on. + * + * Bullet 1 (Map Matcher): combines WiFi and barometer data to give the rest of the system a single + * reliable floor number, accounting for buildings where the API list is in non-physical + * order (e.g. Nucleus stores GF at index 4). * - * WiFi integers are converted to display names per building. The display name is then looked up in the index map built - * at load time, so the result is correct regardless of API floor ordering. + * A barometer-confirmed override (set by SensorFusion when near lift) takes + * priority over the WiFi reading. When no override is set, the WiFi floor integer is + * converted to an API index via physicalToApiIndex. Falls back to the bias formula + * if the lookup map is not ready yet (Nucleus/Murchison: WiFi 0 maps to index 1; + * other buildings: WiFi 0 maps to index 0). * - * Falls back to 0 if WiFi returns an unrecognised value. + * Result is clamped to [0, numFloors-1]. */ int getLikelyFloorIndex() { - if (displayNameToIndex == null) return 0; + // Barometer override beats WiFi when SensorFusion has confirmed a floor change + if (currentFloorOverride >= 0 && currentFloorOverride < numFloors) { + Log.d(TAG_LOAD, "getLikelyFloorIndex: using barometer override=" + currentFloorOverride); + return currentFloorOverride; + } int wifiFloor = sensorFusion.getWifiFloor(); - String name = wifiFloorToDisplayName(wifiFloor); - Integer idx = (name != null) ? displayNameToIndex.get(name) : null; - int result = (idx != null) ? idx : 0; + // physicalToApiIndex handles buildings where the API list is not in physical order + // (e.g. Nucleus: [LG, F1, F2, F3, GF] - WiFi 0 = GF = API index 4, not index 1). + // Falls back to the bias formula when the map has not loaded yet. + Integer apiIdx = (physicalToApiIndex != null) ? physicalToApiIndex.get(wifiFloor) : null; + int result = (apiIdx != null) + ? apiIdx + : Math.max(0, Math.min(numFloors - 1, wifiFloor + getAutoFloorBias())); Log.d(TAG_LOAD, "getLikelyFloorIndex: wifiFloor=" + wifiFloor - + " elevation=" + sensorFusion.getElevation() - + " name=" + name + " index=" + result); + + " bias=" + getAutoFloorBias() + " index=" + result); return result; } /** - * Converts a WiFi floor integer to the display name used in the floorplan API - * for the currently loaded building. + * Returns the index offset between WiFi floor 0 (GF) and its position in the API floor list. + * Nucleus and Murchison store LG at index 0, so GF sits at index 1 (bias = 1). + * All other buildings place GF at index 0, so no offset is needed (bias = 0). + * Kept in sync with IndoorMapManager.getAutoFloorBias(). + */ + int getAutoFloorBias() { + if ("nucleus_building".equals(loadedBuildingId) + || "murchison_house".equals(loadedBuildingId)) { + return 1; + } + return 0; + } + + /** + * Returns the approximate floor-to-floor height in metres for the loaded building. + * SensorFusion divides accumulated elevation change by this value to decide how many + * floors the user has moved. Kept in sync with IndoorMapManager's per-building constants. + */ + float getFloorHeight() { + if ("nucleus_building".equals(loadedBuildingId)) return 4.2f; + if ("murchison_house".equals(loadedBuildingId)) return 4.0f; + if ("library".equals(loadedBuildingId)) return 3.6f; + return 4.0f; // generic fallback + } + + /** + * Records a barometer-confirmed floor index so getLikelyFloorIndex() returns it + * instead of the WiFi estimate. Called by SensorFusion.checkAndApplyFloorChange(). + * Pass -1 to clear the override and let WiFi drive floor selection again. + */ + void setCurrentFloor(int floor) { + currentFloorOverride = floor; + } + + // Returns the staircase centroids in local ENU metres for floor f, or an empty list. + List getStairCentersForFloor(int f) { + if (stairCentersByFloor == null || f < 0 || f >= numFloors) return new ArrayList<>(); + return stairCentersByFloor[f]; + } + + // Returns the lift centroids in local ENU metres for floor f, or an empty list. + List getLiftCentersForFloor(int f) { + if (liftCentersByFloor == null || f < 0 || f >= numFloors) return new ArrayList<>(); + return liftCentersByFloor[f]; + } + + /** + * Converts a physical floor number (LG=-1, GF=0, F1=1, F2=2, ...) to its + * position in the API floorShapes list. Returns -1 if not found. * - * Nucleus / Murchison: 0=GF, 1=F1, 2=F2, 3=F3 (LG not available via WiFi). - * Generic fallback: treats the integer as a display-name string ("0", "1", ...). + * SensorFusion calls this when WiFi changes floor rather than going through + * getLikelyFloorIndex(), because getLikelyFloorIndex() already reflects the + * new WiFi value and combining it with getAdjacentFloorIndex() would shift + * one floor too far. + */ + int physicalFloorToApiIndex(int physicalFloor) { + if (physicalToApiIndex == null) return -1; + Integer idx = physicalToApiIndex.get(physicalFloor); + return (idx != null) ? idx : -1; + } + + /** + * Converts a WiFi floor integer to the display name used in the floorplan API. + * + * For Nucleus and Murchison, the API stores floors in this order: + * index 0=LG, 1=F1, 2=F2, 3=F3, 4=GF + * GF is at index 4, so this cannot simply call String.valueOf(wifiFloor). + * For other buildings, the WiFi integer is used as a plain string ("0", "1", etc.). */ private String wifiFloorToDisplayName(int wifiFloor) { if ("nucleus_building".equals(loadedBuildingId) || "murchison_house".equals(loadedBuildingId)) { switch (wifiFloor) { - case 0: return "GF"; + case 0: return "LG"; case 1: return "F1"; case 2: return "F2"; case 3: return "F3"; + case 4: return "GF"; default: - // Floor not covered by WiFi (e.g. LG) — infer from barometer elevation + // Unknown floor — infer from barometer elevation return (sensorFusion.getElevation() < -1.5f) ? "LG" : "GF"; } } @@ -257,8 +380,73 @@ private String wifiFloorToDisplayName(int wifiFloor) { /** - * Converts a WGS84 LatLng to a local easting/northing offset (meters) - * relative to origin, using the same formula as ParticleFilter. + * Returns the API floorShapes index that is physicalDelta floors above (positive) or + * below (negative) currentApiIndex in physical building height (LG=-1, GF=0, F1=1, ...). + * + * A simple +/-1 on the index would be wrong for buildings with non-physical API ordering + * (e.g. Nucleus: GF is at index 4, so going up one floor from GF must land on index 3 for + * F1, not index 5 which does not exist). Instead, we reverse-lookup the current floor's + * display name, convert it to a physical number, add the delta, then look up the resulting + * physical floor in physicalToApiIndex. + * Issue with being on GF and F1 is showed in auto-floor feature so this fixes that issue. + * + * Returns currentApiIndex unchanged if the target floor does not exist in the list. + */ + int getAdjacentFloorIndex(int currentApiIndex, int physicalDelta) { + if (physicalToApiIndex == null || displayNameToIndex == null) return currentApiIndex; + // Reverse-lookup: find the display name that maps to this API index + for (Map.Entry e : displayNameToIndex.entrySet()) { + if (e.getValue() == currentApiIndex) { + int currentPhysical = displayNameToPhysical(e.getKey()); + int targetPhysical = currentPhysical + physicalDelta; + Integer targetApi = physicalToApiIndex.get(targetPhysical); + return (targetApi != null) ? targetApi : currentApiIndex; + } + } + return currentApiIndex; + } + + /** + * Converts a floor display name to a physical floor number. + * LG = -1, GF = 0, F1 = 1, F2 = 2, F3 = 3 + * Unknown names default to 0. + */ + private static int displayNameToPhysical(String name) { + if (name == null) return 0; + switch (name) { + case "LG": return -1; + case "GF": return 0; + default: + if (name.length() > 1 && name.charAt(0) == 'F') { + try { return Integer.parseInt(name.substring(1)); } + catch (NumberFormatException ignored) {} + } + try { return Integer.parseInt(name); } + catch (NumberFormatException ignored) { return 0; } + } + } + + /** + * Computes the centroid of a set of LatLng points in local ENU metres. + * Used to get a single representative point for stairs/lift polygons. + * Returns null if pts is null or empty. + */ + private static float[] computeCentroidLocal(List pts, LatLng origin) { + if (pts == null || pts.isEmpty()) return null; + float sumE = 0f, sumN = 0f; + for (LatLng ll : pts) { + float[] local = latLngToLocal(ll, origin); + sumE += local[0]; + sumN += local[1]; + } + return new float[]{sumE / pts.size(), sumN / pts.size()}; + } + + /** + * Converts a WGS84 LatLng to a local easting/northing offset in metres + * relative to the particle filter origin. Uses the same equirectangular + * approximation as ParticleFilter.latLngToLocal() to keep coordinate frames + * consistent between the two classes. */ private static float[] latLngToLocal(LatLng point, LatLng origin) { double latDiff = point.latitude - origin.latitude; @@ -270,10 +458,9 @@ private static float[] latLngToLocal(LatLng point, LatLng origin) { } /** - * Returns the cached building whose centre is geographically closest to origin. - * Used as a last-resort fallback when no polygon contains the origin. - - * Distance is computed as squared Euclidean in lat/lon degrees. + * Returns the cached building whose centre is closest to origin. + * Used as a last resort when no building polygon contains the origin point. + * Distance is compared as squared lat/lon degrees (no sqrt needed for ordering). */ private FloorplanApiClient.BuildingInfo closestBuilding(LatLng origin) { FloorplanApiClient.BuildingInfo best = null; @@ -290,4 +477,4 @@ private FloorplanApiClient.BuildingInfo closestBuilding(LatLng origin) { } return best; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 3c98a543..c2e44fa6 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -46,7 +46,7 @@ public class ParticleFilter { private final Random random; private final SensorFusion sensorFusion; - // Map matcher for wall-constrained prediction (null-safe: if null, predict runs unchanged) + // MapMatcher used to check wall crossings during prediction. Null until set via setMapMatcher(). private MapMatcher mapMatcher; // Defines a single hypothesis about the user's position @@ -131,6 +131,17 @@ private void initialisePosition() { initialised = true; } + /** + * Sets the MapMatcher used for wall-crossing checks in predict(). + * Called once from SensorFusion after both objects are created. + * + * Bullet 1 (Map Matcher): wires the map data into the particle filter so position estimates + * are constrained by the building's floor geometry. + */ + public void setMapMatcher(MapMatcher mm) { + this.mapMatcher = mm; + } + /** * Function to retry initial position estimation if it was deferred at construction */ @@ -140,17 +151,6 @@ public void tryInitialise() { } } - /** - * Wires in a MapMatcher for wall-constrained particle prediction. - * When set, predict() will reject movements that enter or cross walls. - * Safe to call with null to disable map matching. - * - * @param mm the MapMatcher instance, or null - */ - public void setMapMatcher(MapMatcher mm) { - this.mapMatcher = mm; - } - /** * Distributes particles as an isotropic 2D gaussian distribution around * (centerX, centerY) and assigns equal weights to each particle (1/N). @@ -180,6 +180,16 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { return; } + // Bullet 3 (Map Matcher): save each particle's position before it is displaced so we have + // a valid rollback point if the move crosses a wall. + // Save positions before the move so we can snap back any particle that crosses a wall + float[] oldX = new float[Num_Particles]; + float[] oldY = new float[Num_Particles]; + for (int i = 0; i < Num_Particles; i++) { + oldX[i] = particles[i].x; + oldY[i] = particles[i].y; + } + // Shift each particle with Gaussian noise considered for (int i = 0; i < Num_Particles; i++) { float noiseX = (float) (random.nextGaussian() * PDRstd); @@ -188,51 +198,29 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { particles[i].y += deltaNorth + noiseY; } - // Wall rejection: zero weights of particles that land inside a wall polygon - Log.d("ParticleFilter", "predict() reached wall check — mapMatcher=" - + (mapMatcher == null ? "null" : (mapMatcher.isInitialised() ? "ready" : "not initialised"))); - if (mapMatcher == null) { - Log.w("ParticleFilter", "Wall rejection skipped: mapMatcher is null"); - } else if (!mapMatcher.isInitialised()) { - Log.w("ParticleFilter", "Wall rejection skipped: mapMatcher not initialised" - + " (building=" + mapMatcher.getLoadedBuildingId() + ")"); - } else { + // Bullet 3 (Map Matcher): use the movement (PDR displacement) and the wall geometry from the map + // to reject any particle whose move segment crossed a wall. That particle is snapped + // back to its pre-move position, keeping the estimate inside walkable space. + // If MapMatcher is loaded, check each particle's move segment against walls on the + // current floor. Any particle that crossed a wall is snapped back to its old position. + if (mapMatcher != null && mapMatcher.isInitialised()) { int floorIndex = mapMatcher.getLikelyFloorIndex(); List walls = mapMatcher.getWallsForFloor(floorIndex); - int zeroed = 0; + int snapped = 0; for (int i = 0; i < Num_Particles; i++) { for (MapMatcher.WallFeature wall : walls) { - if (!wall.isPolygon) continue; - if (MapGeometry.isPointInsidePolygon( - particles[i].x, particles[i].y, wall.localPoints)) { - particles[i].weight = 0f; - zeroed++; + if (MapGeometry.doesSegmentCrossPolygon( + oldX[i], oldY[i], particles[i].x, particles[i].y, + wall.localPoints)) { + particles[i].x = oldX[i]; + particles[i].y = oldY[i]; + snapped++; break; } } } Log.d("ParticleFilter", "Wall rejection (floor " + floorIndex + "): " - + zeroed + "/" + Num_Particles + " particles zeroed this step"); - - // Renormalise remaining weights - float weightSum = 0f; - for (int i = 0; i < Num_Particles; i++) { - weightSum += particles[i].weight; - } - if (weightSum == 0f) { - Log.w("ParticleFilter", "All particles rejected — recovering with uniform respread around estimate"); - float spread = 2.0f; - float uniform = 1.0f / Num_Particles; - for (int i = 0; i < Num_Particles; i++) { - particles[i].x = estimatedX + (float) (random.nextGaussian() * spread); - particles[i].y = estimatedY + (float) (random.nextGaussian() * spread); - particles[i].weight = uniform; - } - } else { - for (int i = 0; i < Num_Particles; i++) { - particles[i].weight /= weightSum; - } - } + + snapped + "/" + Num_Particles + " particles snapped this step"); } // Update weighted mean estimate diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index 42c16ef3..43edc55d 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -34,19 +34,11 @@ import java.util.stream.Stream; /** - * The SensorFusion class is the main data gathering and processing class of the application. + * Main data gathering and processing class for the application. Singleton - all fragments + * share the same instance via SensorFusion.getInstance(). * - *

It follows the singleton design pattern to ensure that every fragment and process has access - * to the same data and sensor instances. Internally it delegates to specialised modules:

- *
    - *
  • {@link SensorState} – shared sensor data holder
  • - *
  • {@link SensorEventHandler} – sensor event dispatch (switch logic)
  • - *
  • {@link TrajectoryRecorder} – recording lifecycle & protobuf construction
  • - *
  • {@link WifiPositionManager} – WiFi scan processing & positioning
  • - *
- * - *

The public API is unchanged – all external callers continue to use - * {@code SensorFusion.getInstance().method()}.

+ * Delegates to: SensorState (data holder), SensorEventHandler (sensor dispatch), + * TrajectoryRecorder (recording and protobuf), WifiPositionManager (WiFi positioning). */ public class SensorFusion implements SensorEventListener { @@ -97,6 +89,14 @@ public class SensorFusion implements SensorEventListener { // Floorplan API cache (latest result from start-location step) private final Map floorplanBuildingCache = new HashMap<>(); + + // Max distances from a staircase / lift for a barometer floor change to be accepted + private static final float STAIR_PROXIMITY_THRESHOLD_M = 5.0f; + private static final float LIFT_PROXIMITY_THRESHOLD_M = 50.0f; + // Elevation recorded when the current floor was last confirmed; NaN before first barometer reading + private float lastFloorElevation = Float.NaN; + // WiFi floor number at the last confirmed floor level; MIN_VALUE until first WiFi fix + private int lastAcceptedWifiFloor = Integer.MIN_VALUE; //endregion //region Initialisation @@ -118,17 +118,10 @@ public static SensorFusion getInstance() { } /** - * Initialisation function for the SensorFusion instance. - * - *

Initialises all movement sensor instances, creates internal modules, and prepares - * the system for data collection.

+ * Sets up all sensors, internal modules, and data processors. + * Must be called once with an application context before anything else. * - * @param context application context for permissions and device access. - * - * @see MovementSensor handling all SensorManager based data collection devices. - * @see ServerCommunications handling communication with the server. - * @see GNSSDataProcessor for location data processing. - * @see WifiDataProcessor for network data processing. + * @param context application context */ public void setContext(Context context) { this.appContext = context.getApplicationContext(); @@ -166,7 +159,8 @@ public void setContext(Context context) { // Initialise particle filter this.particleFilter = new ParticleFilter(); - // Create map matcher and wire into particle filter + // Create the map matcher and give the particle filter a reference to it. + // MapMatcher handles floor detection and stair/lift lookups to help determine whether near stairs or lift. this.mapMatcher = new MapMatcher(this); particleFilter.setMapMatcher(mapMatcher); @@ -212,16 +206,19 @@ public void update(Object[] objList) { //region SensorEventListener /** - * {@inheritDoc} - * - *

Delegates to {@link SensorEventHandler#handleSensorEvent(SensorEvent)}.

+ * Forwards sensor events to SensorEventHandler for processing. + * On each barometer reading while recording, also checks whether enough elevation + * change has accumulated to trigger a floor step. */ @Override public void onSensorChanged(SensorEvent sensorEvent) { eventHandler.handleSensorEvent(sensorEvent); + // Check for floor changes on every pressure update + if (sensorEvent.sensor.getType() == Sensor.TYPE_PRESSURE && recorder.isRecording()) { + checkAndApplyFloorChange(state.elevation); + } } - /** {@inheritDoc} */ @Override public void onAccuracyChanged(Sensor sensor, int i) {} @@ -230,9 +227,7 @@ public void onAccuracyChanged(Sensor sensor, int i) {} //region Start/Stop listening /** - * Registers all device listeners and enables updates with the specified sampling rate. - * - *

Should be called from {@link MainActivity} when resuming the application.

+ * Registers all device listeners. Called from MainActivity on resume. */ public void resumeListening() { accelerometerSensor.sensorManager.registerListener(this, @@ -263,9 +258,8 @@ public void resumeListening() { } /** - * Un-registers all device listeners and pauses data collection. - * - *

Should be called from {@link MainActivity} when pausing the application.

+ * Unregisters all device listeners. Called from MainActivity on pause. + * Does nothing while a recording is in progress. */ public void stopListening() { if (!recorder.isRecording()) { @@ -341,8 +335,11 @@ public void startRecording() { recorder.startRecording(pdrProcessing); eventHandler.resetBootTime(recorder.getBootTime()); particleFilter.tryInitialise(); + // Reset the floor-change baselines for the new recording session + lastFloorElevation = Float.NaN; + lastAcceptedWifiFloor = Integer.MIN_VALUE; - // Try to load building map now that particles are initialised + // Attempt to load the building map now that the particle filter is ready tryTriggerMapMatcher(getSelectedBuildingId()); // Handover WiFi/BLE scan lifecycle from activity callbacks to foreground service. @@ -439,7 +436,7 @@ public void setFloorplanBuildings(List building floorplanBuildingCache.put(building.getName(), building); } - // Attempt to load building map now that the cache is populated + // Cache is now populated - try to load the building map tryTriggerMapMatcher(getSelectedBuildingId()); } @@ -466,14 +463,11 @@ public List getFloorplanBuildings() { } /** - * Attempts to load building map data into the map matcher when all required - * conditions are met: particle filter initialised, origin set, and floorplan - * cache non-empty. - * - *

Called from both {@link #setFloorplanBuildings} and {@link #startRecording} - * because either can fire first; all guards are checked each time.

+ * Loads the building map into MapMatcher once the particle filter is ready, + * the origin is set, and the floorplan cache is populated. Called from both + * startRecording() and setFloorplanBuildings() since either can arrive first. * - * @param preferredBuildingId building name hint; may be null for auto-detection + * @param preferredBuildingId building name hint, or null to auto-detect */ private void tryTriggerMapMatcher(String preferredBuildingId) { if (mapMatcher == null) return; @@ -668,6 +662,141 @@ public void logSensorFrequencies() { eventHandler.logSensorFrequencies(); } + /** + * Returns the current floor as a WiFi-scale integer (0=GF, 1=F1, -1=LG etc.) + * for use by the floor switch in TrajectoryMapFragment. + * + * When MapMatcher is active, getLikelyFloorIndex() returns an API index that + * already has the building bias baked in. We subtract it here so the caller's + * setCurrentFloor(x, autoFloor=true) can add it back without double-counting. + * Falls back to the raw WiFi floor if MapMatcher is not loaded yet. + */ + public int getMapMatcherFloor() { + if (mapMatcher != null && mapMatcher.isInitialised()) { + return mapMatcher.getLikelyFloorIndex() - mapMatcher.getAutoFloorBias(); + } + return getWifiFloor(); + } + + //endregion + + //region Floor change gating + + /** + * Called on every barometer reading while recording. + * + * Rule 1: if WiFi reports a new floor since last check, snap the map to match immediately if near stairs or lift. + * Rule 2: if accumulated elevation change exceeds one floor height (per building), step + * the floor up or down. Only fires when near a staircase or lift. Uses + * state.elevator to classify the transition as lift or stairs. + */ + private void checkAndApplyFloorChange(float elevation) { + if (mapMatcher == null || !mapMatcher.isInitialised()) return; + + // Seed reference on first call + if (Float.isNaN(lastFloorElevation)) { + lastFloorElevation = elevation; + lastAcceptedWifiFloor = getWifiFloor(); + return; + } + + int currentWifiFloor = getWifiFloor(); + + // Rule 1: WiFi floor changed - snap the map to match and reset the elevation baseline + if (lastAcceptedWifiFloor != Integer.MIN_VALUE + && currentWifiFloor != lastAcceptedWifiFloor) { + int wifiApiFloor = mapMatcher.physicalFloorToApiIndex(currentWifiFloor); + if (wifiApiFloor >= 0) { + mapMatcher.setCurrentFloor(wifiApiFloor); + Log.i("SensorFusion", "Floor snapped to WiFi floor " + wifiApiFloor + + " (physical=" + currentWifiFloor + ")"); + } else { + mapMatcher.setCurrentFloor(-1); // unknown floor - clear override and let WiFi drive + } + lastFloorElevation = elevation; + lastAcceptedWifiFloor = currentWifiFloor; + return; + } + + // Bullet 4 (Map Matcher): check accumulated barometer elevation change against the building's + // floor height. Only accept a floor change if the user is physically near a + // staircase or lift centroid (from the map). Floor changes in the middle of an + // open room are ignored. + // Rule 2: Barometer - divide elapsed elevation by the building's floor height + float floorHeight = mapMatcher.getFloorHeight(); + float elevationDelta = elevation - lastFloorElevation; + int floorsToMove = (int) (Math.abs(elevationDelta) / floorHeight); + if (floorsToMove == 0) return; + + // Only proceed if the user is physically near a staircase or lift + if (particleFilter == null || !particleFilter.isInitialised()) return; + int currentApiFloor = mapMatcher.getLikelyFloorIndex(); + float[] pos = particleFilter.getEstimatedLocalPosition(); + boolean nearLift = isNearAny(pos, + mapMatcher.getLiftCentersForFloor(currentApiFloor), LIFT_PROXIMITY_THRESHOLD_M); + boolean nearStairs = isNearAny(pos, + mapMatcher.getStairCentersForFloor(currentApiFloor), STAIR_PROXIMITY_THRESHOLD_M); + if (!nearLift && !nearStairs) { + Log.d("SensorFusion", "Barometer floor change skipped: not near a lift or stairs"); + return; + } + // Bullet 5 (Map Matcher): use state.elevator (the PDR movement model) together with proximity + // to decide whether the user is in a lift or on stairs. state.elevator is true + // when vertical acceleration dominates and step activity is low - the lift pattern. + // state.elevator is true when vertical acceleration dominates and step activity is low. + // Use it alongside proximity to decide lift vs stairs. + boolean movementModelSaysLift = state.elevator; + String transitionType; + if (nearLift && movementModelSaysLift) { + transitionType = "lift"; + } else if (nearStairs && !movementModelSaysLift) { + transitionType = "stairs"; + } else { + // Fallback: proximity wins when movement model and geometry disagree + transitionType = nearLift ? "lift" : "stairs"; + } + + int direction = elevationDelta > 0 ? 1 : -1; + int newApiFloor = currentApiFloor; + for (int i = 0; i < floorsToMove; i++) { + int next = mapMatcher.getAdjacentFloorIndex(newApiFloor, direction); + if (next == newApiFloor) break; // already at the top or bottom - stop + newApiFloor = next; + } + + if (newApiFloor != currentApiFloor) { + mapMatcher.setCurrentFloor(newApiFloor); + // Advance reference by the consumed distance, preserving the remainder + lastFloorElevation += floorsToMove * floorHeight * direction; + Log.i("SensorFusion", "Floor changed to " + newApiFloor + + " via " + transitionType + " (barometer, " + floorsToMove + + " floor(s), delta=" + elevationDelta + "m)"); + } + } + + /** + * Returns true if the estimated position is near a staircase or lift on the current floor. + * Called by TrajectoryMapFragment before applying an auto floor change. + */ + public boolean isNearTransition() { + if (mapMatcher == null || !mapMatcher.isInitialised()) return false; + if (particleFilter == null || !particleFilter.isInitialised()) return false; + int floor = mapMatcher.getLikelyFloorIndex(); + float[] pos = particleFilter.getEstimatedLocalPosition(); + return isNearAny(pos, mapMatcher.getStairCentersForFloor(floor), STAIR_PROXIMITY_THRESHOLD_M) + || isNearAny(pos, mapMatcher.getLiftCentersForFloor(floor), LIFT_PROXIMITY_THRESHOLD_M); + } + + /** Returns true if pos is within threshold meters of any centre in the list. */ + private boolean isNearAny(float[] pos, List centers, float threshold) { + if (centers == null || pos == null) return false; + for (float[] c : centers) { + float dx = pos[0] - c[0], dy = pos[1] - c[1]; + if (Math.sqrt(dx * dx + dy * dy) <= threshold) return true; + } + return false; + } + //endregion //region Location listener @@ -694,4 +823,4 @@ public void onLocationChanged(@NonNull Location location) { } //endregion -} +} \ No newline at end of file From 02eeac50f2ebda922845dcac3bbd3174cb7e4ffe Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Wed, 1 Apr 2026 20:52:42 +0100 Subject: [PATCH 11/14] Comment clarification --- .../PositionMe/sensors/ParticleFilter.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index c2e44fa6..2e1045eb 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -8,7 +8,11 @@ import java.util.Random; /** - * The Particle Filter class represents a sensor fusion algorithm + * The Particle Filter class upholds a sensor fusion algorithm. + * + * Fuses PDR motion estimates with WiFi and GNSS position fixes through + * a predict, update and resample cycle to produce a fused position estimate + * of user location. */ public class ParticleFilter { @@ -19,7 +23,7 @@ public class ParticleFilter { // Meters conversion constant for WGS84 to easting, northing space private static final double Meters_Per_Degree = 111111.0; - // Initial standard deviation spread from GNSS/WiFi fix. + // Approximated standard deviation spread from GNSS/WiFi fix. private static final float GNSS_Init_STD = 15.0f; private static final float WiFi_Init_STD = 8.0f; @@ -40,7 +44,7 @@ public class ParticleFilter { private float estimatedX; private float estimatedY; - // True once initial position estimation has been established + // True once initial position estimate has been established private boolean initialised; private final Random random; @@ -54,7 +58,7 @@ private static class Particle{ // Easting offset from local origin float x; - //Northing offset from local origin + // Northing offset from local origin float y; // Normalised importance weight @@ -104,7 +108,7 @@ private void initialisePosition() { // Defer initialisation if neither GNSS or WiFi have a valid reading if (!hasGNSS && !hasWifi) { - return; // Initialisation retried via tryInitialise() on later call + return; } // Initialisation source and particle spread based on priority @@ -152,8 +156,8 @@ public void tryInitialise() { } /** - * Distributes particles as an isotropic 2D gaussian distribution around - * (centerX, centerY) and assigns equal weights to each particle (1/N). + * Distributes particles as an isotropic 2D Gaussian distribution around + * (centerX, centerY) and assigns equal weights to each particle (1/Num_Particles). */ private void spreadParticles(float centerX, float centerY, float stdM) { float initWeight = 1.0f / Num_Particles; @@ -219,6 +223,8 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { } } } + + // Debug and terminal verification Log.d("ParticleFilter", "Wall rejection (floor " + floorIndex + "): " + snapped + "/" + Num_Particles + " particles snapped this step"); } @@ -231,13 +237,13 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { estimatedY += particles[i].y * particles[i].weight; } - //Debug test + //Debug test and terminal verification Log.d("ParticleFilter", "Delta: (" + deltaEast + ", " + deltaNorth + " ; Estimate: ("+ estimatedX + ", " + estimatedY +")"); } /** - * Particle weight update + * */ public void updateWeights(LatLng measurementLatLng, float accuracy) { // Early return if not initialised @@ -307,7 +313,10 @@ public void updateWeights(LatLng measurementLatLng, float accuracy) { } /** - * Systematic resampling of particles for weight degeneration solution (SIR) + * Systematic resampling of particles for weight degeneration solution. + * + * Eliminates low weight particles and duplicates high weight particles when + * N_eff < Num_Particles / 2. */ private void resample() { // Calculate effective sample size as 1 / sum of particle weights squared @@ -331,7 +340,7 @@ private void resample() { cumulativeSum[i] = cumulativeSum[i-1] + particles[i].weight; } - // Set uniform staring point + // Set uniform starting point float overN = 1.0f / Num_Particles; float u1 = random.nextFloat() * overN; @@ -353,6 +362,7 @@ private void resample() { // Replace particle array with resampled particles System.arraycopy(resampledParticles, 0, particles, 0, Num_Particles); + // Debugging terminal output line Log.d("ParticleFilter", "Resampling complete: all weights reset to " + uniformWeighting); } @@ -389,7 +399,7 @@ public LatLng localToLatLng(float eastingM, float northingM) { } /** - * State accessors defined below. + * State accessors: */ public boolean isInitialised() { From bb3a20d332d0fab4d08b94c6551e19bd732a0485 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 1 Apr 2026 21:55:35 +0100 Subject: [PATCH 12/14] Final Changes --- .../fragment/RecordingFragment.java | 16 +-- .../fragment/TrajectoryMapFragment.java | 42 +++---- .../PositionMe/sensors/MapGeometry.java | 42 ++----- .../PositionMe/sensors/MapMatcher.java | 113 +++++++----------- .../PositionMe/sensors/ParticleFilter.java | 11 +- .../PositionMe/sensors/SensorFusion.java | 16 +-- 6 files changed, 91 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java index 427d69c6..2dfee84a 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java @@ -213,7 +213,7 @@ public void onViewCreated(@NonNull View view, // Start the 1-second fused position + trajectory update loop fusedTrajectoryHandler.postDelayed(fusedTrajectoryTask, 1000); - + // Start the timed or indefinite UI refresh if (this.settings.getBoolean("split_trajectory", false)) { // A maximum recording time is set @@ -249,7 +249,7 @@ private void onAddTestPoint() { LatLng cur = trajectoryMapFragment.getCurrentLocation(); if (cur == null) { Toast.makeText(requireContext(), "" + - "I haven't gotten my current location yet, let me take a couple of steps/wait for the map to load.", + "I haven't gotten my current location yet, let me take a couple of steps/wait for the map to load.", Toast.LENGTH_SHORT).show(); return; } @@ -349,7 +349,7 @@ private void updateUIandPosition() { } // --- Colour-coded observation markers --- - + // GNSS observation: add a blue marker whenever the raw GNSS fix changes float[] gnssRaw = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG); if (gnssRaw != null) { @@ -360,7 +360,7 @@ private void updateUIandPosition() { lastGnssObsPos = gnssObs; } } - + // WiFi observation: add an orange marker and correct the particle filter // whenever a new WiFi fix arrives LatLng wifiObs = sensorFusion.getLatLngWifiPositioning(); @@ -370,13 +370,13 @@ private void updateUIandPosition() { sensorFusion.correctWithWifiPosition(wifiObs); lastWifiObsPos = wifiObs; } - + // PDR observation: add a red marker whenever PDR position has moved ≥ 1 m LatLng currentLoc = trajectoryMapFragment.getCurrentLocation(); if (currentLoc != null) { double pdrDelta = Math.sqrt( Math.pow(pdrValues[0] - previousObsPosX, 2) - + Math.pow(pdrValues[1] - previousObsPosY, 2)); + + Math.pow(pdrValues[1] - previousObsPosY, 2)); if (pdrDelta >= 1.0) { trajectoryMapFragment.addObservationMarker(currentLoc, TrajectoryMapFragment.ObservationSource.PDR); @@ -384,7 +384,7 @@ private void updateUIandPosition() { previousObsPosY = pdrValues[1]; } } - + // Update previous previousPosX = pdrValues[0]; @@ -438,4 +438,4 @@ private static class TestPoint { private final List testPoints = new ArrayList<>(); -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 9a472133..f5174feb 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -43,11 +43,11 @@ /** * A fragment responsible for displaying a trajectory map using Google Maps. - * + * * The TrajectoryMapFragment provides a map interface for visualizing movement trajectories, * GNSS tracking, and indoor mapping. It manages map settings, user interactions, and real-time * updates to user location and GNSS markers. - * + * * Key Features: * - Displays a Google Map with support for different map types (Hybrid, Normal, Satellite). * - Tracks and visualizes user movement using polylines. @@ -66,16 +66,16 @@ public class TrajectoryMapFragment extends Fragment { /** Sources for colour-coded position observations on the map. */ public enum ObservationSource { GNSS, WIFI, PDR } - + // Observation marker colours private static final int COLOR_GNSS_OBS = 0xFF2196F3; // blue (unused directly; hue used below) private static final int COLOR_WIFI_OBS = 0xFFFF9800; // orange private static final int COLOR_PDR_OBS = 0xFFF44336; // red private static final int COLOR_FUSED = 0xFF9C27B0; // purple – fused trajectory - + /** Weight applied to the newest sample in the EMA smoothing filter (0 < α ≤ 1). */ private static final float EMA_ALPHA = 0.3f; - + private GoogleMap gMap; // Google Maps instance private LatLng currentLocation; // Stores the user's current location private Marker orientationMarker; // Marker representing user's heading @@ -93,18 +93,18 @@ public enum ObservationSource { GNSS, WIFI, PDR } // Fused trajectory – updated every 1 s or on movement private Polyline fusedTrajectoryPolyline; private LatLng lastFusedTrajectoryPoint = null; - + // Last 3 observation circle-markers per source; index 0 = newest (label "1") private static final int OBS_HISTORY = 3; private final Deque gnssObsMarkers = new ArrayDeque<>(); private final Deque wifiObsMarkers = new ArrayDeque<>(); private final Deque pdrObsMarkers = new ArrayDeque<>(); - + // EMA smoothing state private boolean smoothingEnabled = false; private double smoothedLat = Double.NaN; private double smoothedLng = Double.NaN; - + private LatLng pendingCameraPosition = null; // Stores pending camera movement private boolean hasPendingCameraMove = false; // Tracks if camera needs to move @@ -246,7 +246,7 @@ public void onMapReady(@NonNull GoogleMap googleMap) { if (polyline != null) polyline.setVisible(isChecked); }); } - + // Auto-floor toggle: start/stop periodic floor evaluation sensorFusion = SensorFusion.getInstance(); autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { @@ -308,7 +308,7 @@ private void initMapSettings(GoogleMap map) { .width(5f) .add() // start empty ); - + // Fused best-estimate trajectory in purple fusedTrajectoryPolyline = map.addPolyline(new PolylineOptions() .color(COLOR_FUSED) @@ -476,7 +476,7 @@ public void updateGNSS(@NonNull LatLng gnssLocation) { */ public void updateFusedPosition(@NonNull LatLng pos, float orientation) { if (gMap == null) return; - + LatLng displayPos; if (smoothingEnabled) { if (Double.isNaN(smoothedLat)) { @@ -491,7 +491,7 @@ public void updateFusedPosition(@NonNull LatLng pos, float orientation) { } else { displayPos = pos; } - + if (orientationMarker == null) { orientationMarker = gMap.addMarker(new MarkerOptions() .position(displayPos) @@ -506,15 +506,15 @@ public void updateFusedPosition(@NonNull LatLng pos, float orientation) { orientationMarker.setRotation(orientation); gMap.moveCamera(CameraUpdateFactory.newLatLng(displayPos)); } - + currentLocation = displayPos; - + if (indoorMapManager != null) { indoorMapManager.setCurrentLocation(displayPos); setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE); } } - + /** * Appends a point to the purple fused-trajectory polyline. * Only adds a point when the position has actually changed to avoid duplicate vertices. @@ -524,13 +524,13 @@ public void updateFusedPosition(@NonNull LatLng pos, float orientation) { public void updateFusedTrajectory(@NonNull LatLng pos) { if (gMap == null || fusedTrajectoryPolyline == null) return; if (pos.equals(lastFusedTrajectoryPoint)) return; - + List points = new ArrayList<>(fusedTrajectoryPolyline.getPoints()); points.add(pos); fusedTrajectoryPolyline.setPoints(points); lastFusedTrajectoryPoint = pos; } - + // Show a numbered circle marker for the given source, keeping last 3 positions. // Circle 1 = latest, 3 = oldest. public void addObservationMarker(@NonNull LatLng pos, @NonNull ObservationSource source) { @@ -599,7 +599,7 @@ private Bitmap makeObsCircleBitmap(int solidColor, int number) { return bmp; } - + /** * Enables or disables the EMA position smoothing filter. * Disabling resets the filter so the next call re-seeds from the raw position. @@ -613,7 +613,7 @@ public void setSmoothingEnabled(boolean enabled) { smoothedLng = Double.NaN; } } - + /** * Remove GNSS marker if user toggles it off */ @@ -687,14 +687,14 @@ public void clearMapAndReset() { wifiObsMarkers.clear(); for (Marker m : pdrObsMarkers) m.remove(); pdrObsMarkers.clear(); - + // Clear fused trajectory if (fusedTrajectoryPolyline != null) { fusedTrajectoryPolyline.remove(); fusedTrajectoryPolyline = null; } lastFusedTrajectoryPoint = null; - + // Reset EMA filter smoothedLat = Double.NaN; smoothedLng = Double.NaN; diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java index a37dd243..6e1d29c2 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapGeometry.java @@ -6,13 +6,10 @@ import java.util.List; /** - * Geometry utility class for the map matcher. All methods are static - no instances are created. - * - * Provides a point-in-polygon test used to detect which building the user is in, and a - * segment-crossing test used by ParticleFilter to check whether a particle's movement - * crosses a wall. Also runs a self-test on load to verify both work correctly. - * - * All coordinates are local ENU metres matching the frame used by ParticleFilter and MapMatcher. + * Seperated in a different class for modularity. + * Static geometry helpers used by MapMatcher and ParticleFilter. + * Handles point-in-polygon checks and wall-crossing detection. + * Coordinates are local metres, same frame as ParticleFilter. */ class MapGeometry { @@ -30,8 +27,8 @@ private MapGeometry() {} * edges it crosses. An odd count means inside, even means outside. * Returns false for null or degenerate polygons (fewer than 3 vertices). * - * @param x easting in local ENU metres - * @param y northing in local ENU metres + * @param x easting in local metres + * @param y northing in local metres * @param polygon list of float[]{eastingM, northingM} vertices in order * @return true if the point is inside the polygon */ @@ -60,10 +57,6 @@ static boolean isPointInsidePolygon(float x, float y, List polygon) { /** * Returns true if the segment from (x1,y1) to (x2,y2) crosses any edge of the polygon. * - * Bullet 3 (Map Matcher): this is the core wall-crossing test. ParticleFilter.predict() calls this - * for every particle after it is displaced. If the move segment crosses a wall edge, - * the particle is snapped back, correcting any estimate that would pass through a wall. - * * Called by ParticleFilter.predict() after each particle is displaced. If the move * segment crosses a wall edge, the particle is snapped back to its previous position. * Iterates all consecutive vertex pairs of the polygon and checks each edge with @@ -71,10 +64,10 @@ static boolean isPointInsidePolygon(float x, float y, List polygon) { * * Returns false for null or single-point polygons. * - * @param x1 start easting in local ENU metres - * @param y1 start northing in local ENU metres - * @param x2 end easting in local ENU metres - * @param y2 end northing in local ENU metres + * @param x1 start easting in local metres + * @param y1 start northing in local metres + * @param x2 end easting in local metres + * @param y2 end northing in local metres * @param polygon wall vertices as float[]{eastingM, northingM} */ static boolean doesSegmentCrossPolygon( @@ -93,14 +86,8 @@ static boolean doesSegmentCrossPolygon( } /** - * Tests whether segment AB and segment CD intersect. - * - * Uses the parametric cross-product method. Each segment is expressed as a - * start point plus a direction vector. The scalar parameters t and u represent - * how far along each segment the potential crossing point lies. If both t and u - * are in [0, 1], the crossing point is within both segment lengths, so they - * intersect. If the cross product of the two direction vectors is near zero, - * the segments are parallel and cannot intersect. + * Returns true if segment AB and segment CD intersect. + * Uses parametric cross-product; returns false for parallel/collinear segments. * * @param ax start of segment A - easting * @param ay start of segment A - northing @@ -123,9 +110,7 @@ private static boolean segmentsIntersect( return t >= 0f && t <= 1f && u >= 0f && u <= 1f; } - // ------------------------------------------------------------------------- - // Self-test (Step 2) - // ------------------------------------------------------------------------- + /** * Runs a set of known-answer checks against isPointInsidePolygon and @@ -148,7 +133,6 @@ static void selfTest() { isPointInsidePolygon(15f, 5f, square), false); // Segment crossing the left edge of the square (x=-2 to x=5, y=5): should cross - // Segment entering the square through the left edge - should cross check("doesSegmentCrossPolygon(-2,5 -> 5,5) == true", doesSegmentCrossPolygon(-2f, 5f, 5f, 5f, square), true); diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java index 9b39162b..72cab52c 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/MapMatcher.java @@ -12,13 +12,12 @@ import java.util.Map; /** - * Loads and stores the floorplan geometry for the current building. Used by the rest of the - * system for three purposes: supplying per-floor wall data to ParticleFilter so particles - * cannot pass through walls, storing stair and lift centroids so SensorFusion can check - * whether the user is near a transition point before accepting a floor change, and resolving - * the current floor index by combining the WiFi floor reading with any barometer-confirmed - * override. Handles buildings where the API floor list is stored in non-physical order - * (e.g. Nucleus stores GF at index 4 rather than index 0). + * Seperated in a different class for modularity. + * Loads and stores floorplan geometry for the current building. + * Provides wall data to ParticleFilter, stair/lift centroids to SensorFusion, + * and floor index resolution combining WiFi with barometer overrides. + * Handles buildings where the API floor list is not in physical order + * (e.g. Nucleus stores GF at index 4). * * Call tryLoadBuilding() once at the start of a recording to initialise. */ @@ -31,11 +30,11 @@ class MapMatcher { - // A single wall polygon or polyline from the floorplan API, converted to local ENU metres. + // A single wall polygon or polyline from the floorplan API, converted to local metres. static class WallFeature { // Vertices as {eastingM, northingM} relative to the particle filter origin final List localPoints; - // True for filled Polygon/MultiPolygon shapes; false for LineString wall segments + // True for filled Polygon/MultiPolygon shapes, false for LineString wall segments final boolean isPolygon; WallFeature(List pts, boolean isPolygon) { @@ -61,7 +60,7 @@ static class WallFeature { @SuppressWarnings("unchecked") private List[] wallsByFloor = null; - // Centroids of staircase and lift features on each floor, in local ENU metres. + // Centroids of staircase and lift features on each floor, in local metres. // Used by SensorFusion.isNearTransition() to gate barometer floor changes. @SuppressWarnings("unchecked") private List[] stairCentersByFloor = null; @@ -72,17 +71,15 @@ static class WallFeature { // store the confirmed API index here. -1 means no override, fall back to WiFi. private int currentFloorOverride = -1; - // Physical floor number (LG=-1, GF=0, F1=1 ...) -> API floorShapes list index. + // Physical floor number (LG=-1, GF=0, F1=1 etc...) -> API floorShapes list index. // Built at load time. Needed because some buildings store floors out of physical order - // (e.g. Nucleus: [LG, F1, F2, F3, GF] so GF is at index 4, not 0). + // (e.g. Nucleus: (LG, F1, F2, F3, GF) so GF is at index 4, not 0). private Map physicalToApiIndex = null; /** * Creates a MapMatcher. No geometry is loaded until tryLoadBuilding() is called. - * - * @param sf the SensorFusion singleton used to access the floorplan cache */ MapMatcher(SensorFusion sf) { this.sensorFusion = sf; @@ -92,17 +89,13 @@ static class WallFeature { /** * Loads wall, stair, and lift features from the floorplan cache for the current building. * - * Building selection works in order: preferred name first (if provided), then + * Building is selected in order: preferred name first (if provided), then * point-in-polygon check against all cached building outlines, then closest center - * as a last resort. Returns early without loading if origin is null or no building - * is found in the cache. + * as fallback. Returns early if origin is null or no building is found in the cache. * * On success, builds the displayName and physicalFloor index lookup maps, converts - * every floor's wall/stair/lift geometry to local ENU metres, and runs - * MapGeometry.selfTest() to verify the geometry helpers. + * all floor geometry to local metres, and runs MapGeometry.selfTest(). * - * @param preferredBuildingId building name from floorplan API (e.g. "nucleus_building"); null to auto-detect - * @param origin local frame origin from ParticleFilter */ @SuppressWarnings("unchecked") void tryLoadBuilding(String preferredBuildingId, LatLng origin) { @@ -111,7 +104,7 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { return; } - // 1. Preferred building by name + // Preferred building by name FloorplanApiClient.BuildingInfo building = null; if (preferredBuildingId != null && !preferredBuildingId.isEmpty()) { building = sensorFusion.getFloorplanBuilding(preferredBuildingId); @@ -120,7 +113,7 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { } } - // 2. Polygon detection against all cached buildings + // Polygon detection against all cached buildings if (building == null) { for (FloorplanApiClient.BuildingInfo b : sensorFusion.getFloorplanBuildings()) { List outline = b.getOutlinePolygon(); @@ -133,7 +126,7 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { } } - // 3. Closest center fallback + // Closest center fallback if (building == null) { building = closestBuilding(origin); if (building != null) { @@ -157,8 +150,8 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { mapOrigin = origin; loadedBuildingId = building.getName(); - // Build both lookup maps: displayName -> index (for adjacent-floor arithmetic) - // and physicalFloor -> index (for WiFi floor conversion) + // Build both lookup maps: displayName to index (for adjacent-floor arithmetic) + // and physicalFloor to index (for WiFi floor conversion) displayNameToIndex = new HashMap<>(); physicalToApiIndex = new HashMap<>(); for (int f = 0; f < numFloors; f++) { @@ -180,14 +173,8 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { liftCentersByFloor[f] = new ArrayList<>(); FloorplanApiClient.FloorShapes floor = floorShapes.get(f); - // Bullet 2 (Map Matcher): the floorplan API gives each feature an indoor_type. We sort every - // feature on this floor into one of the three required categories: - // "wall" -> stored as WallFeature for wall-crossing checks (Bullet 3, Map Matcher) - // "stairs"/"staircase" -> centroid stored for proximity gating (Bullets 4 & 5, Map Matcher) - // "lift"/"elevator" -> centroid stored for proximity gating (Bullets 4 & 5, Map Matcher) - // Walk every feature on this floor and sort it into walls, stairs, or lifts. - // Wall parts are stored as WallFeature objects (polygon or polyline). - // Stair and lift parts have their centroid computed and stored for proximity checks. + // Sort features into walls (for crossing checks) and + // stairs/lifts (centroid stored for proximity gating). for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) { String type = feature.getIndoorType(); @@ -234,7 +221,6 @@ void tryLoadBuilding(String preferredBuildingId, LatLng origin) { /** * Returns true once tryLoadBuilding() has successfully parsed at least one floor. - * All other public methods guard against being called before this returns true. */ boolean isInitialised() { return loadedBuildingId != null && mapOrigin != null && numFloors > 0; @@ -257,20 +243,12 @@ String getLoadedBuildingId() { } /** - * Returns the best guess for which API floorShapes index the user is currently on. - * Index 0 matches floorShapesList[0], index 1 matches floorShapesList[1], and so on. - * - * Bullet 1 (Map Matcher): combines WiFi and barometer data to give the rest of the system a single - * reliable floor number, accounting for buildings where the API list is in non-physical - * order (e.g. Nucleus stores GF at index 4). - * - * A barometer-confirmed override (set by SensorFusion when near lift) takes - * priority over the WiFi reading. When no override is set, the WiFi floor integer is - * converted to an API index via physicalToApiIndex. Falls back to the bias formula - * if the lookup map is not ready yet (Nucleus/Murchison: WiFi 0 maps to index 1; - * other buildings: WiFi 0 maps to index 0). + * Returns the current API floorShapes index. Index 0 = floorShapesList[0], etc. * - * Result is clamped to [0, numFloors-1]. + * A barometer-confirmed override set by SensorFusion takes priority over the WiFi + * reading. When no override is set, the WiFi floor integer is converted to an API + * index via physicalToApiIndex. Falls back to the bias formula if the lookup map + * is not ready yet (Nucleus/Murchison: WiFi 0 maps to index 1; others: index 0). */ int getLikelyFloorIndex() { // Barometer override beats WiFi when SensorFusion has confirmed a floor change @@ -280,7 +258,7 @@ int getLikelyFloorIndex() { } int wifiFloor = sensorFusion.getWifiFloor(); // physicalToApiIndex handles buildings where the API list is not in physical order - // (e.g. Nucleus: [LG, F1, F2, F3, GF] - WiFi 0 = GF = API index 4, not index 1). + // (e.g. Nucleus: (LG, F1, F2, F3, GF) - WiFi 0 = GF = API index 4, not index 1). // Falls back to the bias formula when the map has not loaded yet. Integer apiIdx = (physicalToApiIndex != null) ? physicalToApiIndex.get(wifiFloor) : null; int result = (apiIdx != null) @@ -293,9 +271,8 @@ int getLikelyFloorIndex() { /** * Returns the index offset between WiFi floor 0 (GF) and its position in the API floor list. - * Nucleus and Murchison store LG at index 0, so GF sits at index 1 (bias = 1). - * All other buildings place GF at index 0, so no offset is needed (bias = 0). - * Kept in sync with IndoorMapManager.getAutoFloorBias(). + * Nucleus and Murchison store LG at index 0, so GF is at index 1 (bias = 1). + * All other buildings place GF at index 0 (bias = 0). */ int getAutoFloorBias() { if ("nucleus_building".equals(loadedBuildingId) @@ -306,12 +283,12 @@ int getAutoFloorBias() { } /** - * Returns the approximate floor-to-floor height in metres for the loaded building. - * SensorFusion divides accumulated elevation change by this value to decide how many - * floors the user has moved. Kept in sync with IndoorMapManager's per-building constants. + * Returns the floor-to-floor height in metres for the loaded building. + * SensorFusion divides the accumulated barometer elevation change by this value + * to determine how many floors the user has moved. */ float getFloorHeight() { - if ("nucleus_building".equals(loadedBuildingId)) return 4.2f; + if ("nucleus_building".equals(loadedBuildingId)) return 5.0f; if ("murchison_house".equals(loadedBuildingId)) return 4.0f; if ("library".equals(loadedBuildingId)) return 3.6f; return 4.0f; // generic fallback @@ -326,20 +303,20 @@ void setCurrentFloor(int floor) { currentFloorOverride = floor; } - // Returns the staircase centroids in local ENU metres for floor f, or an empty list. + // Returns the staircase centroids in local metres for floor f, or an empty list. List getStairCentersForFloor(int f) { if (stairCentersByFloor == null || f < 0 || f >= numFloors) return new ArrayList<>(); return stairCentersByFloor[f]; } - // Returns the lift centroids in local ENU metres for floor f, or an empty list. + // Returns the lift centroids in local metres for floor f, or an empty list. List getLiftCentersForFloor(int f) { if (liftCentersByFloor == null || f < 0 || f >= numFloors) return new ArrayList<>(); return liftCentersByFloor[f]; } /** - * Converts a physical floor number (LG=-1, GF=0, F1=1, F2=2, ...) to its + * Converts a physical floor number (LG=-1, GF=0, F1=1, F2=2, etc ...) to its * position in the API floorShapes list. Returns -1 if not found. * * SensorFusion calls this when WiFi changes floor rather than going through @@ -380,17 +357,11 @@ private String wifiFloorToDisplayName(int wifiFloor) { /** - * Returns the API floorShapes index that is physicalDelta floors above (positive) or - * below (negative) currentApiIndex in physical building height (LG=-1, GF=0, F1=1, ...). - * - * A simple +/-1 on the index would be wrong for buildings with non-physical API ordering - * (e.g. Nucleus: GF is at index 4, so going up one floor from GF must land on index 3 for - * F1, not index 5 which does not exist). Instead, we reverse-lookup the current floor's - * display name, convert it to a physical number, add the delta, then look up the resulting - * physical floor in physicalToApiIndex. - * Issue with being on GF and F1 is showed in auto-floor feature so this fixes that issue. - * - * Returns currentApiIndex unchanged if the target floor does not exist in the list. + * Returns the API index that is physicalDelta floors above/below currentApiIndex. + * Can't just do +/-1 on the index for buildings with non-physical API ordering + * (e.g. Nucleus GF is at index 4, not 0), so we convert via display name instead. + * Fixes the GF/F1 off-by-one seen in the auto-floor feature. + * Returns currentApiIndex unchanged if the target floor doesn't exist. */ int getAdjacentFloorIndex(int currentApiIndex, int physicalDelta) { if (physicalToApiIndex == null || displayNameToIndex == null) return currentApiIndex; @@ -427,7 +398,7 @@ private static int displayNameToPhysical(String name) { } /** - * Computes the centroid of a set of LatLng points in local ENU metres. + * Computes the centroid of a set of LatLng points in local metres. * Used to get a single representative point for stairs/lift polygons. * Returns null if pts is null or empty. */ diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java index 2e1045eb..705d0e6c 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/ParticleFilter.java @@ -138,9 +138,6 @@ private void initialisePosition() { /** * Sets the MapMatcher used for wall-crossing checks in predict(). * Called once from SensorFusion after both objects are created. - * - * Bullet 1 (Map Matcher): wires the map data into the particle filter so position estimates - * are constrained by the building's floor geometry. */ public void setMapMatcher(MapMatcher mm) { this.mapMatcher = mm; @@ -184,8 +181,6 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { return; } - // Bullet 3 (Map Matcher): save each particle's position before it is displaced so we have - // a valid rollback point if the move crosses a wall. // Save positions before the move so we can snap back any particle that crosses a wall float[] oldX = new float[Num_Particles]; float[] oldY = new float[Num_Particles]; @@ -202,11 +197,7 @@ public void predict(float deltaEast, float deltaNorth, float PDRstd) { particles[i].y += deltaNorth + noiseY; } - // Bullet 3 (Map Matcher): use the movement (PDR displacement) and the wall geometry from the map - // to reject any particle whose move segment crossed a wall. That particle is snapped - // back to its pre-move position, keeping the estimate inside walkable space. - // If MapMatcher is loaded, check each particle's move segment against walls on the - // current floor. Any particle that crossed a wall is snapped back to its old position. + // If MapMatcher is loaded, snap back any particle whose move crosses a wall on the current floor. if (mapMatcher != null && mapMatcher.isInitialised()) { int floorIndex = mapMatcher.getLikelyFloorIndex(); List walls = mapMatcher.getWallsForFloor(floorIndex); diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java index 6d2d5312..2acbc092 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -745,11 +745,9 @@ private void checkAndApplyFloorChange(float elevation) { return; } - // Bullet 4 (Map Matcher): check accumulated barometer elevation change against the building's - // floor height. Only accept a floor change if the user is physically near a - // staircase or lift centroid (from the map). Floor changes in the middle of an - // open room are ignored. - // Rule 2: Barometer - divide elapsed elevation by the building's floor height + // Rule 2: Barometer - checks accumulated elevation change against the building's floor + // height. Only accepts a floor change if the user is physically near a staircase or + // lift from the map. Floor changes in the middle of a room are ignored. float floorHeight = mapMatcher.getFloorHeight(); float elevationDelta = elevation - lastFloorElevation; int floorsToMove = (int) (Math.abs(elevationDelta) / floorHeight); @@ -767,11 +765,9 @@ private void checkAndApplyFloorChange(float elevation) { Log.d("SensorFusion", "Barometer floor change skipped: not near a lift or stairs"); return; } - // Bullet 5 (Map Matcher): use state.elevator (the PDR movement model) together with proximity - // to decide whether the user is in a lift or on stairs. state.elevator is true - // when vertical acceleration dominates and step activity is low - the lift pattern. - // state.elevator is true when vertical acceleration dominates and step activity is low. - // Use it alongside proximity to decide lift vs stairs. + // Uses state.elevator (the PDR movement model) together with proximity to decide + // whether the user is in a lift or on stairs. state.elevator is true when vertical + // acceleration dominates and step activity is low - the lift pattern. boolean movementModelSaysLift = state.elevator; String transitionType; if (nearLift && movementModelSaysLift) { From 93db7d71ff1487d6ec62d15c5f4b432e49362576 Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Thu, 2 Apr 2026 12:38:10 +0100 Subject: [PATCH 13/14] Key removal --- .gitignore | Bin 233 -> 289 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index d4c3a57eee159cd72ac70f71a203d2756ab24459..86feb6f3024f96b8208def09fa76fcd271ebc05b 100644 GIT binary patch literal 289 zcmZ`!F%H5o4D=i+A7LUdz#?rT$Aya5lZp=Qz;b7w&ZqMZrBB}BT`4B^ zZl7|Xghg{f8r;GS-38nM_`M_!%%vlXrig@vZ_x0BvYc R0im=xH{`|!(zt>L2Vc$DS}Xtn literal 233 zcmZ{fu@1s83`F;Q3WNI(2v(L3jL1!_V&Nu{h=W-%2)RL$ zM}e#L`q From 1133476cc388f5db913ee3e01f391aaa86122fea Mon Sep 17 00:00:00 2001 From: Joshua Bray Date: Thu, 2 Apr 2026 13:05:45 +0100 Subject: [PATCH 14/14] Stop tracking secrets.properties --- secrets.properties | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 secrets.properties diff --git a/secrets.properties b/secrets.properties deleted file mode 100644 index 893161db..00000000 --- a/secrets.properties +++ /dev/null @@ -1,6 +0,0 @@ -# -# Modify the variables to set your keys -# -MAPS_API_KEY=AIzaSyC13ios2NevfsRVS6yIj1arAM29ecy0GxQ -OPENPOSITIONING_API_KEY=c64dIaytmbCb84kU1-uuXw -OPENPOSITIONING_MASTER_KEY=ewireless