168 lines
6.4 KiB
Python
168 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
||
"""R10 — through-foliage WiFi attenuation curves (ITU-R P.833 + per-species gait).
|
||
|
||
See docs/research/sota-2026-05-22/R10-through-foliage-wildlife.md.
|
||
|
||
Plots the ITU-R P.833 vegetation specific attenuation A_v over distance
|
||
for 2.4 GHz and 5 GHz CSI bands across three foliage densities. Compares
|
||
to a 1×1 SISO ESP32-S3's link budget to derive a maximum sensing range.
|
||
Pure NumPy, no plotting libs — emits a JSON file with the curves so a
|
||
downstream consumer can render them.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from pathlib import Path
|
||
import numpy as np
|
||
|
||
|
||
def itu_p833_attenuation(freq_ghz: float, distance_m: float, foliage_density: str) -> float:
|
||
"""ITU-R P.833 specific-attenuation model for in-foliage propagation.
|
||
|
||
Simplified parameterisation:
|
||
A_max = max attenuation through dense canopy (dB)
|
||
gamma = decay coefficient (1/m)
|
||
|
||
A_v(d) = A_max * (1 - exp(-gamma * d))
|
||
|
||
Realistic A_max / gamma per density (calibrated against in-leaf summer
|
||
deciduous from ITU-R P.833-9 Table 1 + simulation studies):
|
||
sparse (orchard, savanna) A_max=20 dB, gamma=0.10
|
||
moderate (suburban tree cover) A_max=35 dB, gamma=0.20
|
||
dense (rainforest canopy) A_max=50 dB, gamma=0.35
|
||
The constant gets multiplied by sqrt(f_GHz / 1) for frequency scaling.
|
||
"""
|
||
params = {
|
||
"sparse": (20.0, 0.10),
|
||
"moderate": (35.0, 0.20),
|
||
"dense": (50.0, 0.35),
|
||
}
|
||
a_max, gamma = params[foliage_density]
|
||
freq_scaling = np.sqrt(freq_ghz) # higher freq → more attenuation
|
||
return a_max * freq_scaling * (1.0 - np.exp(-gamma * distance_m))
|
||
|
||
|
||
def esp32_link_budget(freq_ghz: float) -> dict[str, float]:
|
||
"""ESP32-S3 1x1 SISO link budget at 2.4 / 5 GHz.
|
||
|
||
Numbers from Espressif ESP32-S3 datasheet + standard WiFi specs:
|
||
Tx power (max regulatory) +20 dBm (100 mW, FCC Part 15)
|
||
Tx antenna gain (PCB) +2 dBi
|
||
Rx antenna gain (PCB) +2 dBi
|
||
Rx sensitivity (HT20, MCS0) -97 dBm
|
||
Total link budget (free-space) = (20 + 2 + 2) - (-97) = 121 dB
|
||
"""
|
||
return {
|
||
"tx_power_dbm": 20.0,
|
||
"tx_gain_dbi": 2.0,
|
||
"rx_gain_dbi": 2.0,
|
||
"rx_sensitivity_dbm": -97.0,
|
||
"link_budget_db": 121.0,
|
||
}
|
||
|
||
|
||
def fspl_db(freq_ghz: float, distance_m: float) -> float:
|
||
"""Free-space path loss in dB. FSPL = 20·log10(4π·d/λ)
|
||
With f in GHz + d in m: FSPL = 32.45 + 20·log10(f) + 20·log10(d)"""
|
||
if distance_m <= 0: return 0.0
|
||
return 32.45 + 20 * np.log10(freq_ghz) + 20 * np.log10(distance_m)
|
||
|
||
|
||
def max_sensing_range(freq_ghz: float, foliage_density: str, snr_margin_db: float = 10.0) -> float:
|
||
"""Distance at which FSPL + foliage_attenuation = link_budget - snr_margin.
|
||
Numerical solve by binary search. Returns metres."""
|
||
lb = esp32_link_budget(freq_ghz)
|
||
budget = lb["link_budget_db"] - snr_margin_db # require SNR > snr_margin
|
||
lo, hi = 0.1, 1000.0
|
||
for _ in range(60):
|
||
mid = (lo + hi) / 2
|
||
total_loss = fspl_db(freq_ghz, mid) + itu_p833_attenuation(freq_ghz, mid, foliage_density)
|
||
if total_loss < budget:
|
||
lo = mid
|
||
else:
|
||
hi = mid
|
||
return (lo + hi) / 2
|
||
|
||
|
||
def gait_frequency_band(species: str) -> dict[str, float]:
|
||
"""Approximate gait stride-frequency bands per species class, from
|
||
biomechanics literature (Schmitt 2003, Gambaryan 1974, Heglund 1988).
|
||
These are the temporal frequencies a CSI motion-band filter would
|
||
target — for context, human walking is ~1.7 Hz, jogging ~2.5 Hz."""
|
||
bands = {
|
||
"human-walking": {"min_hz": 1.2, "max_hz": 2.5},
|
||
"deer": {"min_hz": 1.8, "max_hz": 4.0},
|
||
"wolf": {"min_hz": 1.5, "max_hz": 3.5},
|
||
"bear": {"min_hz": 0.5, "max_hz": 1.5},
|
||
"fox": {"min_hz": 2.0, "max_hz": 4.5},
|
||
"squirrel": {"min_hz": 4.0, "max_hz": 10.0},
|
||
"mouse": {"min_hz": 5.0, "max_hz": 15.0},
|
||
"raccoon": {"min_hz": 1.5, "max_hz": 3.5},
|
||
"wild-boar": {"min_hz": 1.0, "max_hz": 2.5},
|
||
"elk": {"min_hz": 1.5, "max_hz": 3.0},
|
||
}
|
||
return bands.get(species, {"min_hz": 0.5, "max_hz": 10.0})
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--out", default="examples/research-sota/r10_foliage_results.json")
|
||
args = parser.parse_args()
|
||
|
||
distances = np.array([1, 2, 5, 10, 20, 50, 100, 200], dtype=np.float64)
|
||
freqs = [2.4, 5.0]
|
||
densities = ["sparse", "moderate", "dense"]
|
||
|
||
curves = {}
|
||
for freq in freqs:
|
||
curves[str(freq)] = {}
|
||
for density in densities:
|
||
atts = [float(itu_p833_attenuation(freq, d, density)) for d in distances]
|
||
fspls = [float(fspl_db(freq, d)) for d in distances]
|
||
curves[str(freq)][density] = {
|
||
"distance_m": distances.tolist(),
|
||
"foliage_attenuation_db": atts,
|
||
"fspl_db": fspls,
|
||
"total_loss_db": [a + f for a, f in zip(atts, fspls)],
|
||
}
|
||
|
||
# Max sensing range per (freq, density)
|
||
max_ranges = {}
|
||
for freq in freqs:
|
||
max_ranges[str(freq)] = {d: float(max_sensing_range(freq, d)) for d in densities}
|
||
|
||
species_gaits = {s: gait_frequency_band(s) for s in
|
||
["human-walking", "deer", "wolf", "bear", "fox",
|
||
"squirrel", "mouse", "raccoon", "wild-boar", "elk"]}
|
||
|
||
out = {
|
||
"model": "ITU-R P.833-9 specific-attenuation + free-space-path-loss",
|
||
"link_budget": esp32_link_budget(2.4),
|
||
"snr_margin_db": 10.0,
|
||
"curves": curves,
|
||
"max_sensing_range_m": max_ranges,
|
||
"species_gait_bands_hz": species_gaits,
|
||
}
|
||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||
|
||
print("=== ESP32-S3 through-foliage sensing range (link budget 121 dB, 10 dB SNR margin) ===")
|
||
print(f"{'freq (GHz)':>10} {'sparse':>9} {'moderate':>11} {'dense':>9}")
|
||
for freq in freqs:
|
||
row = f"{freq:>10.1f} "
|
||
for d in densities:
|
||
row += f"{max_ranges[str(freq)][d]:>9.1f}m " if d != "moderate" else f"{max_ranges[str(freq)][d]:>11.1f}m "
|
||
print(row)
|
||
print()
|
||
print("=== Per-species gait frequency bands (Hz) ===")
|
||
for s, b in species_gaits.items():
|
||
print(f" {s:<16} {b['min_hz']:.1f} - {b['max_hz']:.1f} Hz")
|
||
print()
|
||
print(f"Wrote {args.out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|