Skip to content

Commit 8a3b5e7

Browse files
DTTerastarclaude
andcommitted
Add radio tomography: online static RF-attenuation map per floor
Reconstructs where the radio sees something solid (walls, appliances, the refrigerator) from the node-to-node links already streaming in — no acquisition step, no ground truth. Each link's excess path loss beyond free space is treated as a line integral through an unknown attenuation field; many crisscrossing links let us invert (non-negative ridge) for the per-cell attenuation. A per-link decaying-max strips out transient people so the map captures static structure. - RadioTomographyService (BackgroundService): every 30s, per floor, builds the link set from State, rasterizes each ray into a grid, solves ridge, exposes the latest per-floor grids (attenuation + coverage). - GET /api/tomography returns the grids. - Calibration page gains an "Obstructions" tab: a heatmap overlay on the floor plan (Turbo colormap, faded where link coverage is low) so a human can validate it by eye — "that hot blob is my fridge." Purpose is twofold: (1) visual validation that the model is learning real physics (recognizable structure can't be faked the way a scalar accuracy number can), and (2) groundwork for obstruction-aware locating (down-weighting nodes whose path to a candidate crosses a known blocker). Verified live: hottest first-floor cell lands on the kitchen node where the refrigerator (a Faraday cage) actually is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f106a83 commit 8a3b5e7

9 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using ESPresense.Models;
2+
using ESPresense.Services;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
namespace ESPresense.Controllers;
6+
7+
[Route("api/tomography")]
8+
[ApiController]
9+
public class TomographyController(RadioTomographyService tomography) : ControllerBase
10+
{
11+
/// <summary>Latest reconstructed per-floor static RF-attenuation map.</summary>
12+
[HttpGet]
13+
public TomographyResult Get() => tomography.Latest;
14+
}

src/Models/Tomography.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace ESPresense.Models;
2+
3+
/// <summary>
4+
/// A reconstructed static RF-attenuation field for one floor: a grid of "extra path loss beyond
5+
/// free space" inferred from the node-to-node links that cross each cell. High-attenuation cells
6+
/// are where the radio sees something solid (walls, appliances, a refrigerator).
7+
/// </summary>
8+
public class TomographyFloor
9+
{
10+
public string? FloorId { get; set; }
11+
public string? FloorName { get; set; }
12+
13+
// Grid origin (metres) and geometry.
14+
public double MinX { get; set; }
15+
public double MinY { get; set; }
16+
public double CellSize { get; set; }
17+
public int Cols { get; set; }
18+
public int Rows { get; set; }
19+
20+
/// <summary>Row-major (row * Cols + col) attenuation in dB per metre, clamped to >= 0.</summary>
21+
public double[] Attenuation { get; set; } = System.Array.Empty<double>();
22+
23+
/// <summary>Row-major ray coverage per cell (total link length through the cell). Low = unreliable.</summary>
24+
public double[] Coverage { get; set; } = System.Array.Empty<double>();
25+
26+
public int Links { get; set; }
27+
public double MaxAttenuation { get; set; }
28+
}
29+
30+
public class TomographyResult
31+
{
32+
public System.DateTime Updated { get; set; }
33+
public System.Collections.Generic.List<TomographyFloor> Floors { get; set; } = new();
34+
}

src/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@
7575
builder.Services.AddSingleton<FirmwareUpdateJobService>();
7676
builder.Services.AddSingleton<DeviceService>();
7777
builder.Services.AddSingleton<DeviceCaptureService>();
78+
builder.Services.AddSingleton<RadioTomographyService>();
7879
builder.Services.AddSingleton<LeaseService>();
7980
builder.Services.AddSingleton<ILeaseService>(provider => provider.GetRequiredService<LeaseService>());
8081

