BLE Beacon Positioning & Trilateration
A battery-powered BLE beacon does one honest thing: it shouts an identity a few times a second. Everything that turns that shout into a coordinate on a floor plan is inference, and inference is where beacon positioning quietly falls apart. This technique sits inside the Indoor Positioning & Fingerprinting discipline as the ranging-based counterpart to pattern-matching methods — instead of memorising a radio map, it estimates a metric distance to each anchored beacon from received signal strength and solves for the receiver’s position by intersecting those distances. Done carefully, three or four visible beacons yield a sub-three-metre fix that a routing engine can consume. Done naively, the same beacons produce a position that skates across walls, snaps to the wrong corridor, and jitters by ten metres every time someone walks past with a phone in their pocket.
The Problem: From a Noisy dBm Number to a Trustworthy Coordinate
BLE positioning has to bridge two mismatched worlds. On one side is a physical-layer measurement — RSSI, the received signal strength indicator, reported in dBm — that is corrupted by multipath, body shadowing, antenna orientation, and the receiver’s own automatic gain control. On the other side is a routing graph that expects a clean (x, y, level) in the building’s local frame. The naive pipeline treats RSSI as if it were a ruler: convert each reading to a distance, draw a circle around each beacon, and read off where the circles cross. Real deployments break that story in four ways.
First, RSSI is not distance. The relationship is logarithmic and environment-specific; a 6 dB drop can mean the receiver moved twice as far away or that a person stepped between phone and beacon. A single sample can be off by 8-10 dB, which at typical indoor path-loss exponents is a factor of two in estimated range.
Second, the circles almost never meet at a point. Three noisy range estimates describe three circles that intersect in a small region, not a single coordinate. You need a least-squares estimator that finds the point minimising total squared range error, plus a residual you can inspect to know whether the fix is trustworthy at all.
Third, geometry decides accuracy as much as noise does. Three beacons in a near-straight line — common when they are mounted along one corridor wall — produce a degenerate solve where a millimetre of range error smears into metres of position error along the line. This is geometric dilution of precision, and no amount of averaging fixes bad anchor geometry.
Fourth, a single fix is not a position. Even a good instantaneous solve jumps frame to frame. A usable blue dot comes from fusing successive fixes with motion, which is why the raw trilateration output here feeds a sensor-fusion and particle-filter stage rather than driving the map directly.
The job of this reference is to make each of those four failures explicit and handled: calibrated ranging, a least-squares solve that returns its own error, geometry checks that reject degenerate anchor sets, and a clean hand-off to fusion.
Prerequisites & Dependencies
Before a single position can be computed, the following must already be true:
- Surveyed beacon anchors in the building’s local frame. Every beacon has a known
(x, y)— and alevel— expressed in the same bounded Cartesian indoor coordinate reference system the routing graph uses. If the anchors live in one frame and the map in another, every fix is systematically wrong no matter how clean the RSSI is. Survey the mounting points to the same control network that georeferences the floor plan. - Calibrated transmit power per beacon. The path-loss model needs each beacon’s measured power at one metre (
tx_power, in dBm). Nominal firmware values drift with battery voltage and enclosure, so measure it in situ per beacon rather than trusting the datasheet — the companion guide on estimating distance from BLE RSSI with a path-loss model covers the calibration procedure in full. - An environment-specific path-loss exponent. The exponent
ncaptures how fast signal decays in this space (open atrium versus partitioned office). One value per floor-type is usually enough; measure it once from a few known distances. - Adequate anchor geometry. At any routable point the receiver should see at least three beacons in non-collinear positions. That is a placement problem, addressed in optimal BLE beacon placement for floor coverage, and it is a prerequisite for this maths to work at all.
- Libraries.
numpyfor the linear algebra,scipy.optimize.least_squaresfor the trilateration solve, and aloggingconfig so bad fixes are observable rather than silent.pydanticis handy for validating the beacon anchor table on load.
How It Works: Ranging, Then Intersecting, Then Fusing
The pipeline is a short funnel. Raw advertisements arrive tagged with a beacon identity — an iBeacon UUID/major/minor triple or an Eddystone namespace/instance — and a per-packet RSSI. Each identity is looked up against the surveyed anchor table to recover its (x, y) and calibrated tx_power. The log-distance path-loss model turns RSSI into a range estimate, giving one circle per beacon centred on the anchor. A least-squares solver then finds the single point that best satisfies all the range constraints at once and reports an RMS residual as a built-in quality score. Only then does the fix leave this stage, flowing into a filter that fuses it across time with dead-reckoning to produce the stable position a map actually shows.
Step-by-Step Implementation
Three functions carry the pipeline: range each beacon, assemble a clean anchor-and-range set, then solve for the position with a reported accuracy. Each is small enough to test in isolation, which matters because a positioning bug is almost impossible to diagnose once the stages are fused.
Step 1: Convert RSSI to range with calibrated transmit power
The log-distance path-loss model states that received power falls logarithmically with distance: rssi = tx_power - 10 * n * log10(d), which inverts to d = 10 ** ((tx_power - rssi) / (10 * n)). The two parameters that matter are tx_power (the beacon’s measured RSSI at one metre) and n (the path-loss exponent for this environment). Use the per-beacon calibrated tx_power, never the firmware nominal, and clamp absurd ranges rather than letting a spurious −30 dBm reflection claim the receiver is on top of the beacon.
import logging
import math
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def rssi_to_distance(
rssi: float,
tx_power: float,
path_loss_exponent: float = 2.2,
max_range_m: float = 40.0,
) -> float:
"""Invert the log-distance path-loss model to estimate range in metres.
rssi and tx_power are in dBm; tx_power is the beacon's calibrated RSSI at 1 m.
"""
if path_loss_exponent <= 0:
raise ValueError(f"path_loss_exponent must be > 0, got {path_loss_exponent}")
try:
distance = 10.0 ** ((tx_power - rssi) / (10.0 * path_loss_exponent))
except OverflowError:
logger.warning("range overflow for rssi=%.1f tx_power=%.1f; clamping", rssi, tx_power)
return max_range_m
clamped = min(max(distance, 0.3), max_range_m)
if clamped != distance:
logger.debug("range %.2f m clamped to %.2f m", distance, clamped)
return clamped
Step 2: Assemble the anchor-and-range set, filtering weak and stale beacons
Trilateration is only as good as the beacons fed to it. Drop advertisements weaker than a floor (below roughly −90 dBm the model is pure noise), drop identities not in the surveyed anchor table, and drop readings older than a short staleness window so a beacon the receiver has walked away from does not anchor the solve to a stale circle. Return the anchor coordinates and ranges as parallel numpy arrays ready for the solver.
import logging
import time
from dataclasses import dataclass
import numpy as np
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class BeaconReading:
beacon_id: str
rssi: float
timestamp: float # epoch seconds
@dataclass(frozen=True)
class BeaconAnchor:
x: float
y: float
tx_power: float
def build_range_set(
readings: list[BeaconReading],
anchors: dict[str, BeaconAnchor],
n: float = 2.2,
rssi_floor: float = -90.0,
max_age_s: float = 4.0,
now: float | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Return (anchor_xy[k,2], ranges[k]) for beacons fit to trilaterate."""
now = time.time() if now is None else now
xy: list[tuple[float, float]] = []
ranges: list[float] = []
for r in readings:
anchor = anchors.get(r.beacon_id)
if anchor is None:
logger.debug("unknown beacon %s ignored", r.beacon_id)
continue
if r.rssi < rssi_floor or (now - r.timestamp) > max_age_s:
continue
xy.append((anchor.x, anchor.y))
ranges.append(rssi_to_distance(r.rssi, anchor.tx_power, n))
if len(xy) < 3:
raise ValueError(f"need >= 3 usable beacons, got {len(xy)}")
logger.info("trilaterating from %d beacons", len(xy))
return np.asarray(xy, dtype=float), np.asarray(ranges, dtype=float)
Step 3: Solve for the position by least squares, returning a residual
With k >= 3 circles that do not meet at a point, the position is the coordinate minimising the sum of squared differences between measured range and modelled distance to each anchor. scipy.optimize.least_squares handles the nonlinear residual robustly; seed it at the range-weighted centroid so it converges fast. The RMS of the final residual vector is the fix’s built-in confidence: a small residual means the circles agree, a large one means noise or bad geometry, and the fusion stage keys its trust on exactly this number.
import logging
import numpy as np
from scipy.optimize import least_squares
logger = logging.getLogger(__name__)
def trilaterate(anchor_xy: np.ndarray, ranges: np.ndarray) -> tuple[np.ndarray, float]:
"""Least-squares position fix. Returns (xy[2], rms_residual_m)."""
def residuals(p: np.ndarray) -> np.ndarray:
return np.linalg.norm(anchor_xy - p, axis=1) - ranges
# Seed at the inverse-range-weighted centroid — nearer beacons pull harder.
weights = 1.0 / np.clip(ranges, 0.3, None)
seed = np.average(anchor_xy, axis=0, weights=weights)
try:
sol = least_squares(residuals, seed, method="lm", max_nfev=100)
except ValueError as exc:
logger.error("trilateration solve failed: %s", exc)
raise
rms = float(np.sqrt(np.mean(sol.fun ** 2)))
logger.info("fix=(%.2f, %.2f) rms_residual=%.2f m", sol.x[0], sol.x[1], rms)
return sol.x, rms
Edge Cases & Gotchas
| Symptom | Root cause | Diagnostic | Remediation |
|---|---|---|---|
| Position smears along one axis | Anchors near-collinear (mounted along one wall) → high GDOP | Rank of demeaned anchor_xy, or condition number of the Jacobian |
Reject the fix; fix placement so visible beacons are non-collinear |
need >= 3 usable beacons at run time |
Under-coverage or aggressive rssi_floor |
Count survivors after the staleness/floor filter | Lower rssi_floor slightly or densify beacons; never solve with two |
| Fix lurches toward one beacon | Multipath spike gave one beacon a −45 dBm reflection → tiny range | Flag readings whose range residual is a large outlier | Use a robust loss="soft_l1" in least_squares, or drop the outlier and re-solve |
| Systematic 3-5 m bias everywhere | Stale tx_power (dead battery) or wrong n for the space |
Compare fixes at surveyed ground-truth points | Recalibrate tx_power per beacon; retune n for the floor type |
| Fix flips to the wrong side of a wall | Two-beacon-like geometry with a reflection filling the third circle | Residual acceptable but position off-map | Snap the fused output to the routing graph and reject off-polygon fixes |
The recurring lesson: a low residual is necessary but not sufficient. Geometry can make a confident-looking fix wildly wrong, which is why the geometry gate belongs before the solver’s residual is trusted.
Validation Output
Validate the solver against a synthetic scene with known ground truth and injected noise. A correct implementation lands within a couple of metres of truth and reports a small residual; a degenerate anchor set should be caught before it produces a phantom fix.
import numpy as np
rng = np.random.default_rng(7)
truth = np.array([12.0, 8.0])
anchors_xy = np.array([[0.0, 0.0], [20.0, 1.0], [10.0, 18.0]]) # well-spread
true_ranges = np.linalg.norm(anchors_xy - truth, axis=1)
noisy = true_ranges + rng.normal(0.0, 0.6, size=true_ranges.shape)
fix, rms = trilaterate(anchors_xy, noisy)
assert np.linalg.norm(fix - truth) < 2.0, f"fix {fix} too far from truth {truth}"
assert rms < 1.5, f"residual {rms:.2f} m unexpectedly large"
# Collinear anchors — GDOP explodes; residual can still look small, so gate on geometry.
collinear = np.array([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]])
assert np.linalg.matrix_rank(collinear - collinear.mean(axis=0)) < 2
print("OK", fix, round(rms, 2))
The failure to watch for is the collinear set slipping through: the solver may return a plausible (x, y) with a small residual while the cross-corridor position is unconstrained. Test the anchor geometry, not just the residual.
Performance & Scale Notes
- Advertising interval sets the update ceiling. A beacon at a 100 ms interval offers at most ten samples a second; a 1 s interval offers one. Position update rate can never exceed the slowest beacon in the fix, so choose the interval against how fast a walker crosses the geometry, not against battery alone.
- RSSI must be smoothed before ranging, not after. Feeding a running-mean or EMA-smoothed RSSI into
rssi_to_distancecuts range variance far more than smoothing the position; the path-loss ranging companion shows the smoothing window trade-off. - The solve is cheap; the scan is not. A three-to-six-beacon
least_squarescall is microseconds. The cost is BLE scanning duty cycle on the client, which drains the receiver’s battery — batch-scan and solve on a cadence rather than per packet. - Beacon density is a coverage-versus-collision trade. More beacons improve geometry and redundancy but crowd the advertising channels; past roughly one beacon per 60-80 m² of open floor the marginal fix quality flattens while battery-replacement labour keeps rising.
- Battery is the real operating cost. Coin-cell beacons at aggressive intervals last months, not years. Plan placement and interval together so a floor’s beacons fail predictably on a maintenance schedule rather than randomly mid-deployment.
Frequently Asked Questions
How many beacons must be visible for a usable fix?
At least three non-collinear beacons for a 2D fix, and four or more is materially better because the extra constraint lets the least-squares solver average out one bad reading and report a meaningful residual. Two beacons give you only an ambiguous pair of intersection points and no way to tell which is real, so the range-set builder rejects anything under three. The deeper requirement is geometry: three beacons in a straight line satisfy the count but produce a degenerate solve, so plan placement for spread, not just quantity.
Why not just use the strongest beacon's proximity zone?
Nearest-beacon “proximity” is coarse room-level presence, not positioning — it snaps to whichever beacon happens to shout loudest, which flips as a person turns or a body blocks the signal. Trilateration uses all visible beacons at once and returns a continuous coordinate with an error estimate, which a routing engine can actually navigate on. Proximity is fine for “you are near the entrance” analytics; it cannot drive a blue dot down a corridor.
Is the raw trilateration fix good enough to show on the map?
No — an instantaneous least-squares fix jitters by metres frame to frame because RSSI noise moves the circles every sample. Showing it raw produces a blue dot that vibrates and jumps through walls. Feed the fix and its residual into a filter that fuses it with pedestrian motion, so the displayed position is smooth and physically plausible. That fusion stage, not this solver, is what the map renders.
How do I keep multipath reflections from wrecking a fix?
Multipath inflates RSSI on some packets, making a beacon look closer than it is and dragging the fix toward it. Defend in three layers: smooth RSSI per beacon before ranging so single-packet spikes are diluted, use a robust loss such as soft_l1 in the least-squares solve so one outlier range cannot dominate, and reject fixes whose residual or final position falls outside the floor polygon. No single layer is enough; multipath is the dominant indoor error source.
Related
- Estimating Distance from BLE RSSI with a Path-Loss Model — the ranging step in depth, including tx-power calibration and RSSI smoothing.
- Optimal BLE Beacon Placement for Floor Coverage — planning anchor geometry so trilateration is well-conditioned everywhere.
- Wi-Fi RSSI Fingerprinting — the pattern-matching alternative when metric ranging is impractical.
- Sensor Fusion & Particle Filters — the stage that turns jittery fixes into a stable displayed track.
- Indoor Coordinate Reference Systems — the local frame beacon anchors and the routing graph must share.
This page is part of the Indoor Positioning & Fingerprinting reference; the fixes it produces are stabilised by Sensor Fusion & Particle Filters before they reach the map.