SX1280 Hardware Ranging — Calibration

Project: Giga Ranger — GPS-independent distance measurement via SX1280 Time-of-Flight Hardware: LILYGO T3-S3 (ESP32-S3) · SX1280 @ 2.45 GHz · 13 dBi Yagi · 60 km fixed LOS link, Stubbornly Sovereign Alberta Method: Conducted (cabled) calibration — Wolf et al. (2019) §IV.A.1 + Semtech AN1200.29 Devices: Alpha (permanent master) · Chimp-001 (permanent slave) Calibration date: 2026-07-04 · Die temp: 31.3°C

Parameter Value
Spreading Factor SF9
Bandwidth 1625 kHz
CAL_TABLE[2][4] 13089
AN1200.29 default 13430
Total correction −341 counts
Verification mean +0.668 m
Cable electrical length 0.695 m
Residual −27 mm
CalibrationValue 0

Theoretical accuracy at 60 km (calibration applied): ±0.47 m per exchange (1σ) · ±0.021 m averaged over 500 exchanges


1. Background

The SX1280 integrates a hardware Time-of-Flight ranging engine. The master transmits a ranging request; the slave responds after a fixed internal turnaround delay; the master measures the round-trip time and converts it to distance. The result is a 24-bit integer in units of c / (2 × BW × 2^SF) — for SF9/BW1625: 0.1803 m per count.

The SX1280 subtracts a nominal turnaround time internally, but each chip has a slightly different actual RX→TX switching delay. This systematic offset must be measured and corrected per board. The correction lives in Chimp-001’s RxTxDelay register — adjusted via a calibration table passed to startRanging().

In RadioLib 7.7.1, setRangingCalibration() does not exist. The calibration is applied by passing a custom 3×6 table (rows = BW, columns = SF5–SF10) to startRanging() on every exchange.

See Appendix A for a detailed explanation of why calibration lives on the slave (Chimp-001).

2. Hardware Setup

Devices

Device Role Notes
Alpha Permanent master (initiator) LILYGO T3-S3 V1.3, SX1280
Chimp-001 Permanent slave (responder) LILYGO T3-S3 V1.3, SX1280

Calibration signal chain

[Alpha] ── SMA ── [40 dB atten] ── [1 m RG-316 coax] ── [Chimp-001]

TX power: −18 dBm (SX1280 minimum). With one 40 dB attenuator: −58 dBm at RX. No antenna fitted during calibration.

Reference cable

Property Value
Part DigiKey J10302-ND — Amphenol RG-316 MIL-DTL-17
Physical length 1.000 m
Velocity factor 0.695 (MIL-DTL-17 spec, confirmed from jacket markings)
Electrical length 0.695 m (calibration target)

Calibration rig — 2026-07-04

Figure 1 — Full setup: Alpha and Chimp-001 connected via 40 dB attenuator and 1 m RG-316 reference cable. RF enclosures sealed to reduce spurious coupling.

Calibration rig overview

Figure 2 — Alpha (master)

Alpha unit

Figure 3 — Chimp-001 (slave)

Chimp-001 unit

Figure 4 — Sealed enclosures during calibration run

Alpha sealed enclosure Chimp-001 sealed enclosure

3. Chimp Calibration Method

The Chimp Calibration method uses Alpha as the permanent master across all calibration runs. Only Chimp-001’s RxTxDelay register is corrected — its slave-mode delay is what Alpha measures.

Steps: 1. Flash Chimp-001 (-e slave), then Alpha (-e master) 2. Assemble the cabled signal chain; seal RF enclosures 3. Press SPACE on Alpha’s serial monitor to start a 500-exchange collection pass 4. Record Mean, CalibrationValue, and ESP32 die temp 5. Repeat 3–5 passes to establish a stable baseline mean 6. Adjust CAL_TABLE[2][4] by the CalibrationValue × empirical table rate 7. Reflash Alpha (Chimp-001 does not need reflashing — table is passed from Alpha at runtime) 8. Repeat until CalibrationValue ≈ 0 and Mean ≈ 0.695 m

