Optimal BLE Beacon Placement for Floor Coverage

Where the beacons go decides whether trilateration can work at all, long before any RSSI is smoothed or any solve is run; this page covers planning placement so the BLE beacon positioning solver is well-conditioned everywhere a person can stand.

What “Good Coverage” Means for Trilateration

Coverage for positioning is not the same as coverage for connectivity. A device only needs one beacon in range to detect proximity, but trilateration needs at least three, and it needs them in good geometry. Three placement objectives follow directly from the maths of the least-squares solve.

First, three-plus visibility everywhere routable. At every point a person can walk, the receiver must hear at least three beacons above the ranging floor. Anywhere that drops to two is a coverage hole where positioning collapses to ambiguity.

Perimeter-only and staggered beacon layouts, and the geometry each produces One floor outline showing two layouts. Along the top wall, three orange beacons are mounted in a straight line; a receiver in the corridor below sees all three within a half-plane, so the solve is well constrained along the wall and almost unconstrained across it, and the fix smears sideways. In the lower half, four teal beacons are staggered between opposite walls so any receiver has anchors on more than one side; the angles they subtend are wide and the resulting fix is tight. Same beacon count, opposite conditioning one wall — all three within a half-plane fix smears across the corridor staggered — anchors subtend a wide angle tight fix perimeter-only (degenerate) staggered (well conditioned)

Count is not coverage. Both layouts satisfy a “three visible beacons” rule. Only the staggered one satisfies the geometry the least-squares solve actually needs.

Second, non-collinear geometry. Three beacons in a straight line — the default when they are screwed to one corridor wall — give a degenerate solve: the position is well-constrained along the line and almost unconstrained across it, so the fix smears sideways. Good placement spreads visible beacons so they subtend a wide angle at any receiver, which is what keeps geometric dilution of precision (GDOP) low.

Third, spacing matched to range and overlap. Beacon spacing follows from the usable range at your tx_power and path-loss exponent, discounted for walls. You want enough overlap that the three-visibility rule holds in the gaps, but not so dense that advertising channels congest and battery-replacement labour balloons. Mounting height matters too: 2.5-3 m on a wall or ceiling clears most body-shadowing, which is the largest dynamic attenuation a walking crowd introduces.

Minimal Working Example

The script below samples a grid of points across a shapely floor polygon, counts how many beacons fall within range at each point, and flags any interior point that sees fewer than three as a coverage hole. It also runs a rough geometry check — the angular spread of the visible beacons — so near-collinear configurations are flagged even where the count is satisfied.

import logging

import numpy as np
from shapely.geometry import Point, Polygon

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def audit_coverage(
    floor: Polygon,
    beacons: np.ndarray,      # shape (m, 2), beacon xy in the floor's local frame
    range_m: float = 12.0,
    min_visible: int = 3,
    grid_m: float = 1.5,
) -> list[tuple[float, float]]:
    """Return interior sample points that fail the >=3 well-spread-beacon rule."""
    if beacons.shape[0] < min_visible:
        raise ValueError(f"only {beacons.shape[0]} beacons; cannot ever satisfy min_visible")
    minx, miny, maxx, maxy = floor.bounds
    holes: list[tuple[float, float]] = []
    xs = np.arange(minx, maxx, grid_m)
    ys = np.arange(miny, maxy, grid_m)
    for x in xs:
        for y in ys:
            p = Point(x, y)
            if not floor.contains(p):
                continue
            d = np.linalg.norm(beacons - np.array([x, y]), axis=1)
            visible = beacons[d <= range_m]
            if len(visible) < min_visible or _poorly_spread(visible, x, y):
                holes.append((float(x), float(y)))
    logger.info("%d/%d interior samples fail coverage", len(holes), len(xs) * len(ys))
    return holes


def _poorly_spread(visible: np.ndarray, x: float, y: float) -> bool:
    """Flag near-collinear geometry: max gap between sorted bearings > 180 deg."""
    if len(visible) < 3:
        return True
    bearings = np.sort(np.arctan2(visible[:, 1] - y, visible[:, 0] - x))
    gaps = np.diff(np.concatenate([bearings, [bearings[0] + 2 * np.pi]]))
    return bool(np.max(gaps) > np.pi)   # all beacons within a half-plane => bad GDOP


floor = Polygon([(0, 0), (30, 0), (30, 20), (0, 20)])
beacons = np.array([[3, 3], [27, 4], [15, 18], [6, 16], [24, 15]], dtype=float)
holes = audit_coverage(floor, beacons)

If _poorly_spread flags a point, all its visible beacons sit within a half-plane — the receiver has no beacon “behind” it — which is exactly the collinear-style geometry that makes a fix unstable even with a strong signal.

Placement Parameter Reference

