Skip to content

Commit a967e1a

Browse files
Yerazeclaude
andcommitted
feat(position-estimation): max-accuracy cutoff + gate circles by Show Accuracy
Two follow-ups addressing oversized estimated-position uncertainty circles. 1. Maximum Acceptable Accuracy setting. New global setting position_estimation_max_uncertainty_km (0 = no limit). When set, the scheduler passes it to recomputeAll, which discards any solved estimate whose uncertainty radius exceeds the ceiling rather than storing it — and deletes that node's stale estimate so a now-too-uncertain node doesn't keep an oversized circle. Surfaced in the global Settings → Position Estimation section with a km input and reflected in the last-run summary (N discarded). The dominant "huge circle" case is the 5km single-anchor default (DEFAULT_SINGLE_ANCHOR_KM); a 2-3km cutoff drops those low-confidence guesses. 2. Gate the estimated-position uncertainty circles behind the existing "Show Accuracy" map toggle (showAccuracyRegions) instead of "Show Estimated Positions". Now one control governs every accuracy overlay (precision-bits rectangles + estimated-position circles); the estimated markers stay under "Show Estimated Positions". Both are required so a circle never renders without its marker. Tests: scheduler passes maxUncertaintyKm through (incl. 0=no-limit default); service discards over-threshold estimates + clears stale rows, keeps within- threshold, and treats 0 as unlimited. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e379f64 commit a967e1a

7 files changed

Lines changed: 154 additions & 12 deletions

src/components/NodesTab.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2172,8 +2172,12 @@ const NodesTabComponent: React.FC<NodesTabProps> = ({
21722172
);
21732173
})}
21742174

2175-
{/* Draw uncertainty circles for estimated positions */}
2176-
{showEstimatedPositions && nodesWithPosition
2175+
{/* Draw uncertainty circles for estimated positions. The "Show
2176+
Accuracy" map toggle now governs the radius (issue #3271
2177+
follow-up) — turning it off declutters the circles while the
2178+
estimated-node markers stay under "Show Estimated Positions".
2179+
Both are required so a circle never renders without its marker. */}
2180+
{showEstimatedPositions && showAccuracyRegions && nodesWithPosition
21772181
.filter(node => node.user?.id && nodesWithEstimatedPosition.has(node.user.id) && nodePassesTransportFilter(node, { showRfNodes, showUdpNodes, showMqttNodes }) && (showIncompleteNodes || isNodeComplete(node)) && (!tracerouteNodeNums || tracerouteNodeNums.has(node.nodeNum)))
21782182
.map(node => {
21792183
// Use the real multilateration uncertainty radius (issue #3271) when

src/components/PositionEstimationSection.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ interface EstimationStatus {
1414
enabled: boolean;
1515
frequencyHours: number;
1616
lookbackHours: number;
17+
maxUncertaintyKm: number;
1718
lastRunTime: number | null;
1819
lastRunResult: {
1920
estimatedNodeCount: number;
2021
observationCount: number;
2122
anchorCount: number;
23+
rejectedNodeCount?: number;
2224
durationMs: number;
2325
} | null;
2426
}
@@ -40,10 +42,12 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
4042
const [enabled, setEnabled] = useState(true);
4143
const [frequencyHours, setFrequencyHours] = useState(6);
4244
const [lookbackHours, setLookbackHours] = useState(168);
45+
const [maxUncertaintyKm, setMaxUncertaintyKm] = useState(0);
4346

4447
const [localEnabled, setLocalEnabled] = useState(true);
4548
const [localFrequencyHours, setLocalFrequencyHours] = useState(6);
4649
const [localLookbackHours, setLocalLookbackHours] = useState(168);
50+
const [localMaxUncertaintyKm, setLocalMaxUncertaintyKm] = useState(0);
4751

4852
const [status, setStatus] = useState<EstimationStatus | null>(null);
4953
const [isSaving, setIsSaving] = useState(false);
@@ -58,9 +62,11 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
5862
setEnabled(data.enabled);
5963
setFrequencyHours(data.frequencyHours);
6064
setLookbackHours(data.lookbackHours);
65+
setMaxUncertaintyKm(data.maxUncertaintyKm ?? 0);
6166
setLocalEnabled(data.enabled);
6267
setLocalFrequencyHours(data.frequencyHours);
6368
setLocalLookbackHours(data.lookbackHours);
69+
setLocalMaxUncertaintyKm(data.maxUncertaintyKm ?? 0);
6470
}
6571
} catch {
6672
// Status is non-critical; ignore fetch failures.
@@ -74,7 +80,8 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
7480
const hasChanges =
7581
localEnabled !== enabled ||
7682
localFrequencyHours !== frequencyHours ||
77-
localLookbackHours !== lookbackHours;
83+
localLookbackHours !== lookbackHours ||
84+
localMaxUncertaintyKm !== maxUncertaintyKm;
7885

7986
const handleSave = useCallback(async () => {
8087
setIsSaving(true);
@@ -86,12 +93,14 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
8693
position_estimation_enabled: String(localEnabled),
8794
position_estimation_frequency_hours: String(localFrequencyHours),
8895
position_estimation_lookback_hours: String(localLookbackHours),
96+
position_estimation_max_uncertainty_km: String(localMaxUncertaintyKm),
8997
}),
9098
});
9199
if (response.ok) {
92100
setEnabled(localEnabled);
93101
setFrequencyHours(localFrequencyHours);
94102
setLookbackHours(localLookbackHours);
103+
setMaxUncertaintyKm(localMaxUncertaintyKm);
95104
showToast(t('automation.settings_saved', 'Settings saved'), 'success');
96105
} else {
97106
showToast(t('automation.settings_save_failed', 'Failed to save'), 'error');
@@ -101,13 +110,14 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
101110
} finally {
102111
setIsSaving(false);
103112
}
104-
}, [localEnabled, localFrequencyHours, localLookbackHours, csrfFetch, baseUrl, showToast, t]);
113+
}, [localEnabled, localFrequencyHours, localLookbackHours, localMaxUncertaintyKm, csrfFetch, baseUrl, showToast, t]);
105114