Empirical table rate for SF9: ~0.0224 m per table count (measured from iteration 1 convergence). This is approximately half the SF10 rate (0.0456 m/count), consistent with the SF doubling relationship.

See Appendix B for a note on the AN1200.29 role-reversal averaging method and why it was not used here.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.ticker import MultipleLocator

BG      = "#0d0f12"
SURFACE = "#151820"
BORDER  = "#252a35"
ACCENT  = "#4fa3e0"
ACCENT2 = "#7ed6a0"
TEXT    = "#d8dce8"
MUTED   = "#6b7280"
HEADING = "#f0f4ff"
WARN    = "#f78c6c"
C3      = "#c792ea"

plt.rcParams.update({
    'figure.facecolor': BG, 'axes.facecolor': SURFACE,
    'axes.edgecolor': BORDER, 'axes.labelcolor': TEXT,
    'xtick.color': MUTED, 'ytick.color': MUTED,
    'text.color': TEXT, 'grid.color': BORDER,
    'grid.linewidth': 0.5, 'font.family': 'sans-serif', 'font.size': 11,
    'axes.spines.top': False, 'axes.spines.right': False,
})

CABLE_ELEC = 0.695   # m

# ── Calibration run data ──────────────────────────────────────────────────────
baseline_means  = [-6.877, -6.990, -6.890, -6.947, -6.999]
baseline_sigmas = [ 0.674,  0.884,  0.882,  0.650,  0.477]

iter1_means     = [-3.1125, -3.3078, -3.2223, -3.2039, -3.2977]
iter1_sigmas    = [ 0.4519,  0.4386,  0.4388,  0.4482,  0.4773]

iter2_means     = [0.6499, 0.6674, 0.6874]
iter2_sigmas    = [0.4758, 0.4671, 0.4729]

table_vals = [13430, 13264, 13089]
group_means = [np.mean(baseline_means), np.mean(iter1_means), np.mean(iter2_means)]
group_sigmas = [np.mean(baseline_sigmas), np.mean(iter1_sigmas), np.mean(iter2_sigmas)]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.patch.set_facecolor(BG)

# ── Left: mean convergence per run ───────────────────────────────────────────
ax = axes[0]
all_means  = baseline_means + iter1_means + iter2_means
all_sigmas = baseline_sigmas + iter1_sigmas + iter2_sigmas
n = len(all_means)
x = np.arange(1, n + 1)

colours = ([ACCENT] * len(baseline_means) +
           [ACCENT2] * len(iter1_means) +
           [WARN] * len(iter2_means))

for xi, yi, si, ci in zip(x, all_means, all_sigmas, colours):
    ax.errorbar(xi, yi, yerr=si, fmt='o', color=ci, ecolor=ci,
                elinewidth=1.2, capsize=4, markersize=7, alpha=0.9)

ax.axhline(CABLE_ELEC, color=HEADING, linewidth=1.2, linestyle='--', alpha=0.7, label='Target (0.695 m)')
ax.axhline(0, color=BORDER, linewidth=0.8, linestyle=':')

# Shade iteration bands
for span, col, lbl in [
    ((0.5, len(baseline_means) + 0.5), ACCENT, 'Baseline (13430)'),
    ((len(baseline_means) + 0.5, len(baseline_means) + len(iter1_means) + 0.5), ACCENT2, 'Iter 1 (13264)'),
    ((len(baseline_means) + len(iter1_means) + 0.5, n + 0.5), WARN, 'Iter 2 — Verified (13089)'),
]:
    ax.axvspan(span[0], span[1], alpha=0.07, color=col, label=lbl)

ax.set_xlabel('Run (sequential)')
ax.set_ylabel('Measured distance (m)')
ax.set_title('Calibration convergence — all runs', color=HEADING, fontsize=12)
ax.legend(fontsize=9, framealpha=0.2, facecolor=SURFACE, edgecolor=BORDER)
ax.grid(True, axis='y', alpha=0.4)
ax.set_xlim(0.2, n + 0.8)

# ── Right: mean vs table value (empirical rate) ───────────────────────────────
ax2 = axes[1]
tv = np.array(table_vals)
gm = np.array(group_means)
gs = np.array(group_sigmas)