Parameter Symbol Typical value Notes
Target visible-beacon count min_visible ≥ 3 (aim for 4) Three is the floor; four adds redundancy and a usable residual
Usable range range_m 8 – 15 m Effective, wall-discounted range at your tx_power and n
Nominal spacing 8 – 12 m Grid or staggered; tighten in partitioned areas
Mounting height 2.5 – 3.0 m Wall or ceiling; clears body-shadowing from crowds
Grid resolution grid_m 1.0 – 2.0 m Audit sample step; finer catches smaller holes, costs runtime
Bearing-spread limit max gap ≤ 180° Geometry gate; a larger gap means all beacons on one side
The placement parameters, what each controls, and how each fails A table of six placement parameters. min_visible is three, ideally four, and sets the coverage rule; too low and the fix is ambiguous in the gaps. range_m of 8 to 15 metres is the wall-discounted reach; overstated, the plan looks complete when it is not. Spacing of 8 to 12 metres, grid or staggered, leaves holes at thresholds if too loose. A mount height of 2.5 to 3 metres clears body shadowing, without which crowds attenuate the fix. A bearing-spread gap of at most 180 degrees is the geometry gate; exceed it and the fix smears across the corridor. A grid resolution of 1 to 2 metres sets how small a hole the audit can see. Six numbers, five of them decided before anyone climbs a ladder Parameter Value Sets Failure if wrong min_visible 3 (aim 4) coverage rule △ ambiguous fix in the gaps range_m 8 - 15 m wall-discounted reach △ plan looks complete, isn't spacing 8 - 12 m grid or staggered △ holes at thresholds mount height 2.5 - 3.0 m clears body shadowing △ crowds attenuate the fix bearing spread gap <= 180° geometry gate △ fix smears across the corridor grid_m 1.0 - 2.0 m audit resolution small holes go unseen Only the last row is about the audit; every other row is about the physical install.

Two rows, one audit. range_m and the bearing-spread gap are the pair that decide whether a coverage map means anything — a generous range with no geometry gate produces a green map over a floor that cannot be positioned.

Common Errors & Fixes

Coverage looks complete but fixes are unstable in corridors. Beacons were mounted only along the perimeter walls, so in any corridor the three visible beacons are nearly collinear. The count passes, the geometry fails. Stagger beacons to alternating sides and add occasional interior or opposite-wall anchors so every routable point has a beacon subtending a wide angle:

Position error multiplier against the angular spread of the visible beacons A falling curve of geometric dilution of precision against the angular spread the visible beacons subtend at the receiver. At a 30 degree spread, GDOP is 7.9, so a one-metre ranging error becomes nearly eight metres of position error. It falls steeply to 2.5 at 90 degrees and 1.5 at 180 degrees, after which the curve flattens: full 360 degree coverage only improves it to 1.25. The error multiplier collapses once anchors surround the receiver 2 4 6 8 90 180 270 360 angular spread of visible beacons (degrees) GDOP (x range error) beyond 180° the returns flatten

Getting past a half-plane is the whole win. Moving from a 60° to a 180° spread cuts the error multiplier by 60%; going from 180° to full surround buys another 17%. That is why staggering beats densifying.

bad = np.array([[0, 2], [10, 2], [20, 2]])       # one wall -> collinear
good = np.array([[0, 2], [10, 18], [20, 2], [30, 18]])  # staggered -> well spread

Positioning dies exactly where two zones meet. Beacons were clustered densely inside rooms and left thin at thresholds and junctions — the very places people navigate through. Clustering wastes redundancy where it is not needed and starves the transitions. Space beacons against the coverage audit, not against room boundaries, so the three-visibility rule holds continuously across doorways.

Real-world range is far shorter than the plan assumed. The audit used a free-space range_m while walls, glass, and metal shelving attenuate hard. A beacon that reaches 15 m in an open hall may reach 6 m through two partitions. Discount range_m for the wall count typical of the space — or subtract range where the segment from beacon to sample crosses a wall polygon — before trusting the coverage map. Ranging quality then depends on calibrating that attenuation into the path-loss model.

Integration Point

Placement is upstream of everything else in the positioning chain: the geometry planned here sets the ceiling on what trilateration can achieve, because no solver recovers a coordinate its anchors cannot constrain. The coverage audit consumes the same floor polygon that comes out of automated floor-plan parsing and vectorization, so placement planning and map production share one geometry source and one coordinate frame. Once beacons are mounted and surveyed, their positions and calibrated tx_power feed the BLE beacon positioning solve, and the per-beacon range assumptions here are only as good as the ranging model tuned in estimating distance from BLE RSSI with a path-loss model.

Frequently Asked Questions

How far apart should BLE beacons be for positioning?

Space them so every routable point hears at least three well-spread beacons, which usually lands around 8-12 m in open areas and tighter through partitions. The exact number falls out of your usable range — the wall-discounted distance at which RSSI is still above the ranging floor — not a fixed rule of thumb. Plan the spacing, then verify it with a coverage audit over the actual floor polygon rather than trusting the nominal figure.

Why does perimeter-only placement give poor fixes?

Because in interior corridors the only visible beacons sit along one or two outer walls, leaving them nearly collinear from the receiver’s viewpoint. Trilateration then constrains position well along that line and poorly across it, so the fix smears sideways however clean the signal is. The bearing-spread check flags this: when all visible beacons fall within a half-plane, no anchor sits behind the receiver and the geometry is degenerate. Stagger beacons and add opposite-side anchors to fix it.

Do walls change how many beacons I need?

Yes, substantially. Walls, glass, and metal attenuate BLE, shrinking usable range and raising the path-loss exponent, so a partitioned floor needs more beacons than an open one of the same area. Discount range_m for the walls a signal must cross — or explicitly subtract range where the beacon-to-point segment intersects a wall polygon — before trusting a coverage map, otherwise the plan looks complete while real fixes fail near partitions.

This page is a companion to BLE Beacon Positioning & Trilateration, part of the Indoor Positioning & Fingerprinting reference.