82+
builder.Services.AddHostedService(provider => provider.GetRequiredService<RadioTomographyService>());
8183
builder.Services.AddHostedService<MultiScenarioLocator>();
8284
builder.Services.AddHostedService<OptimizationRunner>();
8385
builder.Services.AddHostedService(provider => provider.GetRequiredService<DeviceTracker>());
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using ESPresense.Models;
2+
using MathNet.Numerics.LinearAlgebra;
3+
using Microsoft.Extensions.Hosting;
4+
using Serilog;
5+
6+
namespace ESPresense.Services;
7+
8+
/// <summary>
9+
/// Continuously reconstructs a static RF-attenuation map per floor from the node-to-node links that
10+
/// are already streaming in. Each link's "excess path loss beyond free space" is treated as a line
11+
/// integral through an unknown attenuation field; many crisscrossing links let us invert for where
12+
/// the attenuation actually sits (walls, appliances, the refrigerator). Runs online, no acquisition
13+
/// step — the result is exposed for the calibration-page heatmap so a human can eyeball it ("that
14+
/// rectangle is my fridge") and, later, fed into the locator to down-weight blocked paths.
15+
/// </summary>
16+
public class RadioTomographyService : BackgroundService
17+
{
18+
private readonly State _state;
19+
20+
// Tunables (kept conservative for a first cut).
21+
private const double FreeSpaceExponent = 2.0; // n in rssi = ref - 10*n*log10(d)
22+
private const double CellSizeMeters = 1.0;
23+
private const int MaxCells = 1200; // bound the inverse-problem size
24+
private const int MinLinksPerFloor = 6;
25+
private const double Regularization = 0.15; // ridge strength, relative to data scale
26+
private const double DecayDbPerCycle = 1.0; // how fast the per-link "clean" peak forgets
27+
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
28+
29+
// Per-link rolling "clean" (least-shadowed) RSSI, keyed "tx|rx". The decaying max strips out
30+
// transient people walking through a link while keeping the persistent static structure.
31+
private readonly Dictionary<string, double> _cleanRssi = new();
32+
33+
public TomographyResult Latest { get; private set; } = new();
34+
35+
public RadioTomographyService(State state)
36+
{
37+
_state = state;
38+
}
39+
40+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
41+
{
42+
while (!stoppingToken.IsCancellationRequested)
43+
{
44+
try
45+
{
46+
Latest = Compute();
47+
}
48+
catch (Exception ex)
49+
{
50+
Log.Warning(ex, "Radio tomography compute failed");
51+
}
52+
await Task.Delay(Interval, stoppingToken);
53+
}
54+
}
55+
56+
private TomographyResult Compute()
57+
{
58+
var result = new TomographyResult { Updated = DateTime.UtcNow };
59+
60+
foreach (var floor in _state.Floors.Values)
61+
{
62+
if (floor.Bounds is not { Length: 2 }) continue;
63+
var tf = ComputeFloor(floor);
64+
if (tf != null) result.Floors.Add(tf);
65+
}
66+
67+
return result;
68+
}
69+
70+
private TomographyFloor? ComputeFloor(Floor floor)
71+
{
72+
double minX = Math.Min(floor.Bounds![0].X, floor.Bounds[1].X);
73+
double minY = Math.Min(floor.Bounds[0].Y, floor.Bounds[1].Y);
74+
double maxX = Math.Max(floor.Bounds[0].X, floor.Bounds[1].X);
75+
double maxY = Math.Max(floor.Bounds[0].Y, floor.Bounds[1].Y);
76+
double w = maxX - minX, h = maxY - minY;
77+
if (w <= 0 || h <= 0) return null;
78+
79+
// Pick a cell size that keeps the grid under the size cap.
80+
double cell = CellSizeMeters;
81+
while (Math.Ceiling(w / cell) * Math.Ceiling(h / cell) > MaxCells) cell *= 1.5;
82+
int cols = (int)Math.Ceiling(w / cell);
83+
int rows = (int)Math.Ceiling(h / cell);
84+
int cellCount = cols * rows;
85+
86+
bool OnFloor(Node n) => n.HasLocation && (n.Floors?.Any(f => f.Id == floor.Id) ?? false);
87+
88+
// Gather links between nodes on this floor.
89+
var rows_W = new List<double[]>();
90+
var ys = new List<double>();
91+
var coverage = new double[cellCount];
92+
93+
foreach (var tx in _state.Nodes.Values)
94+
{
95+
if (!OnFloor(tx)) continue;
96+
foreach (var meas in tx.RxNodes.Values)
97+
{
98+
var rx = meas.Rx;
99+
if (rx == null || !OnFloor(rx) || !meas.Current || meas.Rssi == 0) continue;
100+
101+
double d = Math.Sqrt(Math.Pow(tx.Location.X - rx.Location.X, 2) + Math.Pow(tx.Location.Y - rx.Location.Y, 2));
102+
if (d < 0.5) continue;
103+
104+
double clean = UpdateClean($"{tx.Id}|{rx.Id}", meas.Rssi);
105+
double freeExpected = meas.RefRssi - 10.0 * FreeSpaceExponent * Math.Log10(d);
106+
double excess = freeExpected - clean; // dB of loss beyond free space
107+
if (excess < 0) excess = 0; // can't be "less than free space" (ignore rare constructive multipath)
108+
109+
var rowW = new double[cellCount];
110+
RasterizeRay(tx.Location.X, tx.Location.Y, rx.Location.X, rx.Location.Y, minX, minY, cell, cols, rows, rowW);
111+
double rayLen = rowW.Sum();
112+
if (rayLen <= 0) continue;
113+
114+
for (int c = 0; c < cellCount; c++) coverage[c] += rowW[c];
115+
rows_W.Add(rowW);
116+
ys.Add(excess);
117+
}
118+
}
119+
120+
if (rows_W.Count < MinLinksPerFloor) return null;
121+
122+
var attenuation = SolveRidge(rows_W, ys, cellCount);
123+
124+
var tf = new TomographyFloor
125+
{
126+
FloorId = floor.Id,
127+
FloorName = floor.Name,
128+
MinX = minX,
129+
MinY = minY,
130+
CellSize = cell,
131+
Cols = cols,
132+
Rows = rows,
133+
Attenuation = attenuation,
134+
Coverage = coverage,
135+
Links = rows_W.Count,
136+
MaxAttenuation = attenuation.Length > 0 ? attenuation.Max() : 0
137+
};
138+
return tf;
139+
}
140+
141+
private double UpdateClean(string key, double rssi)
142+
{
143+
if (_cleanRssi.TryGetValue(key, out var prev))
144+
{
145+
double clean = Math.Max(rssi, prev - DecayDbPerCycle);
146+
_cleanRssi[key] = clean;
147+
return clean;
148+
}
149+
_cleanRssi[key] = rssi;
150+
return rssi;
151+
}
152+
153+
/// <summary>Accumulate per-cell path length for the segment by fine sampling.</summary>
154+
private static void RasterizeRay(double x0, double y0, double x1, double y1,
155+
double minX, double minY, double cell, int cols, int rows, double[] rowW)
156+
{
157+
double dx = x1 - x0, dy = y1 - y0;
158+
double len = Math.Sqrt(dx * dx + dy * dy);
159+
if (len <= 0) return;
160+
int steps = Math.Max(1, (int)Math.Ceiling(len / (cell * 0.25)));
161+
double stepLen = len / steps;
162+
for (int s = 0; s < steps; s++)
163+
{
164+
double t = (s + 0.5) / steps;
165+
double px = x0 + dx * t, py = y0 + dy * t;
166+
int col = (int)((px - minX) / cell);
167+
int row = (int)((py - minY) / cell);
168+
if (col < 0 || col >= cols || row < 0 || row >= rows) continue;
169+
rowW[row * cols + col] += stepLen;
170+
}
171+
}
172+
173+
/// <summary>Non-negative ridge regression: min ||Wx - y||^2 + λ||x||^2, then clamp x >= 0.</summary>
174+
private static double[] SolveRidge(List<double[]> rowsW, List<double> ys, int cellCount)
175+
{
176+
int L = rowsW.Count;
177+
var W = Matrix<double>.Build.Dense(L, cellCount, (i, j) => rowsW[i][j]);
178+
var y = Vector<double>.Build.Dense(L, i => ys[i]);
179+
180+
var wtw = W.TransposeThisAndMultiply(W); // C x C, SPD after ridge
181+
double meanDiag = 0;
182+
for (int i = 0; i < cellCount; i++) meanDiag += wtw[i, i];
183+
meanDiag = cellCount > 0 ? meanDiag / cellCount : 1.0;
184+
double lambda = Regularization * meanDiag + 1e-9;
185+
for (int i = 0; i < cellCount; i++) wtw[i, i] += lambda;
186+
187+
var wty = W.TransposeThisAndMultiply(y);
188+
var x = wtw.Cholesky().Solve(wty);
189+
190+
var result = new double[cellCount];
191+
for (int i = 0; i < cellCount; i++) result[i] = Math.Max(0, x[i]);
192+
return result;
193+
}
194+
}