ax2.errorbar(tv, gm, yerr=gs, fmt='o-', color=ACCENT, ecolor=ACCENT,
             elinewidth=1.5, capsize=5, markersize=9, linewidth=1.5, label='Measured mean ± 1σ')
ax2.axhline(CABLE_ELEC, color=HEADING, linewidth=1.2, linestyle='--', alpha=0.7, label='Target (0.695 m)')

# Linear fit
fit = np.polyfit(tv, gm, 1)
tv_line = np.linspace(tv.min() - 30, tv.max() + 30, 200)
ax2.plot(tv_line, np.polyval(fit, tv_line), color=MUTED, linewidth=1,
         linestyle=':', alpha=0.7, label=f'Fit: {fit[0]*1000:.2f} mm/count')

for xi, yi, lbl in zip(tv, gm, ['13430\n(default)', '13264\n(iter 1)', '13089\n(iter 2)']):
    ax2.annotate(lbl, (xi, yi), textcoords='offset points', xytext=(10, -14),
                 color=MUTED, fontsize=8.5)

ax2.set_xlabel('CAL_TABLE[2][4]')
ax2.set_ylabel('Mean measured distance (m)')
ax2.set_title('Empirical table rate — SF9/BW1625', color=HEADING, fontsize=12)
ax2.legend(fontsize=9, framealpha=0.2, facecolor=SURFACE, edgecolor=BORDER)
ax2.grid(True, alpha=0.4)
ax2.invert_xaxis()

plt.tight_layout(pad=2.0)
plt.savefig('images/SF9_Calibration_Convergence.png', dpi=150, bbox_inches='tight',
            facecolor=BG)
plt.show()
print(f"Empirical table rate: {fit[0]*1000:.3f} mm per table count (fit)")
print(f"Measured from iter1:  {3715/166:.1f} mm per table count")

Empirical table rate: -22.313 mm per table count (fit)
Measured from iter1:  22.4 mm per table count
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.patch.set_facecolor(BG)

# ── Left: per-run box/violin summary ─────────────────────────────────────────
ax = axes[0]
labels = ['Run 1', 'Run 2', 'Run 3']
for i, (m, s, col) in enumerate(zip(iter2_means, iter2_sigmas,
                                     [ACCENT, ACCENT2, WARN])):
    x = i + 1
    ax.errorbar(x, m, yerr=s * 2, fmt='o', color=col, ecolor=col,
                elinewidth=2, capsize=6, markersize=10, label=f'{labels[i]}: {m:.3f} m')
    ax.errorbar(x, m, yerr=s, fmt='_', color=col, ecolor=col,
                elinewidth=4, capsize=0, markersize=0, alpha=0.7)

ax.axhline(CABLE_ELEC, color=HEADING, linewidth=1.5, linestyle='--', label='Target 0.695 m')
ax.set_xticks([1, 2, 3])
ax.set_xticklabels(labels)
ax.set_ylabel('Measured distance (m)')
ax.set_title('Verification runs — CAL_TABLE[2][4] = 13089', color=HEADING, fontsize=12)
ax.legend(fontsize=9, framealpha=0.2, facecolor=SURFACE, edgecolor=BORDER)
ax.grid(True, axis='y', alpha=0.4)
ax.set_xlim(0.4, 3.6)

# ── Right: σ comparison across all iterations ────────────────────────────────
ax2 = axes[1]
all_s = baseline_sigmas + iter1_sigmas + iter2_sigmas
xpos  = np.arange(1, len(all_s) + 1)
cols  = ([ACCENT] * len(baseline_sigmas) +
         [ACCENT2] * len(iter1_sigmas) +
         [WARN] * len(iter2_sigmas))
bars = ax2.bar(xpos, [s * 1000 for s in all_s], color=cols, alpha=0.8, width=0.7)

