gps-plus-slam-app-framework 1.0.0 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +4 -4
- package/README.md +249 -108
- package/dist/accuracy-circles-CsxE_7x1.d.ts +43 -0
- package/dist/{app-selectors-DyMzxCEC.d.ts → app-selectors-BeU0_ab8.d.ts} +14 -15
- package/dist/ar/camera-blit-capture.d.ts +1 -1
- package/dist/ar/capture-failure-tracker.d.ts +1 -1
- package/dist/ar/chromium-camera-access-workaround.d.ts +1 -1
- package/dist/ar/depth-sampler.d.ts +2 -2
- package/dist/ar/depth-sampler.js +1 -1
- package/dist/ar/frame-loop.d.ts +2 -0
- package/dist/ar/frame-loop.js +40 -0
- package/dist/ar/image-capture.d.ts +1 -1
- package/dist/ar/image-capture.js +1 -1
- package/dist/ar/index.d.ts +12 -12
- package/dist/ar/index.js +3 -3
- package/dist/ar/replay-scene.d.ts +1 -1
- package/dist/ar/scene-node-names.d.ts +1 -1
- package/dist/ar/webxr-session.d.ts +4 -4
- package/dist/ar/webxr-session.js +151 -56
- package/dist/ar/xr-camera-texture.d.ts +1 -1
- package/dist/ar/xr-error-handler.d.ts +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/create-slam-app-store-CNsZ3rcX.d.ts +371 -0
- package/dist/{depth-sampler-BNCtnEoP.d.ts → depth-sampler-xg8vna6c.d.ts} +1 -1
- package/dist/file-system-DTApEv_Q.d.ts +114 -0
- package/dist/frame-loop-QJIxLin0.d.ts +42 -0
- package/dist/frustum-visibility-BHnFrSKQ.d.ts +58 -0
- package/dist/{fused-path-DtqCSqJh.d.ts → fused-path-CB4DwWOG.d.ts} +1 -1
- package/dist/geo/h3-proximity.d.ts +2 -0
- package/dist/{ref-points/h3-ref-point.js → geo/h3-proximity.js} +16 -12
- package/dist/geo/index.d.ts +2 -0
- package/dist/geo/index.js +2 -0
- package/dist/{geo-types-Xx-LVcnw.d.ts → geo-types-CTuF23gG.d.ts} +16 -1
- package/dist/{gps-Ddzr2KlX.d.ts → gps-DXoQb9Kf.d.ts} +1 -1
- package/dist/gps-anchor-CnWY61XK.d.ts +48 -0
- package/dist/gps-anchor-DjC_3MoT.js +257 -0
- package/dist/gps-ar-pose-sampler-DsM1Kb2N.d.ts +43 -0
- package/dist/{recording-coordinator-DzflP07_.d.ts → gps-event-coordinator-DBx9I1YX.d.ts} +7 -6
- package/dist/gps-event-markers-BGr-iLvi.d.ts +103 -0
- package/dist/{h3-ref-point-CFhayNSZ.d.ts → h3-proximity-BhfgNbdW.d.ts} +16 -12
- package/dist/{image-capture-2pKjYUS6.d.ts → image-capture-DZLxHDN9.d.ts} +1 -1
- package/dist/index-gzWJMVpo.d.ts +2 -0
- package/dist/index-pOjUCKEP.d.ts +24 -0
- package/dist/index.d.ts +59 -56
- package/dist/index.js +22 -21
- package/dist/{leaflet-map-overlay-CvfQXTye.d.ts → leaflet-map-overlay-Cew7XAoO.d.ts} +32 -17
- package/dist/licensing/index.d.ts +1 -1
- package/dist/licensing/index.js +1 -1
- package/dist/{logger-B81iwxx0.d.ts → logger-C9vugiQb.d.ts} +7 -1
- package/dist/map-data-sih4g64v.d.ts +49 -0
- package/dist/map-overlay-draw-B3R1c9Xp.d.ts +42 -0
- package/dist/{null-storage-backend-CsWvQwN8.d.ts → null-storage-backend-BKOS7JpL.d.ts} +4 -2
- package/dist/{opfs-storage-ChOzF_Wp.d.ts → opfs-storage-DOPkzgeT.d.ts} +54 -51
- package/dist/opfs-storage-backend-Cj_7PWoq.d.ts +13 -0
- package/dist/{permission-checker-jO2ziCWC.d.ts → permission-checker-DPJl_RU6.d.ts} +41 -1
- package/dist/persistence-middleware-BkHIqvaA.d.ts +55 -0
- package/dist/{recording-replayer-oOzPITrd.d.ts → recording-replayer-CmCIIZ02.d.ts} +2 -2
- package/dist/recording-slice-d6OtCrza.d.ts +28 -0
- package/dist/{replay-engine-DuX316ae.d.ts → replay-engine-BGBRQyXs.d.ts} +4 -1
- package/dist/{replay-scene-DtUm6bsr.d.ts → replay-scene-DpAGcAKY.d.ts} +3 -3
- package/dist/sensors/gps-error-handler.d.ts +1 -1
- package/dist/sensors/gps.d.ts +1 -1
- package/dist/sensors/gps.js +2 -2
- package/dist/sensors/index.d.ts +3 -3
- package/dist/sensors/index.js +1 -1
- package/dist/sensors/permission-checker.d.ts +2 -2
- package/dist/sensors/permission-checker.js +79 -1
- package/dist/state/app-selectors.d.ts +2 -2
- package/dist/state/app-selectors.js +33 -3
- package/dist/state/combined-root-state.d.ts +2 -0
- package/dist/state/combined-root-state.js +1 -0
- package/dist/state/create-slam-app-store.d.ts +2 -0
- package/dist/state/create-slam-app-store.js +75 -0
- package/dist/state/gps-ar-pose-sampler.d.ts +4 -0
- package/dist/state/gps-ar-pose-sampler.js +19 -0
- package/dist/state/{recording-coordinator.d.ts → gps-event-coordinator.d.ts} +1 -1
- package/dist/state/{recording-coordinator.js → gps-event-coordinator.js} +3 -4
- package/dist/state/index.d.ts +17 -17
- package/dist/state/index.js +10 -8
- package/dist/state/persistence-middleware.d.ts +2 -2
- package/dist/state/persistence-middleware.js +39 -11
- package/dist/state/recording-options.d.ts +1 -1
- package/dist/state/recording-replayer.d.ts +3 -2
- package/dist/state/recording-replayer.js +2 -2
- package/dist/state/recording-slice.d.ts +2 -0
- package/dist/state/{recorder-slice.js → recording-slice.js} +7 -15
- package/dist/state/replay-engine.d.ts +1 -1
- package/dist/state/replay-engine.js +3 -3
- package/dist/state/store-subscribers.d.ts +2 -2
- package/dist/state/store-subscribers.js +38 -20
- package/dist/state/subscribe-to-selector.d.ts +1 -1
- package/dist/state/tracking-quality.d.ts +2 -0
- package/dist/state/tracking-quality.js +723 -0
- package/dist/state/tracking-slice.d.ts +2 -0
- package/dist/state/tracking-slice.js +147 -0
- package/dist/storage/file-system-utils.d.ts +1 -1
- package/dist/storage/file-system.d.ts +2 -2
- package/dist/storage/file-system.js +154 -94
- package/dist/storage/index.d.ts +9 -11
- package/dist/storage/index.js +5 -7
- package/dist/storage/null-storage-backend.d.ts +1 -1
- package/dist/storage/null-storage-backend.js +7 -0
- package/dist/storage/opfs-storage-backend.d.ts +1 -1
- package/dist/storage/opfs-storage-backend.js +7 -1
- package/dist/storage/opfs-storage.d.ts +2 -2
- package/dist/storage/opfs-storage.js +69 -85
- package/dist/storage/storage-backend.d.ts +2 -2
- package/dist/storage/zip-export.d.ts +2 -2
- package/dist/storage/zip-export.js +55 -62
- package/dist/storage/zip-reader.d.ts +2 -2
- package/dist/storage/zip-reader.js +32 -5
- package/dist/storage-backend-CT0Il_AB.d.ts +28 -0
- package/dist/{store-subscribers-CuoIzB5T.d.ts → store-subscribers-Dce_CoJF.d.ts} +31 -7
- package/dist/{subscribe-to-selector-UTZ0KkAc.d.ts → subscribe-to-selector-DIHm3BRZ.d.ts} +1 -1
- package/dist/test-utils/browser-mocks.d.ts +1 -1
- package/dist/test-utils/zip-round-trip-helpers.d.ts +7 -1
- package/dist/test-utils/zip-round-trip-helpers.js +6 -5
- package/dist/tracking-slice-utCNND8t.d.ts +86 -0
- package/dist/types/ar-types.d.ts +1 -1
- package/dist/types/geo-types.d.ts +2 -2
- package/dist/types/index.d.ts +3 -3
- package/dist/utils/concurrency.d.ts +1 -1
- package/dist/utils/failure-tracker.d.ts +1 -1
- package/dist/utils/format-file-size.d.ts +1 -1
- package/dist/utils/fused-path.d.ts +1 -1
- package/dist/utils/index.d.ts +6 -6
- package/dist/utils/list-formatter.d.ts +1 -1
- package/dist/utils/logger.d.ts +1 -1
- package/dist/utils/logger.js +68 -8
- package/dist/visualization/accuracy-circles.d.ts +2 -0
- package/dist/visualization/accuracy-circles.js +57 -0
- package/dist/visualization/alignment-lerper.d.ts +1 -1
- package/dist/visualization/camera-follower.d.ts +1 -1
- package/dist/visualization/css3d-renderer-manager.d.ts +1 -1
- package/dist/visualization/frustum-visibility.d.ts +2 -0
- package/dist/visualization/frustum-visibility.js +139 -0
- package/dist/visualization/gps-anchor.d.ts +2 -0
- package/dist/visualization/gps-anchor.js +2 -0
- package/dist/visualization/gps-compass-cubes.d.ts +1 -1
- package/dist/visualization/gps-event-markers.d.ts +2 -2
- package/dist/visualization/gps-event-markers.js +69 -2
- package/dist/visualization/index.d.ts +17 -12
- package/dist/visualization/index.js +6 -2
- package/dist/visualization/leaflet-map-overlay.d.ts +1 -1
- package/dist/visualization/leaflet-map-overlay.js +56 -98
- package/dist/visualization/lerp-utils.d.ts +1 -1
- package/dist/visualization/map-data.d.ts +2 -0
- package/dist/visualization/map-data.js +33 -0
- package/dist/visualization/map-overlay-draw.d.ts +2 -0
- package/dist/visualization/map-overlay-draw.js +107 -0
- package/dist/visualization/map-overlay.d.ts +1 -1
- package/dist/visualization/three-dispose.d.ts +1 -1
- package/dist/visualization/vis-colors.d.ts +1 -1
- package/dist/{webxr-session-COlSrXdL.d.ts → webxr-session-DB5cIZnN.d.ts} +53 -12
- package/dist/{zip-export-Rtpi75JJ.d.ts → zip-export-QO4iLIi7.d.ts} +58 -7
- package/dist/{zip-reader-D7pkpX8K.d.ts → zip-reader-D1105idz.d.ts} +38 -1
- package/package.json +29 -18
- package/dist/ar/tracking-state.d.ts +0 -2
- package/dist/ar/tracking-state.js +0 -164
- package/dist/community-license-key-ChZLCJyO.d.ts +0 -21
- package/dist/file-system-BsQgYCU3.d.ts +0 -128
- package/dist/gps-event-markers-DpEy2qRd.d.ts +0 -67
- package/dist/licensing/community-license-key.d.ts +0 -2
- package/dist/licensing/community-license-key.js +0 -21
- package/dist/opfs-storage-backend-CVcmfJHN.d.ts +0 -11
- package/dist/persistence-middleware-DrptY02i.d.ts +0 -31
- package/dist/recorder-slice-fcwAXrsh.d.ts +0 -35
- package/dist/ref-point-importer-B_wn5ABk.d.ts +0 -62
- package/dist/ref-point-loader-Pt1smgHS.d.ts +0 -117
- package/dist/ref-points/h3-ref-point.d.ts +0 -2
- package/dist/ref-points/index.d.ts +0 -2
- package/dist/ref-points/index.js +0 -2
- package/dist/ref-points-slice-tC-Wd6XJ.d.ts +0 -48
- package/dist/reference-points-CFWy8_wj.d.ts +0 -68
- package/dist/routing-slice-BlHTM8eh.d.ts +0 -23
- package/dist/state/recorder-slice.d.ts +0 -2
- package/dist/state/ref-points-slice.d.ts +0 -2
- package/dist/state/ref-points-slice.js +0 -44
- package/dist/state/routing-slice.d.ts +0 -2
- package/dist/state/routing-slice.js +0 -13
- package/dist/state/store.d.ts +0 -9
- package/dist/state/store.js +0 -71
- package/dist/storage/ref-point-importer.d.ts +0 -2
- package/dist/storage/ref-point-importer.js +0 -163
- package/dist/storage/ref-point-loader.d.ts +0 -2
- package/dist/storage/ref-point-loader.js +0 -248
- package/dist/storage/ref-point-recovery.d.ts +0 -28
- package/dist/storage/ref-point-recovery.js +0 -159
- package/dist/storage-backend-BcEItnFr.d.ts +0 -20
- package/dist/store-C7PKOj8S.d.ts +0 -96
- package/dist/tracking-state-B9QfuZCg.d.ts +0 -101
- package/dist/visualization/reference-points.d.ts +0 -2
- package/dist/visualization/reference-points.js +0 -160
- /package/dist/{alignment-lerper-D9BeDwZX.d.ts → alignment-lerper-L1RbWHQ4.d.ts} +0 -0
- /package/dist/{ar-types-B-ORgk6Z.d.ts → ar-types-Ck8LEq8k.d.ts} +0 -0
- /package/dist/{camera-blit-capture-DHuyrrxr.d.ts → camera-blit-capture-C1V9I1Z6.d.ts} +0 -0
- /package/dist/{camera-follower-BMqPddxx.d.ts → camera-follower-1k8mnw5J.d.ts} +0 -0
- /package/dist/{capture-failure-tracker-2Q6oI2uQ.d.ts → capture-failure-tracker-JKb1ST8E.d.ts} +0 -0
- /package/dist/{chromium-camera-access-workaround-CU5zSKNr.d.ts → chromium-camera-access-workaround-NvChXsi8.d.ts} +0 -0
- /package/dist/{concurrency-p6gB30fT.d.ts → concurrency-CMw8pSaG.d.ts} +0 -0
- /package/dist/{css3d-renderer-manager-rgLMVAvd.d.ts → css3d-renderer-manager-CkjCb2HW.d.ts} +0 -0
- /package/dist/{failure-tracker-1sQ_Cgyw.d.ts → failure-tracker-DnHXOIRa.d.ts} +0 -0
- /package/dist/{file-system-utils-5cWO8Xyv.d.ts → file-system-utils-CtKiiw27.d.ts} +0 -0
- /package/dist/{format-file-size-onGq0k1Q.d.ts → format-file-size-BTS5H62-.d.ts} +0 -0
- /package/dist/{gps-compass-cubes-C-6JEnpR.d.ts → gps-compass-cubes-CsLtFv4B.d.ts} +0 -0
- /package/dist/{gps-error-handler-VPuNOuf8.d.ts → gps-error-handler-Dg3ziRVY.d.ts} +0 -0
- /package/dist/{lerp-utils-87NWjUOD.d.ts → lerp-utils-B8MM8zCR.d.ts} +0 -0
- /package/dist/{list-formatter-DKQQVLcl.d.ts → list-formatter-CNqi-E2j.d.ts} +0 -0
- /package/dist/{map-overlay-DuiZuCTx.d.ts → map-overlay-DAmreyA3.d.ts} +0 -0
- /package/dist/{recording-options-BYUFX8iU.d.ts → recording-options-Dq6IsKAv.d.ts} +0 -0
- /package/dist/{scene-node-names-DPsaE2z3.d.ts → scene-node-names-6LBjPmwz.d.ts} +0 -0
- /package/dist/{three-dispose-CCBHjE_a.d.ts → three-dispose-C2drkp6f.d.ts} +0 -0
- /package/dist/{vis-colors-Qi2vlRGt.d.ts → vis-colors-CHQhjal3.d.ts} +0 -0
- /package/dist/{xr-camera-texture-BbukNE79.d.ts → xr-camera-texture-BU5YvKAM.d.ts} +0 -0
- /package/dist/{xr-error-handler-DTHfmvrl.d.ts → xr-error-handler-CDY5QxGb.d.ts} +0 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { selectLastSensorOrientation, selectLastValidPose, selectTrackingPhase } from "./tracking-slice.js";
|
|
2
|
+
import { selectAlignmentMatrix, selectGpsPositions, selectOdometryPositions, selectZeroReference } from "./app-selectors.js";
|
|
3
|
+
import { calcGpsCoords, distanceInMeters } from "gps-plus-slam-js";
|
|
4
|
+
import { createListenerMiddleware, createSlice } from "@reduxjs/toolkit";
|
|
5
|
+
import { mat4, quat, vec3 } from "gl-matrix";
|
|
6
|
+
//#region ../src/state/tracking-quality.ts
|
|
7
|
+
const DEFAULT_TRACKING_QUALITY_OPTIONS = {
|
|
8
|
+
matrixHistorySize: 5,
|
|
9
|
+
residualWindowSize: 16,
|
|
10
|
+
degradedThreshold: .5,
|
|
11
|
+
gpsAccuracyWindowSize: 30,
|
|
12
|
+
coverageWalkedDistanceM: 15,
|
|
13
|
+
coverageDirectionSpreadDeg: 90,
|
|
14
|
+
convergenceRotationWarnDeg: 6,
|
|
15
|
+
convergenceTranslationWarnM: 2,
|
|
16
|
+
compassWarnDeg: 15,
|
|
17
|
+
compassFailDeg: 35,
|
|
18
|
+
warmupMinObservations: 10,
|
|
19
|
+
warmupMinCoverage: .5,
|
|
20
|
+
residualConfidenceTargetM: 3,
|
|
21
|
+
gpsAccuracyFloorM: 1,
|
|
22
|
+
firstAgreementMinStreak: 3,
|
|
23
|
+
degradedHoldoff: 3,
|
|
24
|
+
convergenceEmaAlpha: .3
|
|
25
|
+
};
|
|
26
|
+
const initialState = {
|
|
27
|
+
recentAlignments: [],
|
|
28
|
+
firstAgreementObservationIndex: null,
|
|
29
|
+
report: null,
|
|
30
|
+
degradedConsecutiveCount: 0,
|
|
31
|
+
smoothedConvergence: null
|
|
32
|
+
};
|
|
33
|
+
function clamp01(x) {
|
|
34
|
+
if (!Number.isFinite(x)) return 0;
|
|
35
|
+
if (x < 0) return 0;
|
|
36
|
+
if (x > 1) return 1;
|
|
37
|
+
return x;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* §4.8b (Finding 4) — exponential moving-average blend. When `prev` is
|
|
41
|
+
* `null` the filter has no history yet and we seed it to `raw`.
|
|
42
|
+
* `alpha = 1` disables smoothing (returns `raw`); `alpha = 0` freezes
|
|
43
|
+
* the filter at `prev`. Non-finite `alpha` is treated as 1 (no
|
|
44
|
+
* smoothing) to fail safe — never silently swallow a valid raw signal.
|
|
45
|
+
*/
|
|
46
|
+
function emaBlend(prev, raw, alpha) {
|
|
47
|
+
if (prev === null) return raw;
|
|
48
|
+
return prev + (Number.isFinite(alpha) ? alpha : 1) * (raw - prev);
|
|
49
|
+
}
|
|
50
|
+
function wrapToHalfCircle(deg) {
|
|
51
|
+
let d = (deg + 180) % 360 - 180;
|
|
52
|
+
if (d < -180) d += 360;
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
55
|
+
function bearingDeg(north, east) {
|
|
56
|
+
let deg = Math.atan2(east, north) * 180 / Math.PI;
|
|
57
|
+
if (deg < 0) deg += 360;
|
|
58
|
+
return deg;
|
|
59
|
+
}
|
|
60
|
+
function median(values) {
|
|
61
|
+
if (values.length === 0) return 0;
|
|
62
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
63
|
+
const mid = Math.floor(sorted.length / 2);
|
|
64
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Linear ramp: `value ≤ low` → 1, `value ≥ high` → 0, linear between.
|
|
68
|
+
* Used by sub-scores whose underlying physical quantity grows as
|
|
69
|
+
* quality drops (e.g. residuals, heading delta).
|
|
70
|
+
*/
|
|
71
|
+
function rampDown(value, low, high) {
|
|
72
|
+
if (!Number.isFinite(value)) return 0;
|
|
73
|
+
if (high <= low) return value <= low ? 1 : 0;
|
|
74
|
+
if (value <= low) return 1;
|
|
75
|
+
if (value >= high) return 0;
|
|
76
|
+
return 1 - (value - low) / (high - low);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Linear ramp: `value ≤ low` → 0, `value ≥ high` → 1, linear between.
|
|
80
|
+
* Used by sub-scores whose underlying quantity grows as quality
|
|
81
|
+
* improves (e.g. walked distance, direction spread).
|
|
82
|
+
*/
|
|
83
|
+
function rampUp(value, low, high) {
|
|
84
|
+
if (!Number.isFinite(value)) return 0;
|
|
85
|
+
if (high <= low) return value >= high ? 1 : 0;
|
|
86
|
+
if (value <= low) return 0;
|
|
87
|
+
if (value >= high) return 1;
|
|
88
|
+
return (value - low) / (high - low);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* §4.1 matrix-history convergence over the buffered snapshots.
|
|
92
|
+
*
|
|
93
|
+
* Reuses the same kernel idea as `computeStabilityDelta` from the
|
|
94
|
+
* Investigation helpers (rotation + translation delta between two 4×4
|
|
95
|
+
* matrices). With 0 or 1 snapshot we return score 0 — convergence is
|
|
96
|
+
* undefined until at least one consecutive pair exists.
|
|
97
|
+
*
|
|
98
|
+
* Finding 6 (2026-05-23 field test): the per-pair `max` aggregation was
|
|
99
|
+
* replaced with a **sum** across the window. `max` is a burst detector
|
|
100
|
+
* — dominated by one bad pair, blind to slow drift; `sum` answers
|
|
101
|
+
* "how much alignment motion accumulated over the window?" and exposes
|
|
102
|
+
* slow creeping drift in user-readable units (°, m). The HUD now shows
|
|
103
|
+
* the raw sums (`ΣΔrot:`, `ΣΔpos:`) alongside the EMA-smoothed `Conv:`
|
|
104
|
+
* sub-score (Finding 4) so the user can debug an unstable reading.
|
|
105
|
+
*/
|
|
106
|
+
function computeConvergence(snapshots, options = {}) {
|
|
107
|
+
const rotWarn = options.rotationWarnDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.convergenceRotationWarnDeg;
|
|
108
|
+
const transWarn = options.translationWarnM ?? DEFAULT_TRACKING_QUALITY_OPTIONS.convergenceTranslationWarnM;
|
|
109
|
+
const rotFail = rotWarn * 4;
|
|
110
|
+
const transFail = transWarn * 4;
|
|
111
|
+
if (snapshots.length < 2) return {
|
|
112
|
+
score: 0,
|
|
113
|
+
recentSumRotationDeltaDeg: 0,
|
|
114
|
+
recentSumTranslationDeltaM: 0,
|
|
115
|
+
pairCount: 0
|
|
116
|
+
};
|
|
117
|
+
let sumRotDeg = 0;
|
|
118
|
+
let sumTransM = 0;
|
|
119
|
+
let pairCount = 0;
|
|
120
|
+
for (let i = 1; i < snapshots.length; i++) {
|
|
121
|
+
const { rotationDeltaDeg, translationDeltaM } = matrixDelta(snapshots[i - 1].matrix, snapshots[i].matrix);
|
|
122
|
+
sumRotDeg += Math.abs(rotationDeltaDeg);
|
|
123
|
+
sumTransM += Math.abs(translationDeltaM);
|
|
124
|
+
pairCount += 1;
|
|
125
|
+
}
|
|
126
|
+
const rotScore = rampDown(sumRotDeg, rotWarn, rotFail);
|
|
127
|
+
const transScore = rampDown(sumTransM, transWarn, transFail);
|
|
128
|
+
return {
|
|
129
|
+
score: Math.min(rotScore, transScore),
|
|
130
|
+
recentSumRotationDeltaDeg: sumRotDeg,
|
|
131
|
+
recentSumTranslationDeltaM: sumTransM,
|
|
132
|
+
pairCount
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Rotation and translation delta between two 4×4 alignment matrices in
|
|
137
|
+
* the column-major layout that gl-matrix and the framework use.
|
|
138
|
+
*
|
|
139
|
+
* Rotation: extract the orientation quaternion with `mat4.getRotation`
|
|
140
|
+
* and compute the relative angle via `quat.getAngle` (degrees).
|
|
141
|
+
* Translation: extract the origin column with `mat4.getTranslation` and
|
|
142
|
+
* compute the Euclidean distance.
|
|
143
|
+
*
|
|
144
|
+
* This is the **single shared kernel** for the AppFramework reporter and
|
|
145
|
+
* the Investigation harness (see §11 (a) of the tracking-quality plan).
|
|
146
|
+
* `GpsPlusSlamJs_Investigation/src/investigation-helpers.ts` re-exports
|
|
147
|
+
* `computeStabilityDelta` as a thin wrapper around this function so the
|
|
148
|
+
* §6.1 corpus sweep and the runtime convergence score share one numeric
|
|
149
|
+
* definition.
|
|
150
|
+
*/
|
|
151
|
+
function matrixDelta(prev, curr) {
|
|
152
|
+
if (prev.length !== 16 || curr.length !== 16) return {
|
|
153
|
+
rotationDeltaDeg: 0,
|
|
154
|
+
translationDeltaM: 0
|
|
155
|
+
};
|
|
156
|
+
const prevMat = prev;
|
|
157
|
+
const currMat = curr;
|
|
158
|
+
const prevQuat = quat.create();
|
|
159
|
+
const currQuat = quat.create();
|
|
160
|
+
mat4.getRotation(prevQuat, prevMat);
|
|
161
|
+
mat4.getRotation(currQuat, currMat);
|
|
162
|
+
quat.normalize(prevQuat, prevQuat);
|
|
163
|
+
quat.normalize(currQuat, currQuat);
|
|
164
|
+
const angleRad = quat.getAngle(prevQuat, currQuat);
|
|
165
|
+
const rotationDeltaDeg = Number.isNaN(angleRad) ? 0 : angleRad * 180 / Math.PI;
|
|
166
|
+
const prevT = vec3.create();
|
|
167
|
+
const currT = vec3.create();
|
|
168
|
+
mat4.getTranslation(prevT, prevMat);
|
|
169
|
+
mat4.getTranslation(currT, currMat);
|
|
170
|
+
return {
|
|
171
|
+
rotationDeltaDeg,
|
|
172
|
+
translationDeltaM: vec3.distance(prevT, currT)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* §4.2 — Median residual between the last `K` GPS observations and the
|
|
177
|
+
* positions you would have predicted by transforming the matching
|
|
178
|
+
* odometry positions through the current alignment matrix.
|
|
179
|
+
*
|
|
180
|
+
* Returns `score = 0`, `medianResidualM = 0` when we have fewer than
|
|
181
|
+
* 2 paired observations or when the alignment matrix / zero ref is
|
|
182
|
+
* not yet set.
|
|
183
|
+
*/
|
|
184
|
+
function computeResidualConsensus(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, options = {
|
|
185
|
+
residualWindowSize: DEFAULT_TRACKING_QUALITY_OPTIONS.residualWindowSize,
|
|
186
|
+
residualConfidenceTargetM: DEFAULT_TRACKING_QUALITY_OPTIONS.residualConfidenceTargetM,
|
|
187
|
+
gpsAccuracyFloorM: DEFAULT_TRACKING_QUALITY_OPTIONS.gpsAccuracyFloorM
|
|
188
|
+
}) {
|
|
189
|
+
if (!alignmentMatrix || !zeroRef) return {
|
|
190
|
+
score: 0,
|
|
191
|
+
medianResidualM: 0,
|
|
192
|
+
count: 0
|
|
193
|
+
};
|
|
194
|
+
const n = Math.min(gpsPositions.length, odometryPositions.length);
|
|
195
|
+
if (n < 2) return {
|
|
196
|
+
score: 0,
|
|
197
|
+
medianResidualM: 0,
|
|
198
|
+
count: 0
|
|
199
|
+
};
|
|
200
|
+
const start = Math.max(0, n - options.residualWindowSize);
|
|
201
|
+
const glMat = alignmentMatrix;
|
|
202
|
+
const tmp = vec3.create();
|
|
203
|
+
const normalised = [];
|
|
204
|
+
let rawResidualSum = 0;
|
|
205
|
+
let rawResidualCount = 0;
|
|
206
|
+
for (let i = start; i < n; i++) {
|
|
207
|
+
const odom = odometryPositions[i];
|
|
208
|
+
const gps = gpsPositions[i];
|
|
209
|
+
vec3.set(tmp, odom[0], odom[1], odom[2]);
|
|
210
|
+
vec3.transformMat4(tmp, tmp, glMat);
|
|
211
|
+
const meters = distanceInMeters(calcGpsCoords(zeroRef, tmp), {
|
|
212
|
+
lat: gps.latitude,
|
|
213
|
+
lon: gps.longitude
|
|
214
|
+
});
|
|
215
|
+
if (!Number.isFinite(meters)) continue;
|
|
216
|
+
rawResidualSum += meters;
|
|
217
|
+
rawResidualCount += 1;
|
|
218
|
+
const acc = Math.max(options.gpsAccuracyFloorM, gps.latLongAccuracy ?? options.gpsAccuracyFloorM);
|
|
219
|
+
normalised.push(meters / acc);
|
|
220
|
+
}
|
|
221
|
+
if (normalised.length < 2) return {
|
|
222
|
+
score: 0,
|
|
223
|
+
medianResidualM: rawResidualCount > 0 ? rawResidualSum / rawResidualCount : 0,
|
|
224
|
+
count: normalised.length
|
|
225
|
+
};
|
|
226
|
+
const medianNorm = median(normalised);
|
|
227
|
+
const medianResidualM = rawResidualSum / rawResidualCount;
|
|
228
|
+
return {
|
|
229
|
+
score: clamp01(1 / (1 + medianNorm / options.residualConfidenceTargetM)),
|
|
230
|
+
medianResidualM,
|
|
231
|
+
count: normalised.length
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* §4.4 — Median `latLongAccuracy` of the last `K` GPS points, with
|
|
236
|
+
* an "accurate-fix count" diagnostic. Cheap, always available.
|
|
237
|
+
*
|
|
238
|
+
* `score` ramps from 1.0 at median ≤ 3 m to 0.0 at median ≥ 25 m
|
|
239
|
+
* (linear), matching the plan's "≤ 5 m is good" heuristic with a
|
|
240
|
+
* generous fail tail.
|
|
241
|
+
*/
|
|
242
|
+
function computeGpsAccuracy(gpsPositions, options = {}) {
|
|
243
|
+
const windowSize = options.windowSize ?? DEFAULT_TRACKING_QUALITY_OPTIONS.gpsAccuracyWindowSize;
|
|
244
|
+
const goodMedianM = options.goodMedianM ?? 3;
|
|
245
|
+
const badMedianM = options.badMedianM ?? 25;
|
|
246
|
+
if (gpsPositions.length === 0) return {
|
|
247
|
+
score: 0,
|
|
248
|
+
medianM: 0,
|
|
249
|
+
countAccurate: 0,
|
|
250
|
+
countTotal: 0
|
|
251
|
+
};
|
|
252
|
+
const start = Math.max(0, gpsPositions.length - windowSize);
|
|
253
|
+
const accs = [];
|
|
254
|
+
let countAccurate = 0;
|
|
255
|
+
for (let i = start; i < gpsPositions.length; i++) {
|
|
256
|
+
const acc = gpsPositions[i].latLongAccuracy;
|
|
257
|
+
if (typeof acc === "number" && Number.isFinite(acc)) {
|
|
258
|
+
accs.push(acc);
|
|
259
|
+
if (acc <= 5) countAccurate += 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (accs.length === 0) return {
|
|
263
|
+
score: 0,
|
|
264
|
+
medianM: 0,
|
|
265
|
+
countAccurate: 0,
|
|
266
|
+
countTotal: gpsPositions.length - start
|
|
267
|
+
};
|
|
268
|
+
const medianM = median(accs);
|
|
269
|
+
return {
|
|
270
|
+
score: rampDown(medianM, goodMedianM, badMedianM),
|
|
271
|
+
medianM,
|
|
272
|
+
countAccurate,
|
|
273
|
+
countTotal: accs.length
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* §4.5 — Coverage = how far the user has walked AND how diverse the
|
|
278
|
+
* walking direction has been. Both required: a long but unidirectional
|
|
279
|
+
* walk leaves the rotation about the walking axis under-determined.
|
|
280
|
+
*/
|
|
281
|
+
function computeCoverage(odometryPositions, options = {}) {
|
|
282
|
+
const wThr = options.walkedDistanceM ?? DEFAULT_TRACKING_QUALITY_OPTIONS.coverageWalkedDistanceM;
|
|
283
|
+
const dThr = options.directionSpreadDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.coverageDirectionSpreadDeg;
|
|
284
|
+
if (odometryPositions.length < 2) return {
|
|
285
|
+
score: 0,
|
|
286
|
+
walkedDistanceM: 0,
|
|
287
|
+
directionSpreadDeg: 0
|
|
288
|
+
};
|
|
289
|
+
let walked = 0;
|
|
290
|
+
const bearings = [];
|
|
291
|
+
for (let i = 1; i < odometryPositions.length; i++) {
|
|
292
|
+
const a = odometryPositions[i - 1];
|
|
293
|
+
const b = odometryPositions[i];
|
|
294
|
+
const dN = b[0] - a[0];
|
|
295
|
+
const dE = b[2] - a[2];
|
|
296
|
+
const segLen = Math.hypot(dN, dE);
|
|
297
|
+
if (!Number.isFinite(segLen) || segLen < 1e-4) continue;
|
|
298
|
+
walked += segLen;
|
|
299
|
+
bearings.push(bearingDeg(dN, dE));
|
|
300
|
+
}
|
|
301
|
+
let spread = 0;
|
|
302
|
+
if (bearings.length >= 2) {
|
|
303
|
+
const sorted = [...bearings].sort((a, b) => a - b);
|
|
304
|
+
let maxGap = 0;
|
|
305
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
306
|
+
const gap = sorted[i] - sorted[i - 1];
|
|
307
|
+
if (gap > maxGap) maxGap = gap;
|
|
308
|
+
}
|
|
309
|
+
const wrapGap = 360 - sorted[sorted.length - 1] + sorted[0];
|
|
310
|
+
if (wrapGap > maxGap) maxGap = wrapGap;
|
|
311
|
+
spread = Math.max(0, 360 - maxGap);
|
|
312
|
+
}
|
|
313
|
+
const walkScore = rampUp(walked, wThr * .25, wThr);
|
|
314
|
+
const spreadScore = rampUp(spread, dThr * .25, dThr);
|
|
315
|
+
return {
|
|
316
|
+
score: Math.min(walkScore, spreadScore),
|
|
317
|
+
walkedDistanceM: walked,
|
|
318
|
+
directionSpreadDeg: spread
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* §4.3 — Compare the alignment matrix's mapping of AR-forward to ENU
|
|
323
|
+
* against the device compass heading. Returns `score = null` whenever
|
|
324
|
+
* the sensor's `absolute !== true` so the aggregate excludes the
|
|
325
|
+
* sub-score (per Q2 in the plan).
|
|
326
|
+
*
|
|
327
|
+
* AR-forward in our `ARPose` convention is `(0, 0, -1)` (right-handed,
|
|
328
|
+
* Y-up). We rotate that by the alignment's 3×3 rotation, take its
|
|
329
|
+
* North/East components, and compare against the compass alpha.
|
|
330
|
+
*/
|
|
331
|
+
function computeCompassAgreement(alignmentMatrix, sensorOrientation, arPose, options = {}) {
|
|
332
|
+
const warn = options.warnDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.compassWarnDeg;
|
|
333
|
+
const fail = options.failDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.compassFailDeg;
|
|
334
|
+
if (!alignmentMatrix || !sensorOrientation || !arPose) return {
|
|
335
|
+
score: null,
|
|
336
|
+
headingDeltaDeg: null
|
|
337
|
+
};
|
|
338
|
+
if (sensorOrientation.absolute !== true) return {
|
|
339
|
+
score: null,
|
|
340
|
+
headingDeltaDeg: null
|
|
341
|
+
};
|
|
342
|
+
const q = arPose.orientation;
|
|
343
|
+
const fx = 0;
|
|
344
|
+
const fy = 0;
|
|
345
|
+
const fz = -1;
|
|
346
|
+
const x = q.x, y = q.y, z = q.z, w = q.w;
|
|
347
|
+
const tx = 2 * (y * fz - z * fy);
|
|
348
|
+
const ty = 2 * (z * fx - x * fz);
|
|
349
|
+
const tz = 2 * (x * fy - y * fx);
|
|
350
|
+
const arForwardLocal = [
|
|
351
|
+
fx + w * tx + (y * tz - z * ty),
|
|
352
|
+
fy + w * ty + (z * tx - x * tz),
|
|
353
|
+
fz + w * tz + (x * ty - y * tx)
|
|
354
|
+
];
|
|
355
|
+
const m = alignmentMatrix;
|
|
356
|
+
const enuN = m[0] * arForwardLocal[0] + m[4] * arForwardLocal[1] + m[8] * arForwardLocal[2];
|
|
357
|
+
m[1] * arForwardLocal[0] + m[5] * arForwardLocal[1] + m[9] * arForwardLocal[2];
|
|
358
|
+
const enuE = m[2] * arForwardLocal[0] + m[6] * arForwardLocal[1] + m[10] * arForwardLocal[2];
|
|
359
|
+
if (Math.hypot(enuN, enuE) < 1e-6) return {
|
|
360
|
+
score: null,
|
|
361
|
+
headingDeltaDeg: null
|
|
362
|
+
};
|
|
363
|
+
const alignmentHeadingDeg = bearingDeg(enuN, enuE);
|
|
364
|
+
const compassHeadingDeg = sensorOrientation.alpha;
|
|
365
|
+
const delta = Math.abs(wrapToHalfCircle(alignmentHeadingDeg - compassHeadingDeg));
|
|
366
|
+
return {
|
|
367
|
+
score: rampDown(delta, warn, fail),
|
|
368
|
+
headingDeltaDeg: delta
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* §4.6 — Maximum lateral distance between raw GPS positions and the
|
|
373
|
+
* positions you would predict by transforming the matching odometry
|
|
374
|
+
* positions through the current alignment. Diagnostic only — never
|
|
375
|
+
* feeds the aggregate confidence per the plan.
|
|
376
|
+
*/
|
|
377
|
+
function computeGpsVsFusedDivergence(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, windowSize = 16) {
|
|
378
|
+
if (!alignmentMatrix || !zeroRef) return 0;
|
|
379
|
+
const n = Math.min(gpsPositions.length, odometryPositions.length);
|
|
380
|
+
if (n < 2) return 0;
|
|
381
|
+
const start = Math.max(0, n - windowSize);
|
|
382
|
+
const glMat = alignmentMatrix;
|
|
383
|
+
const tmp = vec3.create();
|
|
384
|
+
let maxDiv = 0;
|
|
385
|
+
for (let i = start; i < n; i++) {
|
|
386
|
+
const odom = odometryPositions[i];
|
|
387
|
+
vec3.set(tmp, odom[0], odom[1], odom[2]);
|
|
388
|
+
vec3.transformMat4(tmp, tmp, glMat);
|
|
389
|
+
const d = distanceInMeters(calcGpsCoords(zeroRef, tmp), {
|
|
390
|
+
lat: gpsPositions[i].latitude,
|
|
391
|
+
lon: gpsPositions[i].longitude
|
|
392
|
+
});
|
|
393
|
+
if (Number.isFinite(d) && d > maxDiv) maxDiv = d;
|
|
394
|
+
}
|
|
395
|
+
return maxDiv;
|
|
396
|
+
}
|
|
397
|
+
const trackingQualitySlice = createSlice({
|
|
398
|
+
name: "trackingQuality",
|
|
399
|
+
initialState,
|
|
400
|
+
reducers: {
|
|
401
|
+
/**
|
|
402
|
+
* Push a new alignment snapshot. Idempotent: callers must only
|
|
403
|
+
* dispatch when the matrix has actually changed (the listener
|
|
404
|
+
* middleware enforces this).
|
|
405
|
+
*/
|
|
406
|
+
snapshotPushed(state, action) {
|
|
407
|
+
state.recentAlignments.push(action.payload);
|
|
408
|
+
},
|
|
409
|
+
/**
|
|
410
|
+
* Drop oldest snapshots until `length ≤ size`. Separate action so
|
|
411
|
+
* the buffer cap can be applied after `snapshotPushed` without
|
|
412
|
+
* the reducer needing to know the option value.
|
|
413
|
+
*/
|
|
414
|
+
snapshotsTrimmed(state, action) {
|
|
415
|
+
const size = Math.max(1, action.payload.size | 0);
|
|
416
|
+
while (state.recentAlignments.length > size) state.recentAlignments.shift();
|
|
417
|
+
},
|
|
418
|
+
reportUpdated(state, action) {
|
|
419
|
+
state.report = action.payload;
|
|
420
|
+
},
|
|
421
|
+
firstAgreementReached(state, action) {
|
|
422
|
+
state.firstAgreementObservationIndex = action.payload;
|
|
423
|
+
},
|
|
424
|
+
/** §4.8 — update the hysteresis counter for ok → degraded holdoff. */
|
|
425
|
+
degradedCountUpdated(state, action) {
|
|
426
|
+
state.degradedConsecutiveCount = action.payload;
|
|
427
|
+
},
|
|
428
|
+
/**
|
|
429
|
+
* §4.8b (Finding 4) — persist the latest EMA-smoothed convergence so
|
|
430
|
+
* the next aggregator pass can blend against it. `null` resets the
|
|
431
|
+
* filter (next pass seeds from raw).
|
|
432
|
+
*/
|
|
433
|
+
smoothedConvergenceUpdated(state, action) {
|
|
434
|
+
state.smoothedConvergence = action.payload;
|
|
435
|
+
},
|
|
436
|
+
/** Full reset — used on new session start / tracking reset. */
|
|
437
|
+
resetTrackingQuality() {
|
|
438
|
+
return initialState;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
const { snapshotPushed, snapshotsTrimmed, reportUpdated, firstAgreementReached, degradedCountUpdated, resetTrackingQuality } = trackingQualitySlice.actions;
|
|
443
|
+
const { smoothedConvergenceUpdated } = trackingQualitySlice.actions;
|
|
444
|
+
const trackingQualityReducer = trackingQualitySlice.reducer;
|
|
445
|
+
function selectTrackingQuality(state) {
|
|
446
|
+
return state.trackingQuality?.report ?? null;
|
|
447
|
+
}
|
|
448
|
+
function selectRecentAlignments(state) {
|
|
449
|
+
return state.trackingQuality?.recentAlignments ?? EMPTY_SNAPSHOTS;
|
|
450
|
+
}
|
|
451
|
+
function selectFirstAgreementObservationIndex(state) {
|
|
452
|
+
return state.trackingQuality?.firstAgreementObservationIndex ?? null;
|
|
453
|
+
}
|
|
454
|
+
const EMPTY_SNAPSHOTS = Object.freeze([]);
|
|
455
|
+
/**
|
|
456
|
+
* Pure function — produces a {@link TrackingQualityReport} from a root
|
|
457
|
+
* state and options. The Investigation harness imports this directly to
|
|
458
|
+
* sweep `(N, K)` without going through the Redux listener middleware.
|
|
459
|
+
*
|
|
460
|
+
* The report's `state` enum follows §5 of the plan:
|
|
461
|
+
* - `ar-lost` whenever `tracking.phase !== 'tracking'`
|
|
462
|
+
* - `warming-up` until `coverage ≥ warmupMinCoverage` AND
|
|
463
|
+
* `observationsSeen ≥ warmupMinObservations`
|
|
464
|
+
* - then `ok` / `degraded` from `min(subScores)` vs.
|
|
465
|
+
* `degradedThreshold`.
|
|
466
|
+
*/
|
|
467
|
+
function computeTrackingQualityReport(rootState, options = {}) {
|
|
468
|
+
const opts = {
|
|
469
|
+
...DEFAULT_TRACKING_QUALITY_OPTIONS,
|
|
470
|
+
...options
|
|
471
|
+
};
|
|
472
|
+
const alignmentMatrix = selectAlignmentMatrix(rootState);
|
|
473
|
+
const gpsPositions = selectGpsPositions(rootState);
|
|
474
|
+
const odometryPositions = selectOdometryPositions(rootState);
|
|
475
|
+
const zeroRef = selectZeroReference(rootState);
|
|
476
|
+
const trackingPhase = selectTrackingPhase(rootState);
|
|
477
|
+
const sensorOrientation = selectLastSensorOrientation(rootState);
|
|
478
|
+
const lastPose = selectLastValidPose(rootState);
|
|
479
|
+
const snapshots = selectRecentAlignments(rootState);
|
|
480
|
+
const firstAgreementIdx = selectFirstAgreementObservationIndex(rootState);
|
|
481
|
+
const coverage = computeCoverage(odometryPositions, {
|
|
482
|
+
walkedDistanceM: opts.coverageWalkedDistanceM,
|
|
483
|
+
directionSpreadDeg: opts.coverageDirectionSpreadDeg
|
|
484
|
+
});
|
|
485
|
+
const gpsAccuracy = computeGpsAccuracy(gpsPositions, { windowSize: opts.gpsAccuracyWindowSize });
|
|
486
|
+
const residual = computeResidualConsensus(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, {
|
|
487
|
+
residualWindowSize: opts.residualWindowSize,
|
|
488
|
+
residualConfidenceTargetM: opts.residualConfidenceTargetM,
|
|
489
|
+
gpsAccuracyFloorM: opts.gpsAccuracyFloorM
|
|
490
|
+
});
|
|
491
|
+
const convergence = computeConvergence(snapshots, {
|
|
492
|
+
rotationWarnDeg: opts.convergenceRotationWarnDeg,
|
|
493
|
+
translationWarnM: opts.convergenceTranslationWarnM
|
|
494
|
+
});
|
|
495
|
+
const compass = computeCompassAgreement(alignmentMatrix, sensorOrientation, lastPose, {
|
|
496
|
+
warnDeg: opts.compassWarnDeg,
|
|
497
|
+
failDeg: opts.compassFailDeg
|
|
498
|
+
});
|
|
499
|
+
const gpsVsFusedMaxDivergenceM = computeGpsVsFusedDivergence(alignmentMatrix, gpsPositions, odometryPositions, zeroRef);
|
|
500
|
+
const subScores = {
|
|
501
|
+
convergence: clamp01(emaBlend(rootState.trackingQuality?.smoothedConvergence ?? null, convergence.score, opts.convergenceEmaAlpha)),
|
|
502
|
+
residualConsensus: clamp01(residual.score),
|
|
503
|
+
compassAgreement: compass.score === null ? null : clamp01(compass.score),
|
|
504
|
+
gpsAccuracy: clamp01(gpsAccuracy.score),
|
|
505
|
+
coverage: clamp01(coverage.score)
|
|
506
|
+
};
|
|
507
|
+
const aggregateInputs = [
|
|
508
|
+
subScores.convergence,
|
|
509
|
+
subScores.residualConsensus,
|
|
510
|
+
subScores.gpsAccuracy,
|
|
511
|
+
subScores.coverage
|
|
512
|
+
];
|
|
513
|
+
if (subScores.compassAgreement !== null) aggregateInputs.push(subScores.compassAgreement);
|
|
514
|
+
const confidence = clamp01(Math.min(...aggregateInputs));
|
|
515
|
+
const observationsSeen = gpsPositions.length;
|
|
516
|
+
let state;
|
|
517
|
+
if (trackingPhase !== "tracking") state = "ar-lost";
|
|
518
|
+
else if (subScores.coverage < opts.warmupMinCoverage || observationsSeen < opts.warmupMinObservations) state = "warming-up";
|
|
519
|
+
else if (confidence < opts.degradedThreshold) state = "degraded";
|
|
520
|
+
else state = "ok";
|
|
521
|
+
return {
|
|
522
|
+
state,
|
|
523
|
+
confidence: state === "ar-lost" ? 0 : confidence,
|
|
524
|
+
subScores,
|
|
525
|
+
diagnostics: {
|
|
526
|
+
recentSumRotationDeltaDeg: convergence.recentSumRotationDeltaDeg,
|
|
527
|
+
recentSumTranslationDeltaM: convergence.recentSumTranslationDeltaM,
|
|
528
|
+
medianResidualM: residual.medianResidualM,
|
|
529
|
+
medianRecentGpsAccuracyM: gpsAccuracy.medianM,
|
|
530
|
+
walkedDistanceM: coverage.walkedDistanceM,
|
|
531
|
+
directionSpreadDeg: coverage.directionSpreadDeg,
|
|
532
|
+
headingDeltaDeg: compass.headingDeltaDeg,
|
|
533
|
+
compassDriftDetected: firstAgreementIdx !== null && compass.headingDeltaDeg !== null && compass.headingDeltaDeg > opts.compassWarnDeg,
|
|
534
|
+
observationsSeen,
|
|
535
|
+
gpsVsFusedMaxDivergenceM
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Per-field tolerances for the {@link reportsEqual} dispatch gate.
|
|
541
|
+
*
|
|
542
|
+
* Why this exists: `tracking/poseReceived` fires on *every* XR frame
|
|
543
|
+
* (30–60 fps). The §4.3 compass cross-check consumes the live AR pose
|
|
544
|
+
* and sensor heading, so `subScores.compassAgreement` and
|
|
545
|
+
* `diagnostics.headingDeltaDeg` (and, when compass is the minimum,
|
|
546
|
+
* `confidence`) jitter by imperceptible floating-point amounts on a
|
|
547
|
+
* held-still device. With a strict `!==` comparison every such frame
|
|
548
|
+
* produced a fresh `reportUpdated` dispatch → high Redux churn and a
|
|
549
|
+
* HUD re-render at frame rate. The tolerances below quantise those
|
|
550
|
+
* sub-perceptual changes so the gate only fires when the user-visible
|
|
551
|
+
* quality actually moved.
|
|
552
|
+
*
|
|
553
|
+
* The gate compares the freshly computed report against the *last
|
|
554
|
+
* dispatched* report (the stored `prev`), not against the previous
|
|
555
|
+
* frame, so slow real drift cannot accumulate indefinitely: once it
|
|
556
|
+
* crosses a tolerance a dispatch fires and re-baselines. Tolerances are
|
|
557
|
+
* chosen below the smallest user-meaningful step in the HUD:
|
|
558
|
+
* - scores/confidence live in 0..1 → 1e-3 (0.1 %).
|
|
559
|
+
* - angle diagnostics are shown in whole/tenths of a degree → 0.01°.
|
|
560
|
+
* - metre diagnostics are shown in centimetres at best → 1 mm.
|
|
561
|
+
*/
|
|
562
|
+
const REPORT_SCORE_EPSILON = .001;
|
|
563
|
+
const REPORT_ANGLE_EPSILON_DEG = .01;
|
|
564
|
+
const REPORT_METRE_EPSILON_M = .001;
|
|
565
|
+
/**
|
|
566
|
+
* Tolerant scalar comparison that treats `null` as a distinct value
|
|
567
|
+
* (both `null` ⇒ equal; exactly one `null` ⇒ different) and otherwise
|
|
568
|
+
* compares with an absolute epsilon. Non-finite values are compared with
|
|
569
|
+
* {@link Object.is}, so a genuine finite↔NaN/Infinity transition still
|
|
570
|
+
* fires a dispatch, but a *persistently* NaN diagnostic (the only
|
|
571
|
+
* unguarded path is a NaN compass `alpha` leaking into
|
|
572
|
+
* `diagnostics.headingDeltaDeg`) does NOT churn the gate every frame —
|
|
573
|
+
* `Object.is(NaN, NaN)` is `true` whereas `NaN === NaN` is `false`.
|
|
574
|
+
*/
|
|
575
|
+
function nearlyEqual(a, b, eps) {
|
|
576
|
+
if (a === null || b === null) return a === b;
|
|
577
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return Object.is(a, b);
|
|
578
|
+
return Math.abs(a - b) <= eps;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Deep-equal that only inspects the public shape of a
|
|
582
|
+
* {@link TrackingQualityReport}. Cheaper than a generic deep-equal
|
|
583
|
+
* because it knows the schema; gates `reportUpdated` dispatches so
|
|
584
|
+
* subscribers wake up exactly when the user-visible quality changes.
|
|
585
|
+
*
|
|
586
|
+
* Float fields use the per-field tolerances above
|
|
587
|
+
* ({@link REPORT_SCORE_EPSILON} et al.) so imperceptible per-frame
|
|
588
|
+
* compass jitter does not churn the store — see the constants' doc
|
|
589
|
+
* comment for the rationale.
|
|
590
|
+
*/
|
|
591
|
+
function reportsEqual(a, b) {
|
|
592
|
+
if (a === b) return true;
|
|
593
|
+
if (!a || !b) return false;
|
|
594
|
+
if (a.state !== b.state) return false;
|
|
595
|
+
if (!nearlyEqual(a.confidence, b.confidence, REPORT_SCORE_EPSILON)) return false;
|
|
596
|
+
const sa = a.subScores;
|
|
597
|
+
const sb = b.subScores;
|
|
598
|
+
if (!nearlyEqual(sa.convergence, sb.convergence, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.residualConsensus, sb.residualConsensus, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.compassAgreement, sb.compassAgreement, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.gpsAccuracy, sb.gpsAccuracy, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.coverage, sb.coverage, REPORT_SCORE_EPSILON)) return false;
|
|
599
|
+
const da = a.diagnostics;
|
|
600
|
+
const db = b.diagnostics;
|
|
601
|
+
return da.compassDriftDetected === db.compassDriftDetected && da.observationsSeen === db.observationsSeen && nearlyEqual(da.recentSumRotationDeltaDeg, db.recentSumRotationDeltaDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.directionSpreadDeg, db.directionSpreadDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.headingDeltaDeg, db.headingDeltaDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.recentSumTranslationDeltaM, db.recentSumTranslationDeltaM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.medianResidualM, db.medianResidualM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.medianRecentGpsAccuracyM, db.medianRecentGpsAccuracyM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.walkedDistanceM, db.walkedDistanceM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.gpsVsFusedMaxDivergenceM, db.gpsVsFusedMaxDivergenceM, REPORT_METRE_EPSILON_M);
|
|
602
|
+
}
|
|
603
|
+
function matricesNearlyEqual(a, b) {
|
|
604
|
+
if (a.length !== b.length) return false;
|
|
605
|
+
for (let i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > 1e-9) return false;
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Action types the listener reacts to. Module-private: consumed only by the
|
|
610
|
+
* listener predicate and middleware below. The strings are the canonical
|
|
611
|
+
* RTK auto-generated action types.
|
|
612
|
+
*/
|
|
613
|
+
const TRACKING_QUALITY_INPUT_ACTIONS = {
|
|
614
|
+
gpsRecorded: "gpsData/recordGpsEvent",
|
|
615
|
+
setZeroPos: "gpsData/setZeroPos",
|
|
616
|
+
poseReceived: "tracking/poseReceived",
|
|
617
|
+
poseLost: "tracking/poseLost",
|
|
618
|
+
resetTracking: "tracking/resetTracking",
|
|
619
|
+
startSession: "recording/startSession"
|
|
620
|
+
};
|
|
621
|
+
function inputActionPredicate(action) {
|
|
622
|
+
switch (action.type) {
|
|
623
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.gpsRecorded:
|
|
624
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.setZeroPos:
|
|
625
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.poseReceived:
|
|
626
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.poseLost:
|
|
627
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.resetTracking:
|
|
628
|
+
case TRACKING_QUALITY_INPUT_ACTIONS.startSession: return true;
|
|
629
|
+
default: return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Build the listener middleware that drives the `trackingQuality`
|
|
634
|
+
* slice. Register the returned middleware in `createSlamAppStore`.
|
|
635
|
+
*
|
|
636
|
+
* Behaviour:
|
|
637
|
+
* - On `gpsData/recordGpsEvent` and `gpsData/setZeroPos`: if the
|
|
638
|
+
* alignment matrix changed, dispatch `snapshotPushed` +
|
|
639
|
+
* `snapshotsTrimmed`. Then recompute the report and dispatch
|
|
640
|
+
* `reportUpdated` if it changed.
|
|
641
|
+
* - On `recording/startSession` and `tracking/resetTracking`:
|
|
642
|
+
* dispatch `resetTrackingQuality` and recompute the report.
|
|
643
|
+
* - On `tracking/poseReceived` and `tracking/poseLost`: only
|
|
644
|
+
* recompute the report (no buffer change).
|
|
645
|
+
* - All dispatches are idempotency-gated so quiet motion does not
|
|
646
|
+
* churn the store.
|
|
647
|
+
*/
|
|
648
|
+
function createTrackingQualityListenerMiddleware(options = {}) {
|
|
649
|
+
const opts = {
|
|
650
|
+
...DEFAULT_TRACKING_QUALITY_OPTIONS,
|
|
651
|
+
...options
|
|
652
|
+
};
|
|
653
|
+
let compassStreak = 0;
|
|
654
|
+
const listenerMiddleware = createListenerMiddleware();
|
|
655
|
+
listenerMiddleware.startListening({
|
|
656
|
+
predicate: (action) => inputActionPredicate(action),
|
|
657
|
+
effect: (action, api) => {
|
|
658
|
+
const state = api.getState();
|
|
659
|
+
if (action.type === TRACKING_QUALITY_INPUT_ACTIONS.startSession || action.type === TRACKING_QUALITY_INPUT_ACTIONS.resetTracking) {
|
|
660
|
+
compassStreak = 0;
|
|
661
|
+
const tq = state.trackingQuality;
|
|
662
|
+
if (tq && (tq.recentAlignments.length > 0 || tq.firstAgreementObservationIndex !== null || tq.report !== null || tq.degradedConsecutiveCount > 0)) api.dispatch(resetTrackingQuality());
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (action.type === TRACKING_QUALITY_INPUT_ACTIONS.gpsRecorded || action.type === TRACKING_QUALITY_INPUT_ACTIONS.setZeroPos) {
|
|
666
|
+
const alignment = selectAlignmentMatrix(state);
|
|
667
|
+
if (alignment) {
|
|
668
|
+
const buf = state.trackingQuality?.recentAlignments ?? [];
|
|
669
|
+
const newMatrix = alignment;
|
|
670
|
+
const obsIndex = selectGpsPositions(state).length;
|
|
671
|
+
const last = buf.length > 0 ? buf[buf.length - 1] : null;
|
|
672
|
+
if (!last || !matricesNearlyEqual(last.matrix, newMatrix)) {
|
|
673
|
+
api.dispatch(snapshotPushed({
|
|
674
|
+
observationIndex: obsIndex,
|
|
675
|
+
matrix: [...newMatrix]
|
|
676
|
+
}));
|
|
677
|
+
api.dispatch(snapshotsTrimmed({ size: opts.matrixHistorySize }));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const midState = api.getState();
|
|
682
|
+
const midTq = midState.trackingQuality;
|
|
683
|
+
if (midTq && midTq.firstAgreementObservationIndex === null) {
|
|
684
|
+
const snapshots = selectRecentAlignments(midState);
|
|
685
|
+
if (snapshots.length >= 2) {
|
|
686
|
+
const conv = computeConvergence(snapshots, {
|
|
687
|
+
rotationWarnDeg: opts.convergenceRotationWarnDeg,
|
|
688
|
+
translationWarnM: opts.convergenceTranslationWarnM
|
|
689
|
+
});
|
|
690
|
+
const sensorOr = selectLastSensorOrientation(midState);
|
|
691
|
+
const pose = selectLastValidPose(midState);
|
|
692
|
+
const compass = computeCompassAgreement(selectAlignmentMatrix(midState), sensorOr, pose, {
|
|
693
|
+
warnDeg: opts.compassWarnDeg,
|
|
694
|
+
failDeg: opts.compassFailDeg
|
|
695
|
+
});
|
|
696
|
+
if (conv.score >= .7 && compass.headingDeltaDeg !== null && compass.headingDeltaDeg <= opts.compassWarnDeg) {
|
|
697
|
+
compassStreak += 1;
|
|
698
|
+
if (compassStreak >= opts.firstAgreementMinStreak) {
|
|
699
|
+
const obsCount = selectGpsPositions(midState).length;
|
|
700
|
+
api.dispatch(firstAgreementReached(obsCount));
|
|
701
|
+
}
|
|
702
|
+
} else compassStreak = 0;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const next = api.getState();
|
|
706
|
+
const report = computeTrackingQualityReport(next, opts);
|
|
707
|
+
const prev = next.trackingQuality?.report ?? null;
|
|
708
|
+
const tqNext = next.trackingQuality;
|
|
709
|
+
let dcc = tqNext?.degradedConsecutiveCount ?? 0;
|
|
710
|
+
if (report.state === "degraded") {
|
|
711
|
+
dcc += 1;
|
|
712
|
+
if (prev?.state === "ok" && dcc < opts.degradedHoldoff) report.state = "ok";
|
|
713
|
+
} else dcc = 0;
|
|
714
|
+
if (dcc !== (tqNext?.degradedConsecutiveCount ?? 0)) api.dispatch(degradedCountUpdated(dcc));
|
|
715
|
+
const newSmoothed = report.subScores.convergence;
|
|
716
|
+
if (newSmoothed !== (tqNext?.smoothedConvergence ?? null)) api.dispatch(smoothedConvergenceUpdated(newSmoothed));
|
|
717
|
+
if (!reportsEqual(prev, report)) api.dispatch(reportUpdated(report));
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
return listenerMiddleware.middleware;
|
|
721
|
+
}
|
|
722
|
+
//#endregion
|
|
723
|
+
export { DEFAULT_TRACKING_QUALITY_OPTIONS, computeCompassAgreement, computeConvergence, computeCoverage, computeGpsAccuracy, computeGpsVsFusedDivergence, computeResidualConsensus, computeTrackingQualityReport, createTrackingQualityListenerMiddleware, degradedCountUpdated, firstAgreementReached, matrixDelta, reportUpdated, resetTrackingQuality, selectFirstAgreementObservationIndex, selectRecentAlignments, selectTrackingQuality, snapshotPushed, snapshotsTrimmed, trackingQualityReducer };
|