src/ui/src/lib/CalibrationTabs.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<button class="px-4 py-1 rounded-full text-sm font-medium transition-colors {calibrationType === 'device' ? 'bg-emerald-400 text-black' : 'text-white hover:bg-slate-500'}" onclick={() => (calibrationType = 'device')}>
1616
Devices
1717
</button>
18+
<button class="px-4 py-1 rounded-full text-sm font-medium transition-colors {calibrationType === 'obstructions' ? 'bg-emerald-400 text-black' : 'text-white hover:bg-slate-500'}" onclick={() => (calibrationType = 'obstructions')}>
19+
Obstructions
20+
</button>
1821
</div>
1922
</div>
2023
</nav>

src/ui/src/lib/Map.svelte

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
import AxisY from './AxisY.svelte';
1515
import MapCoordinates from './MapCoordinates.svelte';
1616
import CalibrationSpot from './CalibrationSpot.svelte';
17+
import Tomography from './Tomography.svelte';
18+
import { onDestroy } from 'svelte';
19+
import type { TomographyResult, TomographyFloor } from '$lib/types';
1720
1821
let svg: Element;
1922
let transform = zoomIdentity;
@@ -24,8 +27,35 @@
2427
export let exclusive: boolean = false;
2528
export let calibrate: boolean = false;
2629
export let calibrationSpot: { x: number; y: number } | null = null;
30+
export let showTomography: boolean = false;
2731
export let onselected: ((item: Device | Node) => void) | undefined = undefined;
2832
33+
let tomography: TomographyResult | null = null;
34+
let tomoTimer: ReturnType<typeof setInterval> | null = null;
35+
36+
async function fetchTomography() {
37+
try {
38+
const r = await fetch('/api/tomography');
39+
if (r.ok) tomography = await r.json();
40+
} catch {
41+
/* transient; keep last */
42+
}
43+
}
44+
45+
$: if (showTomography && !tomoTimer) {
46+
fetchTomography();
47+
tomoTimer = setInterval(fetchTomography, 15000);
48+
}
49+
$: if (!showTomography && tomoTimer) {
50+
clearInterval(tomoTimer);
51+
tomoTimer = null;
52+
}
53+
onDestroy(() => {
54+
if (tomoTimer) clearInterval(tomoTimer);
55+
});
56+
57+
$: tomoFloor = (showTomography && tomography?.floors?.find((f) => f.floorId === floorId)) || null;
58+
2959
$: floor = $config?.floors.find((f) => f.id === floorId) ?? $config?.floors.find((f) => f != null);
3060
$: bounds = floor?.bounds;
3161
$: squareBounds = bounds ? makeSquareBounds(bounds) : undefined;
@@ -141,6 +171,9 @@
141171
<AxisX {transform} />
142172
<AxisY {transform} />
143173
<Rooms {transform} {floorId} />
174+
{#if showTomography}
175+
<Tomography {transform} tomo={tomoFloor} />
176+
{/if}
144177
<Nodes {transform} {floorId} {deviceId} {nodeId} onselected={selectedNode} onhovered={hoveredNode} />
145178
<Devices {transform} {floorId} {deviceId} {exclusive} onselected={selectedDevice} onhovered={hoveredDevice} />
146179
{#if calibrate && calibrationSpot}

src/ui/src/lib/Tomography.svelte

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<script lang="ts">
2+
import { getContext } from 'svelte';
3+
import { zoomIdentity } from 'd3-zoom';
4+
import { interpolateTurbo } from 'd3';
5+
import type { LayerCakeContext, TomographyFloor } from '$lib/types';
6+
7+
const { xScale, yScale } = getContext<LayerCakeContext>('LayerCake');
8+
9+
export let tomo: TomographyFloor | null = null;
10+
export let transform = zoomIdentity;
11+
export let opacity = 0.65;
12+
13+
interface Cell {
14+
x: number;
15+
y: number;
16+
w: number;
17+
h: number;
18+
fill: string;
19+
a: number;
20+
}
21+
22+
// Project a cell (in metres) to screen-space, handling axis flips.
23+
function rect(cx: number, cy: number, size: number) {
24+
const sx0 = $xScale(cx),
25+
sx1 = $xScale(cx + size);
26+
const sy0 = $yScale(cy),
27+
sy1 = $yScale(cy + size);
28+
return {
29+
x: Math.min(sx0, sx1),
30+
y: Math.min(sy0, sy1),
31+
w: Math.abs(sx1 - sx0),
32+
h: Math.abs(sy1 - sy0)
33+
};
34+
}
35+
36+
$: maxAtt = tomo?.maxAttenuation && tomo.maxAttenuation > 0 ? tomo.maxAttenuation : 1;
37+
$: maxCov = tomo ? Math.max(1, ...tomo.coverage) : 1;
38+
39+
$: cells = (() => {
40+
if (!tomo) return [] as Cell[];
41+
const out: Cell[] = [];
42+
for (let row = 0; row < tomo.rows; row++) {
43+
for (let col = 0; col < tomo.cols; col++) {
44+
const idx = row * tomo.cols + col;
45+
const att = tomo.attenuation[idx] ?? 0;
46+
const cov = tomo.coverage[idx] ?? 0;
47+
if (att <= 0.05 || cov <= 0) continue;
48+
const cx = tomo.minX + col * tomo.cellSize;
49+
const cy = tomo.minY + row * tomo.cellSize;
50+
const intensity = Math.min(1, att / maxAtt);
51+
// Fade cells that few links cross — low confidence, not "nothing there".
52+
const conf = Math.min(1, cov / (0.25 * maxCov));
53+
const r = rect(cx, cy, tomo.cellSize);
54+
out.push({
55+
...r,
56+
fill: interpolateTurbo(0.15 + 0.85 * intensity),
57+
a: intensity * conf
58+
});
59+
}
60+
}
61+
return out;
62+
})();
63+
</script>
64+
65+
{#if tomo}
66+
<g transform={transform.toString()} pointer-events="none">
67+
{#each cells as c}
68+
<rect x={c.x} y={c.y} width={c.w} height={c.h} fill={c.fill} fill-opacity={c.a * opacity} />
69+
{/each}
70+
</g>
71+
{/if}

0 commit comments

Comments
 (0)