# Mean σ lines per iteration
for span, vals, col in [
    ((1, len(baseline_sigmas)), baseline_sigmas, ACCENT),
    ((len(baseline_sigmas)+1, len(baseline_sigmas)+len(iter1_sigmas)), iter1_sigmas, ACCENT2),
    ((len(baseline_sigmas)+len(iter1_sigmas)+1, len(all_s)), iter2_sigmas, WARN),
]:
    ax2.hlines(np.mean(vals)*1000, span[0]-0.4, span[1]+0.4,
               colors=col, linewidth=2, linestyle='-', alpha=0.9)

ax2.set_xlabel('Run (sequential)')
ax2.set_ylabel('Std dev (mm)')
ax2.set_title('Per-run σ — all iterations', color=HEADING, fontsize=12)
ax2.grid(True, axis='y', alpha=0.4)

patches = [mpatches.Patch(color=ACCENT,  label='Baseline (13430)'),
           mpatches.Patch(color=ACCENT2, label='Iter 1 (13264)'),
           mpatches.Patch(color=WARN,    label='Iter 2 verified (13089)')]
ax2.legend(handles=patches, fontsize=9, framealpha=0.2,
           facecolor=SURFACE, edgecolor=BORDER)

plt.tight_layout(pad=2.0)
plt.savefig('images/SF9_Calibration_Sigma.png', dpi=150, bbox_inches='tight',
            facecolor=BG)
plt.show()
print(f"Baseline avg σ:     {np.mean(baseline_sigmas)*1000:.0f} mm")
print(f"Iter 1 avg σ:       {np.mean(iter1_sigmas)*1000:.0f} mm")
print(f"Verification avg σ: {np.mean(iter2_sigmas)*1000:.0f} mm")

Baseline avg σ:     713 mm
Iter 1 avg σ:       451 mm
Verification avg σ: 472 mm

4. Production Calibration Table

Copy this table directly into the ranging firmware. Pass it via startRanging() in both the Alpha and Chimp-001 builds — only Chimp-001’s register is active during ranging, but both builds must supply the table.

// SF9, BW=1625 kHz — Alpha (master) + Chimp-001 (slave), LILYGO T3-S3 V1.3
// Calibration date: 2026-07-04  ·  ESP32 die temp: 31.3°C
// AN1200.29 default SF9/BW1625 = 13430  ·  total correction = −341 counts (−7.64 m)
static const uint16_t CAL_TABLE[3][6] = {
    { 10299, 10271, 10244, 10242, 10230, 10246 },  // BW  406.25 kHz — SF5–SF10
    { 11486, 11474, 11453, 11426, 11417, 11401 },  // BW  812.50 kHz — SF5–SF10
    { 13308, 13493, 13528, 13515, 13089, 13376 },  // BW 1625.00 kHz — SF5–SF10 (SF9 adjusted)
};

radio.startRanging(master, RANGING_ADDR, CAL_TABLE);

Calibration table unit

1 table count ≈ 22.4 mm of distance shift (empirical, SF9). This is approximately half the SF10 rate (45.6 mm/count), consistent with the 2× difference in ranging resolution between SF9 and SF10. The table uses an internal timer, not the result register counter.

Temperature sensitivity

The CAL_TABLE value is mildly temperature-sensitive via crystal oscillator drift. The LILYGO T3-S3 uses an AT-cut crystal near its thermal turnover point at calibration temperature (~31°C), which is the flattest part of the curve.

Operating scenario ΔT from calibration Range error at 60 km
Warm summer (35°C) +4°C < 0.05 m
Cold morning (15°C) −16°C < 0.20 m
Full range (15–35°C) ±16°C < 0.20 m

Maximum temperature drift is below the per-exchange noise floor (σ ≈ 0.47 m). Log BME280 ambient temperature alongside each ToF result to detect long-term drift. ESP32 die temperature baseline: 31.3°C.


5. Temperature Characterisation (2026-07-15)

A 110-minute continuous log measured ranging drift vs die temperature across the full thermal range — from cold boot (~29°C) through natural CPU plateau (~37°C) to heat-gun peak (~54°C).

Technique

Both devices ran the calibration firmware with dual-core CPU burn (core 0 continuous + 400 ms/exchange on core 1) to self-heat. A heat gun was then applied to the sealed RF enclosure to exceed the natural plateau. Both devices logged (master) and (slave) simultaneously.