106115
const resetChanges = useCallback(() => {
107116
setLocalEnabled(enabled);
108117
setLocalFrequencyHours(frequencyHours);
109118
setLocalLookbackHours(lookbackHours);
110-
}, [enabled, frequencyHours, lookbackHours]);
119+
setLocalMaxUncertaintyKm(maxUncertaintyKm);
120+
}, [enabled, frequencyHours, lookbackHours, maxUncertaintyKm]);
111121

112122
useSaveBar({
113123
id: 'position-estimation',
@@ -226,6 +236,26 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
226236
</select>
227237
</div>
228238

239+
<div className="setting-item" style={{ marginTop: '1rem' }}>
240+
<label>{t('automation.position_estimation.max_uncertainty', 'Maximum acceptable accuracy (km)')}</label>
241+
<input
242+
type="number"
243+
min={0}
244+
step={0.5}
245+
value={localMaxUncertaintyKm}
246+
onChange={(e) => {
247+
const v = parseFloat(e.target.value);
248+
setLocalMaxUncertaintyKm(Number.isFinite(v) && v > 0 ? v : 0);
249+
}}
250+
disabled={!localEnabled}
251+
className="setting-input"
252+
/>
253+
<p style={{ fontSize: '12px', color: 'var(--ctp-subtext0)', margin: '0.35rem 0 0 0' }}>
254+
{t('automation.position_estimation.max_uncertainty_help',
255+
'Estimates with an uncertainty radius larger than this are discarded rather than stored, so low-confidence guesses don’t draw huge circles on the map. Set 0 for no limit.')}
256+
</p>
257+
</div>
258+
229259
{status && (
230260
<div style={{ marginTop: '1.5rem', marginLeft: '1.75rem', fontSize: '13px', color: 'var(--ctp-subtext1)' }}>
231261
<div>
@@ -240,6 +270,15 @@ const PositionEstimationSection: React.FC<PositionEstimationSectionProps> = ({ b
240270
anchors: status.lastRunResult.anchorCount,
241271
defaultValue: `${status.lastRunResult.estimatedNodeCount} node(s) estimated from ${status.lastRunResult.observationCount} observation(s), ${status.lastRunResult.anchorCount} anchor(s)`,
242272
})}
273+
{status.lastRunResult.rejectedNodeCount ? (
274+
<>
275+
{' '}
276+
{t('automation.position_estimation.last_rejected', {
277+
count: status.lastRunResult.rejectedNodeCount,
278+
defaultValue: `(${status.lastRunResult.rejectedNodeCount} discarded over max accuracy)`,
279+
})}
280+
</>
281+
) : null}
243282
</div>
244283
)}
245284
</div>

src/server/constants/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export const VALID_SETTINGS_KEYS = [
100100
'position_estimation_enabled',
101101
'position_estimation_frequency_hours',
102102
'position_estimation_lookback_hours',
103+
// Max acceptable uncertainty (km). Estimates whose computed radius exceeds
104+
// this are discarded rather than stored (issue #3271 follow-up). 0 = no limit.
105+
'position_estimation_max_uncertainty_km',
103106
'autoKeyManagementEnabled',
104107
'autoKeyManagementIntervalMinutes',
105108
'autoKeyManagementMaxExchanges',

src/server/services/positionEstimationScheduler.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isRunDue,
1818
positionEstimationScheduler,
1919
DEFAULT_FREQUENCY_HOURS,
20+
DEFAULT_LOOKBACK_HOURS,
2021
} from './positionEstimationScheduler.js';
2122

2223
const HOUR = 60 * 60 * 1000;
@@ -50,14 +51,24 @@ describe('positionEstimationScheduler.runNow', () => {
5051
mockDb.settings.setSetting.mockResolvedValue(undefined);
5152
});
5253

53-
it('invokes recomputeAll with the configured lookback window', async () => {
54+
it('invokes recomputeAll with the configured lookback window and max uncertainty', async () => {
5455
mockDb.settings.getSetting.mockImplementation(async (key: string) => {
5556
if (key === 'position_estimation_lookback_hours') return '48';
57+
if (key === 'position_estimation_max_uncertainty_km') return '3';
5658
return null;
5759
});
5860
await positionEstimationScheduler.runNow();
5961
expect(mockService.positionEstimationService.recomputeAll).toHaveBeenCalledWith({
6062
lookbackMs: 48 * HOUR,
63+
maxUncertaintyKm: 3,
64+
});
65+
});
66+
67+
it('passes maxUncertaintyKm: 0 (no limit) when the setting is unset', async () => {
68+
await positionEstimationScheduler.runNow();
69+
expect(mockService.positionEstimationService.recomputeAll).toHaveBeenCalledWith({
70+
lookbackMs: DEFAULT_LOOKBACK_HOURS * HOUR,
71+
maxUncertaintyKm: 0,
6172
});
6273
});
6374

src/server/services/positionEstimationScheduler.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const LAST_RUN_KEY = 'position_estimation_last_run';
2222

2323
export const DEFAULT_FREQUENCY_HOURS = 6;
2424
export const DEFAULT_LOOKBACK_HOURS = 168; // 7 days
25+
// 0 = no limit (store every solvable estimate). Opt-in: the operator sets a
26+
// km ceiling to drop low-confidence estimates that draw huge map circles.
27+
export const DEFAULT_MAX_UNCERTAINTY_KM = 0;
2528
const MIN_FREQUENCY_HOURS = 0.5;
2629
const MIN_LOOKBACK_HOURS = 1;
2730
const CHECK_INTERVAL_MS = 60_000;
@@ -41,6 +44,7 @@ export interface EstimationStatus {
4144
enabled: boolean;
4245
frequencyHours: number;
4346
lookbackHours: number;
47+
maxUncertaintyKm: number;
4448
lastRunTime: number | null;
4549
lastRunResult: RecomputeResult | null;
4650
}
@@ -103,6 +107,15 @@ class PositionEstimationScheduler {
103107
return raw;
104108
}
105109

110+
private async getMaxUncertaintyKm(): Promise<number> {
111+
const raw = parseFloat(
112+
(await databaseService.settings.getSetting('position_estimation_max_uncertainty_km')) || String(DEFAULT_MAX_UNCERTAINTY_KM)
113+
);
114+
// Treat invalid/negative as "no limit" (0).
115+
if (!Number.isFinite(raw) || raw < 0) return DEFAULT_MAX_UNCERTAINTY_KM;
116+
return raw;
117+
}
118+
106119
private async getLastRun(): Promise<number | null> {
107120
if (this.lastRunTime !== null) return this.lastRunTime;
108121
const stored = await databaseService.settings.getSetting(LAST_RUN_KEY);
@@ -143,9 +156,13 @@ class PositionEstimationScheduler {
143156
private async execute(): Promise<RecomputeResult> {
144157
this.inProgress = true;
145158
try {
146-
const lookbackHours = await this.getLookbackHours();
159+
const [lookbackHours, maxUncertaintyKm] = await Promise.all([
160+
this.getLookbackHours(),
161+
this.getMaxUncertaintyKm(),
162+
]);
147163
const result = await positionEstimationService.recomputeAll({
148164
lookbackMs: lookbackHours * 60 * 60 * 1000,
165+
maxUncertaintyKm,
149166
});
150167
this.lastRunResult = result;
151168
return result;
@@ -161,10 +178,11 @@ class PositionEstimationScheduler {
161178
}
162179

163180
async getStatus(): Promise<EstimationStatus> {
164-
const [enabled, frequencyHours, lookbackHours, lastRun] = await Promise.all([
181+
const [enabled, frequencyHours, lookbackHours, maxUncertaintyKm, lastRun] = await Promise.all([
165182
this.getEnabled(),
166183
this.getFrequencyHours(),
167184
this.getLookbackHours(),
185+
this.getMaxUncertaintyKm(),
168186
this.getLastRun(),
169187
]);
170188
return {
@@ -173,6 +191,7 @@ class PositionEstimationScheduler {
173191
enabled,
174192
frequencyHours,
175193
lookbackHours,
194+
maxUncertaintyKm,
176195
lastRunTime: lastRun,
177196
lastRunResult: this.lastRunResult,
178197
};

src/server/services/positionEstimationService.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,51 @@ describe('positionEstimationService.recomputeAll', () => {
254254
expect(deletedNodeNums).toContain(100);
255255
expect(deletedNodeNums).toContain(200);
256256
});
257+
258+
describe('maxUncertaintyKm threshold (issue #3271 follow-up)', () => {
259+
// A single-anchor neighbor observation yields the DEFAULT_SINGLE_ANCHOR_KM
260+
// (5 km) uncertainty — the dominant "huge circle" case.
261+
const singleAnchorSetup = () => {
262+
mockDb.sources.getAllSources.mockResolvedValue([{ id: 's', type: 'meshtastic_tcp' }]);
263+
mockDb.nodes.getAllNodes.mockResolvedValue([{ nodeNum: 100, latitude: 10, longitude: 20 }]);
264+
mockDb.neighbors.getAllNeighborInfo.mockResolvedValue([
265+
{ nodeNum: 100, neighborNodeNum: 5, snr: 10, timestamp: NOW },
266+
]);
267+
};
268+
269+
it('discards an estimate whose uncertainty exceeds the maximum and clears any stale row', async () => {
270+
singleAnchorSetup();
271+
const result = await positionEstimationService.recomputeAll({
272+
lookbackMs: 7 * 24 * 60 * 60 * 1000,
273+
maxUncertaintyKm: 3, // 5km estimate > 3km ceiling → rejected
274+
});
275+
expect(result.estimatedNodeCount).toBe(0);
276+
expect(result.rejectedNodeCount).toBe(1);
277+
// Nothing stored for the over-threshold node...
278+
expect(mockDb.upsertEstimatedPositionsAsync.mock.calls[0][0]).toHaveLength(0);
279+
// ...and its existing estimate (if any) is cleared.
280+
expect(mockDb.deleteEstimatedPositionsByNodeNumsAsync.mock.calls[0][0]).toContain(5);
281+
});
282+
283+
it('keeps an estimate within the maximum', async () => {
284+
singleAnchorSetup();
285+
const result = await positionEstimationService.recomputeAll({
286+
lookbackMs: 7 * 24 * 60 * 60 * 1000,
287+
maxUncertaintyKm: 10, // 5km estimate ≤ 10km ceiling → kept
288+
});
289+
expect(result.estimatedNodeCount).toBe(1);
290+
expect(result.rejectedNodeCount).toBe(0);
291+
expect(mockDb.upsertEstimatedPositionsAsync.mock.calls[0][0]).toHaveLength(1);
292+
});
293+
294+
it('treats maxUncertaintyKm 0 as no limit', async () => {
295+
singleAnchorSetup();
296+
const result = await positionEstimationService.recomputeAll({
297+
lookbackMs: 7 * 24 * 60 * 60 * 1000,
298+
maxUncertaintyKm: 0,
299+
});
300+
expect(result.estimatedNodeCount).toBe(1);
301+
expect(result.rejectedNodeCount).toBe(0);
302+
});
303+
});
257304
});

src/server/services/positionEstimationService.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export interface RecomputeResult {
4646
estimatedNodeCount: number;
4747
observationCount: number;
4848
anchorCount: number;
49+
/** Solved positions discarded because their uncertainty exceeded the
50+
* configured maximum (issue #3271 follow-up). */
51+
rejectedNodeCount: number;
4952
durationMs: number;
5053
}
5154

@@ -279,10 +282,13 @@ class PositionEstimationService {
279282
* window. Pools every Meshtastic source (MeshCore excluded). Bulk-upserts
280283
* results and clears estimates for nodes that now have real positions.
281284
*/
282-
async recomputeAll(opts: { lookbackMs: number }): Promise<RecomputeResult> {
285+
async recomputeAll(opts: { lookbackMs: number; maxUncertaintyKm?: number | null }): Promise<RecomputeResult> {
283286
const start = Date.now();
284287
const now = start;
285288
const cutoff = now - opts.lookbackMs;
289+
// 0 / null / negative ⇒ no limit (store every solvable estimate).
290+
const maxUncertaintyKm =
291+
opts.maxUncertaintyKm != null && opts.maxUncertaintyKm > 0 ? opts.maxUncertaintyKm : null;
286292

287293
// Meshtastic-only sources (exclude MeshCore).
288294
const allSources = await databaseService.sources.getAllSources();
@@ -338,10 +344,18 @@ class PositionEstimationService {
338344

339345
let observationCount = 0;
340346
const inputs: EstimatedPositionInput[] = [];
347+
// Nodes solved but rejected for exceeding maxUncertaintyKm. Their existing
348+
// estimates (if any) are deleted below so a now-too-uncertain node doesn't
349+
// keep a stale, oversized circle on the map.
350+
const rejectedNodeNums: number[] = [];
341351
for (const [nodeNum, observations] of obsByNode) {
342352
observationCount += observations.length;
343353
const solved = solveNodePosition(observations, now);
344354
if (!solved) continue;
355+
if (maxUncertaintyKm != null && solved.uncertaintyKm > maxUncertaintyKm) {
356+
rejectedNodeNums.push(nodeNum);
357+
continue;
358+
}
345359
inputs.push({
346360
nodeNum,
347361
nodeId: nodeNumToId(nodeNum),
@@ -355,20 +369,25 @@ class PositionEstimationService {
355369

356370
await databaseService.upsertEstimatedPositionsAsync(inputs);
357371

358-
// A node that gained a real position should not also carry an estimate.
372+
// Clear estimates that are no longer valid: a node that gained a real
373+
// position (now an anchor), or one whose fresh estimate is too uncertain
374+
// to keep under the configured maximum.
359375
const anchorNodeNums = [...anchors.keys()];
360-
await databaseService.deleteEstimatedPositionsByNodeNumsAsync(anchorNodeNums);
376+
await databaseService.deleteEstimatedPositionsByNodeNumsAsync([...anchorNodeNums, ...rejectedNodeNums]);
361377

362378
const durationMs = Date.now() - start;
363379
logger.info(
364380
`📍 Position estimation: ${inputs.length} node(s) estimated from ${observationCount} observation(s) ` +
365-
`across ${meshtasticSourceIds.length} source(s), ${anchors.size} anchor(s), in ${durationMs}ms`
381+
`across ${meshtasticSourceIds.length} source(s), ${anchors.size} anchor(s)` +
382+
(rejectedNodeNums.length ? `, ${rejectedNodeNums.length} rejected (>${maxUncertaintyKm}km)` : '') +
383+
`, in ${durationMs}ms`
366384
);
367385

368386
return {
369387
estimatedNodeCount: inputs.length,
370388
observationCount,
371389
anchorCount: anchors.size,
390+
rejectedNodeCount: rejectedNodeNums.length,
372391
durationMs,
373392
};
374393
}

0 commit comments

Comments
 (0)