Signal chain (same as calibration):

Key Epochs

Time Event
t = 0 s Boot — both devices cold (~29°C)
t = 0–43 min CPU burn warmup — plateau at 36–37°C
t = 43.5 min Heat gun applied to Alpha enclosure
t = 47.1 min Alpha peak die: 54.3°C
t = 49.3 min Chimp peak die: 52.6°C
t = 110 min Log end (9 035 valid master samples)
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from scipy import stats
from IPython.display import display, Image

# ── Load data ────────────────────────────────────────────────────────────────
def load_master(path):
    rows = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("---") or line.startswith("#") or line.startswith("t_ms"):
                continue
            p = line.split(",")
            try:
                if len(p) == 4:
                    rows.append({"t_ms": float(p[0]), "raw_m": float(p[1]), "die_c": float(p[2]), "amb_c": float(p[3])})
            except: pass
    return pd.DataFrame(rows)

def load_slave(path):
    rows = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("---") or line.startswith("#") or line.startswith("t_ms"):
                continue
            p = line.split(",")
            try:
                if len(p) == 3:
                    rows.append({"t_ms": float(p[0]), "die_c": float(p[1]), "amb_c": float(p[2])})
            except: pass
    return pd.DataFrame(rows)

m = load_master("data/master_20260715_023633.csv")
s = load_slave("data/slave_20260715_023640.csv")
mf = m[(m.raw_m != 0.0) & (m.raw_m > -20.0) & (m.raw_m < 10.0)].copy()
mf["t_min"] = (mf.t_ms - mf.t_ms.min()) / 60000.0
s["t_min"]  = (s.t_ms  - mf.t_ms.min()) / 60000.0

print(f"Master: {len(mf)} valid samples  die {mf.die_c.min():.1f}{mf.die_c.max():.1f}°C")
print(f"Slave:  {len(s)} samples          die {s.die_c.min():.1f}{s.die_c.max():.1f}°C")

Figure 5 — Time series

Figure 5 — Time series. Top: raw ranging result vs time. Bottom: Alpha and Chimp-001 die temperatures and ambient. The dashed line marks heat gun application at t = 43.5 min. The natural CPU burn plateau is clearly visible at ~37°C before the heat gun drives temperatures to ~54°C.

Figure 6 — Regression

Figure 6 — Ranging result vs Alpha die temperature. Blue = warmup phase (30–38°C, CPU burn only); orange = heat-gun phase (>38°C). Linear fits shown for each phase.

Phase Slope n
Warmup (30–38°C) −0.085 m/°C 0.007 6 114
Heat-gun (>38°C) ~0 m/°C <0.001 2 921

The low R² reflects that per-sample ranging noise (σ ≈ 0.5 m) dominates the thermal drift signal. The −0.085 m/°C coefficient is statistically significant (p < 10⁻⁹) but practically small at normal field ΔT. The heat-gun phase shows elevated noise (σ up to 3.4 m at 40°C) consistent with thermal stress on SMA connectors rather than a clean XOSC drift.

Figure 7 — Per-bin means

Figure 7 — Mean ranging result per die temperature bin (≥20 samples). Error bars = ±1σ. Blue bars = CPU warmup phase; orange = heat-gun phase.

Summary and Recommendation

The temperature coefficient of −0.085 m/°C (combined Alpha + Chimp-001) means:

  • At calibration temp 31.3°C → field plateau 44°C (+12.7°C): expected drift ≈ 1.1 m
  • This exceeds the rolling median noise floor and represents a systematic offset in production

Recommended action: re-run calibration with both devices at their field thermal plateau (~44°C) to absorb the temperature offset into the calibration table. The CPU burn firmware with dual-core loading achieves this without external heating.

Future work: repeat heat-gun characterisation with longer temperature soak (≥5 min per step) to separate XOSC drift from connector thermal instability.

# ── Linear fit summary ───────────────────────────────────────────────────
warm = mf[mf.die_c <= 38.0]
hot  = mf[mf.die_c >  38.0]
sl, ic, r, p, _ = stats.linregress(warm.die_c, warm.raw_m)
sl2, ic2, r2, p2, _ = stats.linregress(hot.die_c, hot.raw_m)

print("Warmup phase (30–38°C):")
print(f"  slope     = {sl:.4f} m/°C")
print(f"  intercept = {ic:.4f} m")
print(f"  R²        = {r**2:.4f}")
print(f"  p-value   = {p:.2e}")
print(f"  n         = {len(warm)}")
print()
print("Heat-gun phase (>38°C):")
print(f"  slope     = {sl2:.4f} m/°C")
print(f"  R²        = {r2**2:.4f}")
print(f"  n         = {len(hot)}")
print()
grp = mf.groupby("die_c")["raw_m"].agg(["mean","std","count"])
grp = grp[grp["count"] >= 20].round(4)
print("Per-bin summary (≥20 samples):")
print(grp.to_string())

References

[Wolf 2019] Wolf, F., Le Déroff, K., de Rivaz, S., Deparday, J., Guichard, R. (2019). Ranging and Positioning with the SX1280 in LoRa Modulation.

[AN1200.29] Semtech. SX1280 Ranging Calibration. Application Note AN1200.29.

[MIL-DTL-17] US Department of Defense. Detail Specification: Cables, Radio Frequency, Flexible and Semirigid, General Specification for. MIL-DTL-17H.


Appendix A — Why Calibration Lives on Chimp-001

The SX1280 ranging exchange:

  1. Alpha transmits a ranging request packet
  2. Chimp-001 receives it, switches from RX to TX mode, and sends a response after an internal turnaround delay
  3. Alpha measures the total round-trip time and computes distance

The chip subtracts a fixed nominal turnaround time internally. What it cannot account for is each board’s actual RX→TX switching delay, which varies between chips due to component tolerances. The RxTxDelay register on Chimp-001 adjusts when it transmits its response — shifting Alpha’s RTT measurement to compensate.

Because Alpha and Chimp-001 have permanently fixed roles: - Chimp-001: its RxTxDelay is corrected to match what Alpha’s ranging engine expects - Alpha: its calibration register is unused while acting as master

Running multiple passes with Alpha as the fixed master directly measures Chimp-001’s slave-mode delay. This is more accurate than the averaged role-reversal approach (see Appendix B) because it does not dilute Chimp-001’s correction with Alpha’s delay.

Appendix B — AN1200.29 Role-Reversal Method (Not Used)

Semtech AN1200.29 describes a two-pass calibration where each board acts as master in turn. The two CalibrationValue results are averaged to produce a single correction applied to both boards. This approach is designed for deployments where both boards may swap roles.

Why it was not used for Giga Ranger:

Alpha and Chimp-001 have permanently labelled fixed roles. Averaging the two CalibrationValues would dilute Chimp-001’s correction with Alpha’s delay — producing a less accurate result for a fixed-role deployment. The Chimp Calibration method (Alpha as master only, multiple passes) directly measures the delay that needs correcting.

For reference, a single role-reversal run (Chimp-001 as master) was performed during SF10 calibration and showed CalibrationValue = −8 for Alpha as slave, versus −96 for Chimp-001 as slave — confirming significant asymmetry between the two chips and validating the fixed-role approach.

Appendix C — SF10 Calibration (Historical Reference)

SF10 was evaluated before SF9 was confirmed as the production spreading factor. SF9 gives approximately 2× better ranging precision (σ ≈ 470 mm vs ~1600 mm) with sufficient link margin for the 60 km fixed LOS link.

SF10 calibration — 2026-07-03:

Run Mean Std Dev CalibrationValue
A1 −7.985 m 1542 mm −96
A2 −8.179 m 1753 mm −98
A3 −8.351 m 1795 mm −100
A4 −8.292 m 2260 mm −100
A5 −8.344 m 2018 mm −100
A6 −8.250 m 1431 mm −99
Avg −8.242 m −99

Final table value: CAL_TABLE[2][5] = 13180 (default 13376 − 196 counts). Verification residual = +53 mm.

SF10 Alpha calibration runs