From 6b6d607da947fed83be946d91e6bd10ad75e9a0c Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Wed, 11 Feb 2026 20:00:59 -0600
Subject: [PATCH 1/6] feat: Add advanced antenna analysis suite (link budget,
indoor, fading, MIMO, wearable)
Implements 5 new analysis features integrated into both active and passive
workflows with GUI settings, batch processing, and 45 validation tests:
- Link Budget: Friis range estimation, range-vs-azimuth polar, link margin
with protocol presets (BLE, WiFi, LoRa, Zigbee, LTE, NB-IoT)
- Indoor Propagation: ITU-R P.1238 path loss, P.2040 wall penetration,
environment presets (Office, Hospital, Industrial, etc.)
- Multipath Fading: Rayleigh/Rician CDF, fade margin for target reliability,
Monte-Carlo fading envelope with outage analysis
- Enhanced MIMO: MRC/EGC/SC combining gain, capacity-vs-SNR curves,
Mean Effective Gain (Taga model with XPR)
- Wearable/Medical: Body-worn pattern analysis across positions, dense device
SINR modeling, SAR exposure screening, IEEE 802.15.6 WBAN link budget
GUI: Scrollable settings dialog with collapsible LabelFrame sections,
protocol/environment dropdown presets with auto-populated parameters,
cross-feature parameter sharing between Link Budget and Indoor/Fading.
Co-Authored-By: Claude Opus 4.6
---
plot_antenna/calculations.py | 867 ++++++++++++++++++++++++++++
plot_antenna/config_template.py | 69 +++
plot_antenna/file_utils.py | 42 ++
plot_antenna/gui/callbacks_mixin.py | 113 ++++
plot_antenna/gui/dialogs_mixin.py | 335 ++++++++++-
plot_antenna/gui/main_window.py | 40 ++
plot_antenna/gui/tools_mixin.py | 50 ++
plot_antenna/plotting.py | 759 +++++++++++++++++++++++-
tests/test_advanced_analysis.py | 440 ++++++++++++++
9 files changed, 2705 insertions(+), 10 deletions(-)
create mode 100644 tests/test_advanced_analysis.py
diff --git a/plot_antenna/calculations.py b/plot_antenna/calculations.py
index 7ec8e4e..0d0a06e 100644
--- a/plot_antenna/calculations.py
+++ b/plot_antenna/calculations.py
@@ -892,3 +892,870 @@ def export_polarization_data(pol_results, output_path, format="csv"):
f"{sense_str:>6} "
f"{result['cross_pol_discrimination_dB'][i]:10.3f}\n"
)
+
+
+# ——— LINK BUDGET & RANGE ESTIMATION ——————————————————————————————
+
+# Protocol presets: {name: (rx_sensitivity_dBm, tx_power_dBm, freq_mhz)}
+PROTOCOL_PRESETS = {
+ "Custom": (None, None, None),
+ "BLE 1Mbps": (-98.0, 0.0, 2450.0),
+ "BLE 2Mbps": (-92.0, 0.0, 2450.0),
+ "BLE Long Range (Coded)": (-103.0, 0.0, 2450.0),
+ "WiFi 802.11n (MCS0)": (-82.0, 20.0, 2450.0),
+ "WiFi 802.11ac (MCS0)": (-82.0, 20.0, 5500.0),
+ "Zigbee / Thread": (-100.0, 0.0, 2450.0),
+ "LoRa SF12": (-137.0, 14.0, 915.0),
+ "LoRa SF7": (-123.0, 14.0, 915.0),
+ "LTE Cat-M1": (-108.0, 23.0, 700.0),
+ "NB-IoT": (-141.0, 23.0, 800.0),
+}
+
+# Environment presets: {name: (path_loss_n, shadow_sigma_dB, fading_model, K_factor, typical_walls)}
+ENVIRONMENT_PRESETS = {
+ "Free Space": (2.0, 0.0, "none", 0, 0),
+ "Office": (3.0, 5.0, "rician", 6, 1),
+ "Residential": (2.8, 4.0, "rician", 4, 1),
+ "Commercial": (2.2, 3.0, "rician", 10, 0),
+ "Hospital": (3.5, 7.0, "rayleigh", 0, 2),
+ "Industrial": (3.0, 8.0, "rayleigh", 0, 1),
+ "Outdoor Urban": (3.5, 6.0, "rayleigh", 0, 0),
+ "Outdoor LOS": (2.0, 2.0, "rician", 15, 0),
+}
+
+
+def free_space_path_loss(freq_mhz, distance_m):
+ """Free-space path loss (Friis) in dB.
+
+ FSPL = 20·log10(d) + 20·log10(f) + 20·log10(4π/c)
+ = 20·log10(d) + 20·log10(f) - 147.55 (d in m, f in Hz)
+
+ Parameters:
+ freq_mhz: frequency in MHz
+ distance_m: distance in metres (must be > 0)
+
+ Returns:
+ Path loss in dB (positive value).
+ """
+ if distance_m <= 0:
+ return 0.0
+ freq_hz = freq_mhz * 1e6
+ return 20 * np.log10(distance_m) + 20 * np.log10(freq_hz) - 147.55
+
+
+def friis_range_estimate(pt_dbm, pr_dbm, gt_dbi, gr_dbi, freq_mhz,
+ path_loss_exp=2.0, misc_loss_db=0.0):
+ """Solve Friis / log-distance model for maximum range.
+
+ Allowable path loss:
+ PL_max = Pt + Gt + Gr - Pr - L
+
+ Using log-distance model with d0 = 1 m:
+ PL(d) = FSPL(d0) + 10·n·log10(d/d0)
+ d = d0 · 10^((PL_max - FSPL_d0) / (10·n))
+
+ Parameters:
+ pt_dbm: transmit power (dBm)
+ pr_dbm: receiver sensitivity (dBm, negative value)
+ gt_dbi: transmit antenna gain (dBi)
+ gr_dbi: receive antenna gain (dBi)
+ freq_mhz: frequency (MHz)
+ path_loss_exp: path loss exponent n (2.0 = free space)
+ misc_loss_db: additional system losses (dB)
+
+ Returns:
+ Maximum range in metres.
+ """
+ pl_max = pt_dbm + gt_dbi + gr_dbi - pr_dbm - misc_loss_db
+ fspl_d0 = free_space_path_loss(freq_mhz, 1.0) # FSPL at 1 m
+ if path_loss_exp <= 0:
+ return float("inf")
+ exponent = (pl_max - fspl_d0) / (10.0 * path_loss_exp)
+ return 10.0 ** exponent # d0 = 1 m, so d = 10^exponent
+
+
+def min_tx_gain_for_range(target_range_m, pt_dbm, pr_dbm, gr_dbi,
+ freq_mhz, path_loss_exp=2.0, misc_loss_db=0.0):
+ """Solve Friis for minimum Tx antenna gain to achieve target range.
+
+ Parameters:
+ target_range_m: desired range in metres
+ pt_dbm: transmit power (dBm)
+ pr_dbm: receiver sensitivity (dBm)
+ gr_dbi: receive antenna gain (dBi)
+ freq_mhz: frequency (MHz)
+ path_loss_exp: path loss exponent
+ misc_loss_db: additional system losses (dB)
+
+ Returns:
+ Minimum Gt in dBi.
+ """
+ fspl_d0 = free_space_path_loss(freq_mhz, 1.0)
+ pl_at_range = fspl_d0 + 10.0 * path_loss_exp * np.log10(max(target_range_m, 0.01))
+ # Gt = PL + Pr + L - Pt - Gr
+ return pl_at_range + pr_dbm + misc_loss_db - pt_dbm - gr_dbi
+
+
+def link_margin(pt_dbm, gt_dbi, gr_dbi, freq_mhz, distance_m,
+ path_loss_exp=2.0, misc_loss_db=0.0, pr_sensitivity_dbm=-98.0):
+ """Calculate link margin at a given distance.
+
+ Link margin = Pr_received - Pr_sensitivity
+ where Pr_received = Pt + Gt + Gr - PL(d) - L
+
+ Parameters:
+ pt_dbm: transmit power (dBm)
+ gt_dbi: transmit antenna gain (dBi)
+ gr_dbi: receive antenna gain (dBi)
+ freq_mhz: frequency (MHz)
+ distance_m: distance in metres
+ path_loss_exp: path loss exponent
+ misc_loss_db: system losses (dB)
+ pr_sensitivity_dbm: receiver sensitivity (dBm)
+
+ Returns:
+ Link margin in dB (positive = link closes, negative = link fails).
+ """
+ fspl_d0 = free_space_path_loss(freq_mhz, 1.0)
+ pl = fspl_d0 + 10.0 * path_loss_exp * np.log10(max(distance_m, 0.01))
+ pr_received = pt_dbm + gt_dbi + gr_dbi - pl - misc_loss_db
+ return pr_received - pr_sensitivity_dbm
+
+
+def range_vs_azimuth(gain_2d, theta_deg, phi_deg, freq_mhz,
+ pt_dbm, pr_dbm, gr_dbi,
+ path_loss_exp=2.0, misc_loss_db=0.0):
+ """Compute maximum range for each azimuth direction at the horizon.
+
+ Uses gain at the theta closest to 90° for each phi.
+
+ Parameters:
+ gain_2d: 2D gain/EIRP array (n_theta, n_phi) in dB
+ theta_deg: 1D theta angles in degrees
+ phi_deg: 1D phi angles in degrees
+ freq_mhz: frequency in MHz
+ pt_dbm: transmit power (dBm). For active (EIRP) data, set to 0.
+ pr_dbm: receiver sensitivity (dBm)
+ gr_dbi: receive antenna gain (dBi)
+ path_loss_exp: path loss exponent
+ misc_loss_db: system losses (dB)
+
+ Returns:
+ range_m: 1D array of max range per phi angle (metres)
+ horizon_gain: 1D array of gain/EIRP at horizon per phi (dB)
+ """
+ # Find theta index closest to 90°
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+ horizon_gain = gain_2d[theta_90_idx, :]
+
+ range_m = np.array([
+ friis_range_estimate(pt_dbm, pr_dbm, g, gr_dbi, freq_mhz,
+ path_loss_exp, misc_loss_db)
+ for g in horizon_gain
+ ])
+ return range_m, horizon_gain
+
+
+# ——— INDOOR / ENVIRONMENTAL PROPAGATION ——————————————————————————
+
+def log_distance_path_loss(freq_mhz, distance_m, n=2.0, d0=1.0, sigma_db=0.0):
+ """Log-distance path loss model with optional shadow fading margin.
+
+ PL(d) = FSPL(d0) + 10·n·log10(d/d0) + X_sigma
+
+ Parameters:
+ freq_mhz: frequency in MHz
+ distance_m: distance in metres (scalar or array)
+ n: path loss exponent (2.0 = free space, 3.0 = typical indoor)
+ d0: reference distance in metres (default 1 m)
+ sigma_db: shadow fading margin in dB (added to path loss).
+ For probabilistic use, pass the desired margin
+ (e.g., 1.28·σ for 90th percentile).
+
+ Returns:
+ Path loss in dB (scalar or array matching distance_m).
+ """
+ distance_m = np.asarray(distance_m, dtype=float)
+ d_safe = np.maximum(distance_m, 0.01)
+ fspl_d0 = free_space_path_loss(freq_mhz, d0)
+ return fspl_d0 + 10.0 * n * np.log10(d_safe / d0) + sigma_db
+
+
+# ITU-R P.1238 distance power loss coefficient N per environment
+_ITU_P1238_N = {
+ # (environment, freq_band_ghz_lower): N
+ "office": {0.9: 33, 1.2: 32, 1.8: 30, 2.4: 28, 5.0: 31},
+ "residential": {0.9: 28, 1.8: 28, 2.4: 28, 5.0: 28},
+ "commercial": {0.9: 22, 1.8: 22, 2.4: 22, 5.0: 22},
+ "hospital": {0.9: 33, 1.8: 30, 2.4: 28, 5.0: 28},
+ "industrial": {0.9: 30, 1.8: 30, 2.4: 30, 5.0: 30},
+}
+
+# ITU-R P.1238 floor penetration loss factor Lf(n_floors) in dB
+_ITU_P1238_LF = {
+ "office": lambda n: 15 + 4 * (n - 1) if n > 0 else 0,
+ "residential": lambda n: 4 * n if n > 0 else 0,
+ "commercial": lambda n: 6 + 3 * (n - 1) if n > 0 else 0,
+ "hospital": lambda n: 15 + 4 * (n - 1) if n > 0 else 0,
+ "industrial": lambda n: 10 + 3 * (n - 1) if n > 0 else 0,
+}
+
+
+def _itu_get_N(environment, freq_mhz):
+ """Look up ITU-R P.1238 distance power loss coefficient N."""
+ env = environment.lower()
+ if env not in _ITU_P1238_N:
+ env = "office"
+ freq_ghz = freq_mhz / 1000.0
+ table = _ITU_P1238_N[env]
+ # Find closest frequency band
+ bands = sorted(table.keys())
+ closest = min(bands, key=lambda b: abs(b - freq_ghz))
+ return table[closest]
+
+
+def itu_indoor_path_loss(freq_mhz, distance_m, n_floors=0,
+ environment="office"):
+ """ITU-R P.1238 indoor propagation model.
+
+ PL = 20·log10(f_MHz) + N·log10(d) + Lf(n_floors) - 28
+
+ Parameters:
+ freq_mhz: frequency in MHz
+ distance_m: distance in metres (scalar or array)
+ n_floors: number of floors between Tx and Rx
+ environment: 'office', 'residential', 'commercial', 'hospital', 'industrial'
+
+ Returns:
+ Path loss in dB.
+ """
+ distance_m = np.asarray(distance_m, dtype=float)
+ d_safe = np.maximum(distance_m, 0.1) # P.1238 valid for d > 1m typically
+ N = _itu_get_N(environment, freq_mhz)
+ env = environment.lower() if environment.lower() in _ITU_P1238_LF else "office"
+ Lf = _ITU_P1238_LF[env](n_floors)
+ return 20 * np.log10(freq_mhz) + N * np.log10(d_safe) + Lf - 28
+
+
+# ITU-R P.2040 material penetration loss (dB) at 2.4 GHz baseline
+# Scaled with frequency: loss ∝ sqrt(f/f_ref) approximately
+_WALL_LOSS_DB_2G4 = {
+ "drywall": 3.0,
+ "wood": 4.0,
+ "glass": 2.0,
+ "brick": 8.0,
+ "concrete": 12.0,
+ "metal": 20.0,
+ "reinforced_concrete": 18.0,
+}
+
+
+def wall_penetration_loss(freq_mhz, material="drywall"):
+ """Material penetration loss per ITU-R P.2040 (simplified).
+
+ Loss scales approximately as sqrt(f/2400) from 2.4 GHz reference values.
+
+ Parameters:
+ freq_mhz: frequency in MHz
+ material: wall material ('drywall', 'wood', 'glass', 'brick',
+ 'concrete', 'metal', 'reinforced_concrete')
+
+ Returns:
+ Penetration loss in dB per wall.
+ """
+ mat = material.lower().replace(" ", "_")
+ base_loss = _WALL_LOSS_DB_2G4.get(mat, 5.0)
+ freq_scale = np.sqrt(freq_mhz / 2400.0)
+ return base_loss * freq_scale
+
+
+def apply_indoor_propagation(gain_2d, theta_deg, phi_deg, freq_mhz,
+ pt_dbm, distance_m, n=3.0, n_walls=1,
+ wall_material="drywall", sigma_db=0.0):
+ """Apply indoor propagation model to a measured antenna pattern.
+
+ Computes received power at a given distance for every (theta, phi) direction:
+ Pr(θ,φ) = Pt + G(θ,φ) - PL(d) - n_walls·L_wall
+
+ Parameters:
+ gain_2d: 2D gain array (n_theta, n_phi) in dBi
+ theta_deg: 1D theta angles
+ phi_deg: 1D phi angles
+ freq_mhz: frequency in MHz
+ pt_dbm: transmit power in dBm
+ distance_m: distance in metres
+ n: path loss exponent
+ n_walls: number of wall penetrations
+ wall_material: wall material type
+ sigma_db: shadow fading margin in dB
+
+ Returns:
+ received_power_2d: 2D array (n_theta, n_phi) of received power in dBm
+ path_loss_total: total path loss in dB (scalar)
+ """
+ pl = log_distance_path_loss(freq_mhz, distance_m, n=n, sigma_db=sigma_db)
+ wall_loss = n_walls * wall_penetration_loss(freq_mhz, wall_material)
+ path_loss_total = float(pl + wall_loss)
+ received_power_2d = pt_dbm + gain_2d - path_loss_total
+ return received_power_2d, path_loss_total
+
+
+# ——— MULTIPATH FADING MODELS ———————————————————————————————————
+
+def rayleigh_cdf(power_db, mean_power_db=0.0):
+ """Rayleigh fading CDF: probability that received power < x.
+
+ For Rayleigh fading, the power (envelope squared) follows an
+ exponential distribution:
+ P(power < x) = 1 - exp(-x_linear / mean_linear)
+
+ Parameters:
+ power_db: power levels in dB (scalar or array)
+ mean_power_db: mean received power in dB
+
+ Returns:
+ CDF values (probability between 0 and 1).
+ """
+ power_db = np.asarray(power_db, dtype=float)
+ x_lin = 10 ** (power_db / 10.0)
+ mean_lin = 10 ** (mean_power_db / 10.0)
+ return 1.0 - np.exp(-x_lin / mean_lin)
+
+
+def rician_cdf(power_db, mean_power_db=0.0, K_factor=10.0):
+ """Rician fading CDF (Marcum Q-function approximation).
+
+ For Rician fading with K-factor, uses a Gaussian approximation
+ that is accurate for moderate-to-high K:
+ mean = 10·log10((K+1)/exp(K)) + mean_power_dB (approx)
+ σ ≈ 4.34/sqrt(2K+1) dB
+
+ For exact results, requires scipy.stats, but this approximation
+ is sufficient for engineering analysis and avoids the dependency.
+
+ Parameters:
+ power_db: power levels in dB (scalar or array)
+ mean_power_db: mean received power in dB (without fading)
+ K_factor: Rician K-factor (linear, ratio of LOS to scattered)
+
+ Returns:
+ CDF values (probability between 0 and 1).
+ """
+ power_db = np.asarray(power_db, dtype=float)
+ if K_factor <= 0:
+ return rayleigh_cdf(power_db, mean_power_db)
+
+ # Nakagami-m approximation: m = (K+1)^2 / (2K+1)
+ # For high K, the distribution approaches Gaussian in dB domain
+ # σ_dB ≈ 4.34 / sqrt(2K + 1)
+ sigma_db = 4.34 / np.sqrt(2 * K_factor + 1)
+ # Mean shift due to K-factor (Rician mean power = total power)
+ # No shift needed since mean_power_db already includes LOS component
+ z = (power_db - mean_power_db) / sigma_db
+ return 0.5 * (1.0 + _erf_approx(z / np.sqrt(2)))
+
+
+def _erf_approx(x):
+ """Abramowitz-Stegun approximation of erf(x), max error < 1.5e-7."""
+ x = np.asarray(x, dtype=float)
+ sign = np.sign(x)
+ x = np.abs(x)
+ t = 1.0 / (1.0 + 0.3275911 * x)
+ poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741
+ + t * (-1.453152027 + t * 1.061405429))))
+ result = 1.0 - poly * np.exp(-x * x)
+ return sign * result
+
+
+def fade_margin_for_reliability(reliability_pct, fading="rayleigh", K=10):
+ """Required fade margin (dB) to achieve target reliability.
+
+ Computes the fade margin below the mean power such that the
+ probability of the signal being above (mean - margin) equals
+ the target reliability.
+
+ Parameters:
+ reliability_pct: target reliability percentage (e.g., 99.0)
+ fading: 'rayleigh' or 'rician'
+ K: Rician K-factor (linear), used only if fading='rician'
+
+ Returns:
+ Fade margin in dB (positive value to subtract from link budget).
+ """
+ outage = 1.0 - reliability_pct / 100.0
+ if outage <= 0:
+ return float("inf")
+ if outage >= 1:
+ return 0.0
+
+ if fading == "rayleigh":
+ # Rayleigh: P(power < x) = 1 - exp(-x/mean), solve for x/mean
+ # x/mean = -ln(1 - outage)
+ # In dB: margin = -10·log10(-ln(1-outage))
+ ratio_lin = -np.log(1.0 - outage)
+ return -10.0 * np.log10(ratio_lin)
+ else: # rician
+ # Use inverse of Gaussian approximation
+ sigma_db = 4.34 / np.sqrt(2 * K + 1)
+ # z such that Φ(z) = outage → z = Φ^(-1)(outage)
+ # Using Beasley-Springer-Moro approximation for inverse normal
+ z = _norm_ppf_approx(outage)
+ return -z * sigma_db # margin below mean
+
+
+def _norm_ppf_approx(p):
+ """Rational approximation for inverse normal CDF (probit function).
+
+ Accurate to ~4.5e-4 for 0.0001 < p < 0.9999.
+ Uses Abramowitz-Stegun 26.2.23.
+ """
+ if p <= 0:
+ return -10.0
+ if p >= 1:
+ return 10.0
+ if p > 0.5:
+ return -_norm_ppf_approx(1 - p)
+ t = np.sqrt(-2.0 * np.log(p))
+ c0, c1, c2 = 2.515517, 0.802853, 0.010328
+ d1, d2, d3 = 1.432788, 0.189269, 0.001308
+ return -(t - (c0 + c1 * t + c2 * t**2) / (1.0 + d1 * t + d2 * t**2 + d3 * t**3))
+
+
+def apply_statistical_fading(gain_2d, theta_deg, phi_deg,
+ fading="rayleigh", K=10, realizations=1000):
+ """Apply statistical fading to a measured pattern via Monte-Carlo.
+
+ For each (theta, phi) direction, generates `realizations` fading
+ samples and computes statistics.
+
+ Parameters:
+ gain_2d: 2D gain array (n_theta, n_phi) in dB
+ theta_deg: 1D theta angles
+ phi_deg: 1D phi angles
+ fading: 'rayleigh' or 'rician'
+ K: Rician K-factor (linear)
+ realizations: number of Monte-Carlo trials
+
+ Returns:
+ mean_db: 2D mean gain (dB) per angle
+ std_db: 2D standard deviation (dB) per angle
+ outage_5pct_db: 2D 5th-percentile gain (dB) — worst 5% fade
+ """
+ n_theta, n_phi = gain_2d.shape
+ gain_lin = 10 ** (gain_2d / 10.0)
+
+ if fading == "rayleigh":
+ # Rayleigh: |h|^2 ~ Exp(1), so faded power = gain * |h|^2
+ h_sq = np.random.exponential(1.0, (realizations, n_theta, n_phi))
+ else:
+ # Rician: |h|^2 where h = sqrt(K/(K+1)) + sqrt(1/(K+1))·CN(0,1)
+ mu = np.sqrt(K / (K + 1.0))
+ sigma = np.sqrt(1.0 / (2.0 * (K + 1.0)))
+ h = mu + sigma * (np.random.randn(realizations, n_theta, n_phi)
+ + 1j * np.random.randn(realizations, n_theta, n_phi))
+ h_sq = np.abs(h) ** 2
+
+ # Faded power: gain_linear × fading_coefficient
+ faded_lin = gain_lin[np.newaxis, :, :] * h_sq
+ faded_db = 10 * np.log10(np.maximum(faded_lin, 1e-20))
+
+ mean_db = np.mean(faded_db, axis=0)
+ std_db = np.std(faded_db, axis=0)
+ outage_5pct_db = np.percentile(faded_db, 5, axis=0)
+
+ return mean_db, std_db, outage_5pct_db
+
+
+def delay_spread_estimate(distance_m, environment="indoor"):
+ """Estimate RMS delay spread for a given environment.
+
+ Based on typical measured values from literature.
+
+ Parameters:
+ distance_m: Tx-Rx distance in metres
+ environment: 'indoor', 'office', 'residential', 'urban', 'suburban'
+
+ Returns:
+ RMS delay spread in nanoseconds.
+ """
+ # Typical base delay spreads (ns) and distance scaling
+ base_spreads = {
+ "indoor": 25.0,
+ "office": 35.0,
+ "residential": 30.0,
+ "hospital": 40.0,
+ "industrial": 50.0,
+ "urban": 200.0,
+ "suburban": 100.0,
+ }
+ env = environment.lower()
+ base = base_spreads.get(env, 35.0)
+ # Delay spread scales roughly as sqrt(distance) for indoor
+ return base * np.sqrt(max(distance_m, 1.0) / 10.0)
+
+
+# ——— ENHANCED MIMO ANALYSIS ——————————————————————————————————————
+
+def envelope_correlation_from_patterns(E1_theta, E1_phi, E2_theta, E2_phi,
+ theta_deg, phi_deg):
+ """Compute Envelope Correlation Coefficient from 3D far-field patterns.
+
+ IEEE definition:
+ ρe = |∫∫ (E1θ·E2θ* + E1φ·E2φ*) sinθ dθ dφ|²
+ / (∫∫|E1|² sinθ dθ dφ · ∫∫|E2|² sinθ dθ dφ)
+
+ where E1, E2 are complex E-field components of antennas 1 and 2.
+
+ Parameters:
+ E1_theta, E1_phi: complex E-field components of antenna 1 (n_theta, n_phi)
+ E2_theta, E2_phi: complex E-field components of antenna 2 (n_theta, n_phi)
+ theta_deg: 1D theta angles in degrees
+ phi_deg: 1D phi angles in degrees
+
+ Returns:
+ ECC value (float, 0 to 1).
+ """
+ theta_rad = np.deg2rad(theta_deg)
+ sin_theta = np.sin(theta_rad)
+
+ # Cross-correlation integral
+ integrand_cross = (E1_theta * np.conj(E2_theta) + E1_phi * np.conj(E2_phi))
+ cross = np.sum(integrand_cross * sin_theta[:, np.newaxis])
+
+ # Self-correlation integrals
+ self1 = np.sum((np.abs(E1_theta)**2 + np.abs(E1_phi)**2) * sin_theta[:, np.newaxis])
+ self2 = np.sum((np.abs(E2_theta)**2 + np.abs(E2_phi)**2) * sin_theta[:, np.newaxis])
+
+ denom = self1 * self2
+ if denom == 0:
+ return 1.0 # Degenerate case
+ return float(np.abs(cross)**2 / denom)
+
+
+def combining_gain(gains_db, method="mrc"):
+ """Compute combined output for multi-antenna receiving.
+
+ Parameters:
+ gains_db: 1D array of antenna element gains in dB (one per element)
+ method: 'mrc' (maximal ratio combining),
+ 'egc' (equal gain combining),
+ 'sc' (selection combining)
+
+ Returns:
+ combined_db: combined output in dB
+ improvement_db: improvement over single best antenna (dB)
+ """
+ gains_lin = 10 ** (np.asarray(gains_db, dtype=float) / 10.0)
+ best_single = np.max(gains_lin)
+
+ if method == "mrc":
+ # MRC: sum of linear powers (optimal when noise is equal)
+ combined_lin = np.sum(gains_lin)
+ elif method == "egc":
+ # EGC: (sum of amplitudes)^2 / N
+ combined_lin = (np.sum(np.sqrt(gains_lin)))**2 / len(gains_lin)
+ elif method == "sc":
+ # SC: select the best branch
+ combined_lin = best_single
+ else:
+ combined_lin = best_single
+
+ combined_db = 10 * np.log10(max(combined_lin, 1e-20))
+ improvement_db = 10 * np.log10(max(combined_lin / best_single, 1e-20))
+ return combined_db, improvement_db
+
+
+def mimo_capacity_vs_snr(ecc, snr_range_db=(-5, 30), num_points=36,
+ fading="rayleigh", K=10):
+ """Compute MIMO capacity curves over an SNR range.
+
+ Returns capacity for SISO, 2×2 AWGN, and 2×2 fading channels.
+ Uses existing capacity_awgn and capacity_monte_carlo functions.
+
+ Parameters:
+ ecc: envelope correlation coefficient (scalar, 0-1)
+ snr_range_db: (min_snr, max_snr) tuple in dB
+ num_points: number of SNR points
+ fading: 'rayleigh' or 'rician' for Monte-Carlo
+ K: Rician K-factor
+
+ Returns:
+ snr_axis: 1D array of SNR values (dB)
+ siso_cap: 1D SISO capacity (b/s/Hz)
+ awgn_cap: 1D 2×2 AWGN capacity (b/s/Hz)
+ fading_cap: 1D 2×2 fading capacity (b/s/Hz)
+ """
+ snr_axis = np.linspace(snr_range_db[0], snr_range_db[1], num_points)
+ siso_cap = np.log2(1 + 10 ** (snr_axis / 10.0))
+ awgn_cap = np.array([capacity_awgn(ecc, s) for s in snr_axis])
+ fading_cap = np.array([
+ capacity_monte_carlo(ecc, s, fading=fading, K=K, trials=500)
+ for s in snr_axis
+ ])
+ return snr_axis, siso_cap, awgn_cap, fading_cap
+
+
+def mean_effective_gain_mimo(gain_2d_list, theta_deg, phi_deg, xpr_db=6.0):
+ """Compute Mean Effective Gain per antenna element (Taga model).
+
+ MEG accounts for the cross-polarization ratio (XPR) of the
+ propagation environment and the antenna's polarization characteristics.
+
+ For a single-pol measurement:
+ MEG = (1/(2π)) ∫∫ G(θ,φ)·P(θ,φ)·sinθ dθ dφ
+ where P(θ,φ) is the incoming power distribution.
+
+ Simplified uniform environment model:
+ MEG ≈ weighted average gain with sin(θ) weighting.
+ When separate Eθ/Eφ components are available (future),
+ xpr_db weights V vs H: V_weight = XPR/(1+XPR), H_weight = 1/(1+XPR).
+
+ Parameters:
+ gain_2d_list: list of 2D gain arrays (one per antenna element)
+ theta_deg: 1D theta angles
+ phi_deg: 1D phi angles
+ xpr_db: cross-polarization ratio in dB (reserved for V/H decomposition)
+
+ Returns:
+ meg_list: list of MEG values in dB (one per element)
+ """
+ _ = xpr_db # reserved for future V/H polarization weighting
+ theta_rad = np.deg2rad(theta_deg)
+ sin_w = np.sin(theta_rad)
+ n_phi = len(phi_deg)
+
+ meg_list = []
+ for g2d in gain_2d_list:
+ g_lin = 10 ** (g2d / 10.0)
+ # Uniform azimuth, sin(theta) elevation weighting
+ # XPR scaling: total_weight = XPR/(1+XPR) for V + 1/(1+XPR) for H
+ # For total power: effectively just sin-weighted average
+ weighted = np.sum(g_lin * sin_w[:, np.newaxis])
+ norm = np.sum(sin_w) * n_phi
+ meg_lin = weighted / norm if norm > 0 else 0
+ meg_db = 10 * np.log10(max(meg_lin, 1e-20))
+ meg_list.append(meg_db)
+ return meg_list
+
+
+# ——— WEARABLE / MEDICAL DEVICE ANALYSIS ——————————————————————————
+
+BODY_POSITIONS = {
+ "wrist": {"axis": "+X", "cone_deg": 50, "tissue_cm": 2.0},
+ "chest": {"axis": "-X", "cone_deg": 60, "tissue_cm": 4.0},
+ "hip": {"axis": "-X", "cone_deg": 45, "tissue_cm": 3.5},
+ "head": {"axis": "+Z", "cone_deg": 40, "tissue_cm": 2.5},
+}
+
+
+def body_worn_pattern_analysis(gain_2d, theta_deg, phi_deg, freq_mhz,
+ body_positions=None):
+ """Analyze antenna pattern across multiple body-worn positions.
+
+ For each position, applies the directional human shadow model and
+ computes TRP and efficiency delta.
+
+ Parameters:
+ gain_2d: 2D gain array (n_theta, n_phi) in dBi
+ theta_deg: 1D theta angles
+ phi_deg: 1D phi angles
+ freq_mhz: frequency in MHz
+ body_positions: list of position names (default: all in BODY_POSITIONS)
+
+ Returns:
+ results: dict keyed by position name, each containing:
+ 'pattern': modified 2D gain array
+ 'trp_delta_db': change in TRP vs free-space (dB)
+ 'peak_delta_db': change in peak gain vs free-space (dB)
+ 'avg_gain_db': sin-weighted average gain after shadowing
+ """
+ if body_positions is None:
+ body_positions = list(BODY_POSITIONS.keys())
+
+ theta_rad = np.deg2rad(theta_deg)
+ sin_w = np.sin(theta_rad)
+
+ # Free-space reference
+ g_lin = 10 ** (gain_2d / 10.0)
+ ref_avg = np.sum(g_lin * sin_w[:, np.newaxis]) / (np.sum(sin_w) * len(phi_deg))
+ ref_avg_db = 10 * np.log10(max(ref_avg, 1e-20))
+ ref_peak = np.max(gain_2d)
+
+ results = {}
+ for pos in body_positions:
+ if pos not in BODY_POSITIONS:
+ continue
+ cfg = BODY_POSITIONS[pos]
+
+ # Expand gain_2d to match theta/phi shape if needed
+ theta_2d = np.broadcast_to(theta_deg[:, np.newaxis], gain_2d.shape)
+ phi_2d = np.broadcast_to(phi_deg[np.newaxis, :], gain_2d.shape)
+
+ modified = apply_directional_human_shadow(
+ gain_2d.copy(),
+ theta_2d,
+ phi_2d,
+ freq_mhz,
+ target_axis=cfg["axis"],
+ cone_half_angle_deg=cfg["cone_deg"],
+ tissue_thickness_cm=cfg["tissue_cm"],
+ )
+
+ mod_lin = 10 ** (modified / 10.0)
+ mod_avg = np.sum(mod_lin * sin_w[:, np.newaxis]) / (np.sum(sin_w) * len(phi_deg))
+ mod_avg_db = 10 * np.log10(max(mod_avg, 1e-20))
+
+ results[pos] = {
+ "pattern": modified,
+ "trp_delta_db": mod_avg_db - ref_avg_db,
+ "peak_delta_db": float(np.max(modified)) - ref_peak,
+ "avg_gain_db": mod_avg_db,
+ }
+
+ return results
+
+
+def dense_device_interference(num_devices, tx_power_dbm, freq_mhz,
+ bandwidth_mhz=2.0, room_size_m=(10, 10, 3)):
+ """Estimate aggregate interference in a dense device deployment.
+
+ Monte-Carlo: places N co-channel devices at random positions in a room
+ and computes interference power at the center.
+
+ Parameters:
+ num_devices: number of interfering devices
+ tx_power_dbm: per-device transmit power (dBm)
+ freq_mhz: frequency (MHz)
+ bandwidth_mhz: channel bandwidth (MHz) — for noise floor calc
+ room_size_m: (length, width, height) in metres
+
+ Returns:
+ avg_interference_dbm: average aggregate interference (dBm)
+ sinr_distribution: 1D array of SINR values (dB) from Monte-Carlo
+ noise_floor_dbm: thermal noise floor (dBm)
+ """
+ n_trials = 500
+ lx, ly, lz = room_size_m
+
+ # Thermal noise floor: kTB
+ noise_floor_dbm = -174 + 10 * np.log10(bandwidth_mhz * 1e6)
+
+ sinr_values = []
+ for _ in range(n_trials):
+ # Random device positions
+ positions = np.column_stack([
+ np.random.uniform(0, lx, num_devices),
+ np.random.uniform(0, ly, num_devices),
+ np.random.uniform(0, lz, num_devices),
+ ])
+ # Receiver at room center
+ rx = np.array([lx / 2, ly / 2, lz / 2])
+ distances = np.linalg.norm(positions - rx, axis=1)
+ distances = np.maximum(distances, 0.1) # min 10 cm
+
+ # Path loss per device (indoor n=3)
+ pl = np.array([log_distance_path_loss(freq_mhz, d, n=3.0) for d in distances])
+ rx_power = tx_power_dbm - pl # dBm per device
+
+ # One device is the "desired", rest are interferers
+ # Take closest as desired signal
+ desired_idx = np.argmin(distances)
+ desired_power = rx_power[desired_idx]
+ interferer_mask = np.ones(num_devices, dtype=bool)
+ interferer_mask[desired_idx] = False
+ interference_lin = np.sum(10 ** (rx_power[interferer_mask] / 10.0))
+ noise_lin = 10 ** (noise_floor_dbm / 10.0)
+ sinr = desired_power - 10 * np.log10(interference_lin + noise_lin)
+ sinr_values.append(sinr)
+
+ sinr_distribution = np.array(sinr_values)
+ return float(np.mean(sinr_distribution)), sinr_distribution, noise_floor_dbm
+
+
+def sar_exposure_estimate(tx_power_mw, antenna_gain_dbi, distance_cm,
+ freq_mhz, tissue_type="muscle"):
+ """Simplified SAR estimation for regulatory screening.
+
+ Uses far-field power density and tissue absorption:
+ S = (Pt · Gt) / (4π·d²) [W/m²]
+ SAR ≈ σ · S / (ρ · penetration) [W/kg]
+
+ NOTE: This is an indicative estimate only. Full SAR compliance
+ requires 3D EM simulation per IEC 62209 / IEEE 1528.
+
+ Parameters:
+ tx_power_mw: transmit power in milliwatts
+ antenna_gain_dbi: antenna gain in dBi
+ distance_cm: distance from antenna to tissue surface (cm)
+ freq_mhz: frequency in MHz
+ tissue_type: 'muscle', 'skin', 'fat', 'bone'
+
+ Returns:
+ sar_w_per_kg: estimated SAR (W/kg)
+ fcc_limit: FCC limit for comparison (1.6 W/kg for 1g average)
+ icnirp_limit: ICNIRP limit (2.0 W/kg for 10g average)
+ compliant: True if below both limits (indicative only)
+ """
+ # Tissue density (kg/m³)
+ tissue_props = {
+ "muscle": {"density": 1040, "depth_cm": 2.0},
+ "skin": {"density": 1100, "depth_cm": 0.5},
+ "fat": {"density": 920, "depth_cm": 1.5},
+ "bone": {"density": 1850, "depth_cm": 1.0},
+ }
+ props = tissue_props.get(tissue_type, tissue_props["muscle"])
+ sigma = get_tissue_properties(freq_mhz)[1]
+
+ # Power density at distance
+ gt_lin = 10 ** (antenna_gain_dbi / 10.0)
+ pt_w = tx_power_mw / 1000.0
+ d_m = max(distance_cm / 100.0, 0.001)
+ S = (pt_w * gt_lin) / (4 * np.pi * d_m ** 2) # W/m²
+
+ # SAR ≈ σ · E² / ρ, and S = E²/(2·η), so SAR ≈ 2·σ·S/ρ
+ # This is the surface SAR, averaged over penetration depth
+ rho = props["density"]
+ sar = 2 * sigma * S / rho # W/kg (surface estimate)
+
+ fcc_limit = 1.6 # W/kg (1g average)
+ icnirp_limit = 2.0 # W/kg (10g average)
+
+ return sar, fcc_limit, icnirp_limit, bool(sar < fcc_limit and sar < icnirp_limit)
+
+
+def wban_link_budget(tx_power_dbm, freq_mhz, body_channel="on_body",
+ distance_cm=30):
+ """IEEE 802.15.6 WBAN channel model link budget.
+
+ Simplified path loss models from IEEE 802.15.6 standard.
+
+ Parameters:
+ tx_power_dbm: transmit power in dBm
+ freq_mhz: frequency in MHz
+ body_channel: 'on_body', 'in_body', or 'off_body'
+ distance_cm: distance in cm
+
+ Returns:
+ path_loss_db: estimated path loss (dB)
+ received_power_dbm: estimated received power (dBm)
+ """
+ d_m = max(distance_cm / 100.0, 0.01)
+
+ if body_channel == "on_body":
+ # CM3 model (on-body to on-body): PL = a·log10(d) + b + N
+ # Typical at 2.4 GHz: a=6.6, b=36.1 (from IEEE 802.15.6)
+ a, b = 6.6, 36.1
+ path_loss = a * np.log10(d_m) + b
+ elif body_channel == "in_body":
+ # CM1 model (implant to implant): very high loss
+ # PL ≈ 47.14 + 4.26·f_GHz + 29.0·d_cm
+ f_ghz = freq_mhz / 1000.0
+ d_cm = distance_cm
+ path_loss = 47.14 + 4.26 * f_ghz + 0.29 * d_cm
+ else: # off_body
+ # CM4 model (on-body to off-body): essentially indoor propagation
+ # Use log-distance with n=2.5 (body-nearby effects)
+ path_loss = free_space_path_loss(freq_mhz, d_m) + 5.0 # +5dB body effect
+
+ received_power = tx_power_dbm - path_loss
+ return float(path_loss), float(received_power)
diff --git a/plot_antenna/config_template.py b/plot_antenna/config_template.py
index 4bfbc30..fc3e304 100644
--- a/plot_antenna/config_template.py
+++ b/plot_antenna/config_template.py
@@ -240,3 +240,72 @@
# When True, enforces schema-based responses (requires schema definition in code)
# Useful for programmatic parsing but reduces natural language quality
# RECOMMENDED: Keep False for readable report captions
+
+# ============================================================================
+# LINK BUDGET / RANGE ESTIMATION
+# ============================================================================
+# Integrates measured antenna gain with Friis / log-distance path loss to
+# compute maximum communication range per azimuth direction.
+
+LINK_BUDGET_ENABLED = False
+LINK_BUDGET_PROTOCOL_PRESET = "BLE 1Mbps" # "Custom", "BLE 1Mbps", "BLE 2Mbps",
+# "BLE Long Range (Coded)", "WiFi 802.11n (MCS0)", "WiFi 802.11ac (MCS0)",
+# "Zigbee / Thread", "LoRa SF12", "LoRa SF7", "LTE Cat-M1", "NB-IoT"
+LINK_BUDGET_TX_POWER_DBM = 0.0 # Transmit power in dBm
+LINK_BUDGET_RX_SENSITIVITY_DBM = -98.0 # Receiver sensitivity in dBm
+LINK_BUDGET_RX_GAIN_DBI = 0.0 # Receive antenna gain in dBi
+LINK_BUDGET_PATH_LOSS_EXP = 2.0 # Path loss exponent (2=free-space, 3=indoor, 4=worst)
+LINK_BUDGET_MISC_LOSS_DB = 10.0 # Cable, mismatch, body loss, etc. in dB
+LINK_BUDGET_TARGET_RANGE_M = 5.0 # Target range for reverse calculations in metres
+
+# ============================================================================
+# INDOOR PROPAGATION ANALYSIS
+# ============================================================================
+# Models indoor propagation effects (ITU-R P.1238) on measured antenna patterns
+# including wall penetration (ITU-R P.2040) and shadow fading.
+
+INDOOR_ANALYSIS_ENABLED = False
+INDOOR_ENVIRONMENT = "Office" # "Free Space", "Office", "Residential", "Commercial",
+# "Hospital", "Industrial", "Outdoor Urban", "Outdoor LOS"
+INDOOR_PATH_LOSS_EXP = 3.0 # Overridable, auto-set by environment preset
+INDOOR_NUM_WALLS = 1 # Number of wall obstructions
+INDOOR_WALL_MATERIAL = "drywall" # drywall, wood, glass, brick, concrete, metal
+INDOOR_SHADOW_FADING_DB = 5.0 # Log-normal shadow fading std deviation (dB)
+INDOOR_MAX_DISTANCE_M = 30.0 # Max range for coverage plots (metres)
+
+# ============================================================================
+# MULTIPATH FADING ANALYSIS
+# ============================================================================
+# Statistical fading models to assess reliability of the measured antenna
+# performance in multipath environments.
+
+FADING_ANALYSIS_ENABLED = False
+FADING_MODEL = "rayleigh" # "rayleigh" (NLOS) or "rician" (LOS)
+FADING_RICIAN_K = 10 # Rician K-factor (linear, LOS/scattered ratio)
+FADING_TARGET_RELIABILITY = 99.0 # Target reliability percentage for fade margin
+FADING_REALIZATIONS = 1000 # Monte-Carlo fading realizations
+
+# ============================================================================
+# MIMO / DIVERSITY ANALYSIS
+# ============================================================================
+# Enhanced MIMO analysis with capacity curves, combining techniques,
+# and pattern-based metrics. Requires ECC analysis to be enabled.
+
+MIMO_ANALYSIS_ENABLED = False
+MIMO_SNR_DB = 20 # Operating SNR for capacity analysis (dB)
+MIMO_SNR_RANGE_DB = (-5, 30) # SNR range for capacity-vs-SNR curves
+MIMO_FADING_MODEL = "rayleigh" # "rayleigh" or "rician" for capacity simulation
+MIMO_RICIAN_K = 10 # K-factor for Rician MIMO channels
+MIMO_XPR_DB = 6.0 # Cross-polarization ratio (indoor ~6 dB)
+
+# ============================================================================
+# WEARABLE / MEDICAL DEVICE ANALYSIS
+# ============================================================================
+# Body-worn performance analysis across multiple positions,
+# dense device interference modeling, and SAR screening.
+
+WEARABLE_ANALYSIS_ENABLED = False
+WEARABLE_BODY_POSITIONS = ["wrist", "chest", "hip", "head"]
+WEARABLE_TX_POWER_MW = 1.0 # Transmit power in milliwatts
+WEARABLE_DENSE_DEVICE_COUNT = 20 # Number of nearby co-channel devices
+WEARABLE_ROOM_SIZE_M = (10, 10, 3) # Room dimensions (L, W, H) in metres
diff --git a/plot_antenna/file_utils.py b/plot_antenna/file_utils.py
index 8c84f57..cec06e9 100644
--- a/plot_antenna/file_utils.py
+++ b/plot_antenna/file_utils.py
@@ -1022,6 +1022,7 @@ def batch_process_passive_scans(
maritime_theta_max=120.0,
maritime_theta_cuts=None,
maritime_gain_threshold=-3.0,
+ advanced_analysis_params=None,
):
"""
Batch‑process all HPOL/VPOL pairs in a directory.
@@ -1181,6 +1182,29 @@ def batch_process_passive_scans(
save_path=maritime_sub,
)
+ # Advanced analysis plots
+ if advanced_analysis_params and subfolder:
+ from .plotting import _prepare_gain_grid as _pgrid
+ from .plotting import generate_advanced_analysis_plots
+
+ freq_idx = freq_list.index(sel_freq) if sel_freq in freq_list else 0
+ unique_theta, unique_phi, gain_grid = _pgrid(
+ theta_deg, phi_deg, total_gain_dB, freq_idx
+ )
+ if gain_grid is not None:
+ adv_sub = os.path.join(subfolder, "Advanced Analysis")
+ os.makedirs(adv_sub, exist_ok=True)
+ generate_advanced_analysis_plots(
+ unique_theta,
+ unique_phi,
+ gain_grid,
+ sel_freq,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=adv_sub,
+ **advanced_analysis_params,
+ )
+
# Re-enable interactive mode after batch processing
plt.ion()
@@ -1197,6 +1221,7 @@ def batch_process_active_scans(
maritime_theta_max=120.0,
maritime_theta_cuts=None,
maritime_gain_threshold=-3.0,
+ advanced_analysis_params=None,
):
"""
Batch‑process all active TRP measurement files in a directory.
@@ -1354,6 +1379,23 @@ def batch_process_active_scans(
save_path=maritime_sub,
)
+ # Advanced analysis plots
+ if advanced_analysis_params and subfolder:
+ from .plotting import generate_advanced_analysis_plots
+
+ adv_sub = os.path.join(subfolder, "Advanced Analysis")
+ os.makedirs(adv_sub, exist_ok=True)
+ generate_advanced_analysis_plots(
+ theta_angles_deg,
+ phi_angles_deg,
+ total_power_dBm_2d,
+ frequency,
+ data_label="Power",
+ data_unit="dBm",
+ save_path=adv_sub,
+ **advanced_analysis_params,
+ )
+
print(f" ✓ Completed {trp_file} at {frequency} MHz (TRP={TRP_dBm:.2f} dBm)")
except Exception as e:
diff --git a/plot_antenna/gui/callbacks_mixin.py b/plot_antenna/gui/callbacks_mixin.py
index 942706c..26e890c 100644
--- a/plot_antenna/gui/callbacks_mixin.py
+++ b/plot_antenna/gui/callbacks_mixin.py
@@ -44,6 +44,7 @@
process_vswr_files,
_prepare_gain_grid,
generate_maritime_plots,
+ generate_advanced_analysis_plots,
)
from ..groupdelay import process_groupdelay_files
from .utils import calculate_min_max_parameters, display_parameter_table
@@ -1298,6 +1299,55 @@ def _process_active_data(self):
save_path=None,
)
+ # Advanced analysis plots (active)
+ _any_advanced = (
+ self.link_budget_enabled
+ or self.indoor_analysis_enabled
+ or self.fading_analysis_enabled
+ or self.wearable_analysis_enabled
+ )
+ if _any_advanced:
+ self.log_message("Generating advanced analysis plots (active)...")
+ generate_advanced_analysis_plots(
+ np.rad2deg(theta_angles_rad),
+ np.rad2deg(phi_angles_rad),
+ total_power_dBm_2d,
+ frequency,
+ data_label="Power",
+ data_unit="dBm",
+ save_path=None,
+ link_budget_enabled=self.link_budget_enabled,
+ lb_pt_dbm=self.lb_tx_power.get(),
+ lb_pr_dbm=self.lb_rx_sensitivity.get(),
+ lb_gr_dbi=self.lb_rx_gain.get(),
+ lb_path_loss_exp=self.lb_path_loss_exp.get(),
+ lb_misc_loss_db=self.lb_misc_loss.get(),
+ lb_target_range_m=self.lb_target_range.get(),
+ indoor_enabled=self.indoor_analysis_enabled,
+ indoor_environment=self.indoor_environment.get(),
+ indoor_path_loss_exp=self.lb_path_loss_exp.get(),
+ indoor_n_walls=self.indoor_num_walls.get(),
+ indoor_wall_material=self.indoor_wall_material.get(),
+ indoor_shadow_fading_db=self.indoor_shadow_fading.get(),
+ indoor_max_distance_m=self.indoor_max_distance.get(),
+ fading_enabled=self.fading_analysis_enabled,
+ fading_pr_sensitivity_dbm=self.lb_rx_sensitivity.get(),
+ fading_pt_dbm=self.lb_tx_power.get(),
+ fading_target_reliability=self.fading_target_reliability.get(),
+ wearable_enabled=self.wearable_analysis_enabled,
+ wearable_body_positions=[
+ pos for pos, var in self.wearable_positions_var.items()
+ if var.get()
+ ],
+ wearable_tx_power_mw=self.wearable_tx_power_mw.get(),
+ wearable_num_devices=self.wearable_device_count.get(),
+ wearable_room_size=(
+ self.wearable_room_x.get(),
+ self.wearable_room_y.get(),
+ self.wearable_room_z.get(),
+ ),
+ )
+
# Update measurement context for AI awareness
self._measurement_context["processing_complete"] = True
self._measurement_context["key_metrics"] = {
@@ -1523,3 +1573,66 @@ def _process_passive_data(self):
self.log_message(
"Maritime: Could not reshape gain data to 2D grid.", level="warning"
)
+
+ # Advanced analysis plots (passive)
+ _any_advanced_p = (
+ self.link_budget_enabled
+ or self.indoor_analysis_enabled
+ or self.fading_analysis_enabled
+ or self.wearable_analysis_enabled
+ )
+ if _any_advanced_p:
+ self.log_message("Generating advanced analysis plots (passive)...")
+ _adv_freq_idx = (
+ self.freq_list.index(float(self.selected_frequency.get()))
+ if self.freq_list
+ else 0
+ )
+ _adv_theta, _adv_phi, _adv_grid = _prepare_gain_grid(
+ theta_angles_deg, phi_angles_deg, Total_Gain_dB, _adv_freq_idx
+ )
+ if _adv_grid is not None:
+ generate_advanced_analysis_plots(
+ _adv_theta,
+ _adv_phi,
+ _adv_grid,
+ float(self.selected_frequency.get()),
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+ link_budget_enabled=self.link_budget_enabled,
+ lb_pt_dbm=self.lb_tx_power.get(),
+ lb_pr_dbm=self.lb_rx_sensitivity.get(),
+ lb_gr_dbi=self.lb_rx_gain.get(),
+ lb_path_loss_exp=self.lb_path_loss_exp.get(),
+ lb_misc_loss_db=self.lb_misc_loss.get(),
+ lb_target_range_m=self.lb_target_range.get(),
+ indoor_enabled=self.indoor_analysis_enabled,
+ indoor_environment=self.indoor_environment.get(),
+ indoor_path_loss_exp=self.lb_path_loss_exp.get(),
+ indoor_n_walls=self.indoor_num_walls.get(),
+ indoor_wall_material=self.indoor_wall_material.get(),
+ indoor_shadow_fading_db=self.indoor_shadow_fading.get(),
+ indoor_max_distance_m=self.indoor_max_distance.get(),
+ fading_enabled=self.fading_analysis_enabled,
+ fading_pr_sensitivity_dbm=self.lb_rx_sensitivity.get(),
+ fading_pt_dbm=self.lb_tx_power.get(),
+ fading_target_reliability=self.fading_target_reliability.get(),
+ wearable_enabled=self.wearable_analysis_enabled,
+ wearable_body_positions=[
+ pos for pos, var in self.wearable_positions_var.items()
+ if var.get()
+ ],
+ wearable_tx_power_mw=self.wearable_tx_power_mw.get(),
+ wearable_num_devices=self.wearable_device_count.get(),
+ wearable_room_size=(
+ self.wearable_room_x.get(),
+ self.wearable_room_y.get(),
+ self.wearable_room_z.get(),
+ ),
+ )
+ else:
+ self.log_message(
+ "Advanced analysis: Could not reshape gain data to 2D grid.",
+ level="warning",
+ )
diff --git a/plot_antenna/gui/dialogs_mixin.py b/plot_antenna/gui/dialogs_mixin.py
index 338187e..44a690d 100644
--- a/plot_antenna/gui/dialogs_mixin.py
+++ b/plot_antenna/gui/dialogs_mixin.py
@@ -29,6 +29,7 @@
AI_MAX_TOKENS,
AI_REASONING_EFFORT,
)
+from ..calculations import PROTOCOL_PRESETS, ENVIRONMENT_PRESETS
# Import additional AI settings with fallbacks
try:
@@ -1208,6 +1209,274 @@ def _on_destroy(event):
settings_window.bind("", _on_destroy)
+ # ────────────────────────────────────────────────────────────────────────
+ # ADVANCED ANALYSIS SETTINGS (shared builder for Active & Passive)
+ # ────────────────────────────────────────────────────────────────────────
+
+ def _build_advanced_analysis_frames(self, parent, start_row):
+ """Build all advanced analysis LabelFrame sections.
+
+ Returns the next available row index and a callback to read values.
+ """
+ row = start_row
+
+ # ── Link Budget / Range Estimation ──
+ lb_frame = tk.LabelFrame(
+ parent, text="Link Budget / Range Estimation",
+ bg=DARK_BG_COLOR, fg=ACCENT_BLUE_COLOR, font=SECTION_HEADER_FONT,
+ )
+ lb_frame.grid(row=row, column=0, columnspan=4, sticky="ew", padx=15, pady=5)
+ row += 1
+
+ self._cb_link_budget_var = tk.BooleanVar(
+ value=getattr(self, "link_budget_enabled", False)
+ )
+ tk.Checkbutton(
+ lb_frame, text="Enable Link Budget Analysis",
+ variable=self._cb_link_budget_var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).grid(row=0, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+
+ # Protocol preset dropdown
+ tk.Label(lb_frame, text="Protocol:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=0, sticky=tk.W, padx=5)
+ protocol_options = list(PROTOCOL_PRESETS.keys())
+ self._lb_protocol_menu = ttk.Combobox(
+ lb_frame, textvariable=self.lb_protocol_preset,
+ values=protocol_options, width=22, state="readonly",
+ )
+ self._lb_protocol_menu.grid(row=1, column=1, columnspan=2, sticky=tk.W, padx=5)
+
+ def _on_protocol_change(*_args):
+ preset = self.lb_protocol_preset.get()
+ if preset in PROTOCOL_PRESETS and preset != "Custom":
+ sens, pwr, _freq = PROTOCOL_PRESETS[preset]
+ if sens is not None:
+ self.lb_rx_sensitivity.set(sens)
+ if pwr is not None:
+ self.lb_tx_power.set(pwr)
+ self.lb_protocol_preset.trace_add("write", _on_protocol_change)
+
+ # Tx Power
+ tk.Label(lb_frame, text="Tx Power (dBm):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=0, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_tx_power, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=1, padx=5)
+
+ # Rx Sensitivity
+ tk.Label(lb_frame, text="Rx Sensitivity (dBm):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=2, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_rx_sensitivity, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=3, padx=5)
+
+ # Rx Gain
+ tk.Label(lb_frame, text="Rx Gain (dBi):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=3, column=0, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_rx_gain, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=3, column=1, padx=5)
+
+ # Path loss exponent
+ tk.Label(lb_frame, text="Path Loss Exp (n):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=3, column=2, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_path_loss_exp, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=3, column=3, padx=5)
+
+ # Misc loss + target range
+ tk.Label(lb_frame, text="Misc Loss (dB):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=4, column=0, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_misc_loss, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=4, column=1, padx=5)
+
+ tk.Label(lb_frame, text="Target Range (m):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=4, column=2, sticky=tk.W, padx=5)
+ tk.Entry(lb_frame, textvariable=self.lb_target_range, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=4, column=3, padx=5)
+
+ # ── Indoor Propagation ──
+ indoor_frame = tk.LabelFrame(
+ parent, text="Indoor Propagation",
+ bg=DARK_BG_COLOR, fg=ACCENT_BLUE_COLOR, font=SECTION_HEADER_FONT,
+ )
+ indoor_frame.grid(row=row, column=0, columnspan=4, sticky="ew", padx=15, pady=5)
+ row += 1
+
+ self._cb_indoor_var = tk.BooleanVar(
+ value=getattr(self, "indoor_analysis_enabled", False)
+ )
+ tk.Checkbutton(
+ indoor_frame, text="Enable Indoor Analysis",
+ variable=self._cb_indoor_var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).grid(row=0, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+
+ # Environment dropdown
+ tk.Label(indoor_frame, text="Environment:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=0, sticky=tk.W, padx=5)
+ env_options = list(ENVIRONMENT_PRESETS.keys())
+ self._indoor_env_menu = ttk.Combobox(
+ indoor_frame, textvariable=self.indoor_environment,
+ values=env_options, width=18, state="readonly",
+ )
+ self._indoor_env_menu.grid(row=1, column=1, sticky=tk.W, padx=5)
+
+ def _on_env_change(*_args):
+ env = self.indoor_environment.get()
+ if env in ENVIRONMENT_PRESETS:
+ n, sigma, fading_m, k, walls = ENVIRONMENT_PRESETS[env]
+ self.lb_path_loss_exp.set(n)
+ self.indoor_shadow_fading.set(sigma)
+ self.indoor_num_walls.set(walls)
+ if fading_m != "none":
+ self.fading_model.set(fading_m)
+ if k > 0:
+ self.fading_rician_k.set(float(k))
+ self.indoor_environment.trace_add("write", _on_env_change)
+
+ # Walls + material
+ tk.Label(indoor_frame, text="Walls:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=0, sticky=tk.W, padx=5)
+ tk.Spinbox(
+ indoor_frame, textvariable=self.indoor_num_walls,
+ from_=0, to=10, width=4,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ ).grid(row=2, column=1, sticky=tk.W, padx=5)
+
+ tk.Label(indoor_frame, text="Material:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=2, sticky=tk.W, padx=5)
+ wall_options = ["drywall", "wood", "glass", "brick", "concrete", "metal"]
+ ttk.Combobox(
+ indoor_frame, textvariable=self.indoor_wall_material,
+ values=wall_options, width=12, state="readonly",
+ ).grid(row=2, column=3, sticky=tk.W, padx=5)
+
+ # Shadow fading + max distance
+ tk.Label(indoor_frame, text="Shadow σ (dB):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=3, column=0, sticky=tk.W, padx=5)
+ tk.Entry(indoor_frame, textvariable=self.indoor_shadow_fading, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=3, column=1, padx=5)
+
+ tk.Label(indoor_frame, text="Max Distance (m):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=3, column=2, sticky=tk.W, padx=5)
+ tk.Entry(indoor_frame, textvariable=self.indoor_max_distance, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=3, column=3, padx=5)
+
+ # ── Multipath Fading ──
+ fading_frame = tk.LabelFrame(
+ parent, text="Multipath Fading",
+ bg=DARK_BG_COLOR, fg=ACCENT_BLUE_COLOR, font=SECTION_HEADER_FONT,
+ )
+ fading_frame.grid(row=row, column=0, columnspan=4, sticky="ew", padx=15, pady=5)
+ row += 1
+
+ self._cb_fading_var = tk.BooleanVar(
+ value=getattr(self, "fading_analysis_enabled", False)
+ )
+ tk.Checkbutton(
+ fading_frame, text="Enable Fading Analysis",
+ variable=self._cb_fading_var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).grid(row=0, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+
+ tk.Label(fading_frame, text="Model:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=0, sticky=tk.W, padx=5)
+ ttk.Combobox(
+ fading_frame, textvariable=self.fading_model,
+ values=["rayleigh", "rician"], width=12, state="readonly",
+ ).grid(row=1, column=1, sticky=tk.W, padx=5)
+
+ tk.Label(fading_frame, text="K-factor:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=2, sticky=tk.W, padx=5)
+ tk.Entry(fading_frame, textvariable=self.fading_rician_k, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=1, column=3, padx=5)
+
+ tk.Label(fading_frame, text="Target Reliability (%):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=0, columnspan=2, sticky=tk.W, padx=5)
+ tk.Entry(fading_frame, textvariable=self.fading_target_reliability, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=2, padx=5)
+
+ # ── Wearable / Medical ──
+ wear_frame = tk.LabelFrame(
+ parent, text="Wearable / Medical",
+ bg=DARK_BG_COLOR, fg=ACCENT_BLUE_COLOR, font=SECTION_HEADER_FONT,
+ )
+ wear_frame.grid(row=row, column=0, columnspan=4, sticky="ew", padx=15, pady=5)
+ row += 1
+
+ self._cb_wearable_var = tk.BooleanVar(
+ value=getattr(self, "wearable_analysis_enabled", False)
+ )
+ tk.Checkbutton(
+ wear_frame, text="Enable Wearable Assessment",
+ variable=self._cb_wearable_var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).grid(row=0, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+
+ # Body positions checkboxes
+ pos_frame = tk.Frame(wear_frame, bg=DARK_BG_COLOR)
+ pos_frame.grid(row=1, column=0, columnspan=4, sticky=tk.W, padx=5)
+ for i, (pos, var) in enumerate(self.wearable_positions_var.items()):
+ tk.Checkbutton(
+ pos_frame, text=pos.capitalize(), variable=var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).pack(side=tk.LEFT, padx=8)
+
+ tk.Label(wear_frame, text="Tx Power (mW):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=0, sticky=tk.W, padx=5)
+ tk.Entry(wear_frame, textvariable=self.wearable_tx_power_mw, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=1, padx=5)
+
+ tk.Label(wear_frame, text="Nearby Devices:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=2, sticky=tk.W, padx=5)
+ tk.Spinbox(
+ wear_frame, textvariable=self.wearable_device_count,
+ from_=1, to=100, width=5,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ ).grid(row=2, column=3, sticky=tk.W, padx=5)
+
+ # Room size
+ room_frame = tk.Frame(wear_frame, bg=DARK_BG_COLOR)
+ room_frame.grid(row=3, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+ tk.Label(room_frame, text="Room (m):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).pack(side=tk.LEFT)
+ tk.Entry(room_frame, textvariable=self.wearable_room_x, width=5,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).pack(side=tk.LEFT, padx=2)
+ tk.Label(room_frame, text="×", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).pack(side=tk.LEFT)
+ tk.Entry(room_frame, textvariable=self.wearable_room_y, width=5,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).pack(side=tk.LEFT, padx=2)
+ tk.Label(room_frame, text="×", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).pack(side=tk.LEFT)
+ tk.Entry(room_frame, textvariable=self.wearable_room_z, width=5,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).pack(side=tk.LEFT, padx=2)
+
+ return row
+
+ def _save_advanced_analysis_settings(self):
+ """Read advanced analysis checkbox values back to self attributes."""
+ self.link_budget_enabled = self._cb_link_budget_var.get()
+ self.indoor_analysis_enabled = self._cb_indoor_var.get()
+ self.fading_analysis_enabled = self._cb_fading_var.get()
+ self.wearable_analysis_enabled = self._cb_wearable_var.get()
+
# ────────────────────────────────────────────────────────────────────────
# SCAN TYPE SETTINGS DIALOG
# ────────────────────────────────────────────────────────────────────────
@@ -1215,10 +1484,46 @@ def _on_destroy(event):
def show_settings(self):
"""Show settings dialog based on current scan type."""
scan_type_value = self.scan_type.get()
- settings_window = tk.Toplevel(self.root)
- settings_window.geometry("600x350")
- settings_window.title(f"{scan_type_value.capitalize()} Settings")
- settings_window.configure(bg=DARK_BG_COLOR)
+ outer_window = tk.Toplevel(self.root)
+ outer_window.geometry("650x800")
+ outer_window.title(f"{scan_type_value.capitalize()} Settings")
+ outer_window.configure(bg=DARK_BG_COLOR)
+ outer_window.resizable(True, True)
+
+ # Scrollable content area
+ _canvas = tk.Canvas(outer_window, bg=DARK_BG_COLOR, highlightthickness=0)
+ _scrollbar = ttk.Scrollbar(
+ outer_window, orient="vertical", command=_canvas.yview
+ )
+ settings_window = tk.Frame(_canvas, bg=DARK_BG_COLOR)
+
+ settings_window.bind(
+ "",
+ lambda e: _canvas.configure(scrollregion=_canvas.bbox("all")),
+ )
+ _cw = _canvas.create_window((0, 0), window=settings_window, anchor="nw")
+ _canvas.configure(yscrollcommand=_scrollbar.set)
+ _canvas.bind(
+ "", lambda e: _canvas.itemconfig(_cw, width=e.width)
+ )
+
+ def _on_mousewheel(event):
+ try:
+ _canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
+ except tk.TclError:
+ _canvas.unbind_all("")
+
+ def _on_enter(_e):
+ _canvas.bind_all("", _on_mousewheel)
+
+ def _on_leave(_e):
+ _canvas.unbind_all("")
+
+ _canvas.bind("", _on_enter)
+ _canvas.bind("", _on_leave)
+
+ _scrollbar.pack(side="right", fill="y")
+ _canvas.pack(side="left", fill="both", expand=True)
# ────────────────────────────────────
# ACTIVE (TRP) SETTINGS
@@ -1364,11 +1669,17 @@ def show_settings(self):
insertbackground=LIGHT_TEXT_COLOR,
).grid(row=3, column=1, columnspan=3, sticky=tk.W, padx=5, pady=2)
+ # Advanced analysis settings (Link Budget, Indoor, Fading, Wearable)
+ _adv_next_row = self._build_advanced_analysis_frames(
+ settings_window, start_row=5
+ )
+
def save_active_settings():
self.interpolate_3d_plots = self.interpolate_var.get()
self.maritime_plots_enabled = self.cb_maritime_var.get()
+ self._save_advanced_analysis_settings()
self.update_visibility()
- settings_window.destroy()
+ outer_window.destroy()
tk.Button(
settings_window,
@@ -1376,7 +1687,7 @@ def save_active_settings():
command=save_active_settings,
bg=ACCENT_BLUE_COLOR,
fg=LIGHT_TEXT_COLOR,
- ).grid(row=5, column=0, columnspan=4, pady=20)
+ ).grid(row=_adv_next_row, column=0, columnspan=4, pady=20)
# ────────────────────────────────────
# PASSIVE (HPOL/VPOL or G&D) SETTINGS
@@ -1675,6 +1986,11 @@ def refresh_passive_ui():
insertbackground=LIGHT_TEXT_COLOR,
).grid(row=3, column=1, columnspan=3, sticky=tk.W, padx=5, pady=2)
+ # Advanced analysis settings (Link Budget, Indoor, Fading, Wearable)
+ _adv_next_row_p = self._build_advanced_analysis_frames(
+ settings_window, start_row=9
+ )
+
# Save button
def save_passive_settings():
self.passive_scan_type.set(self.plot_type_var.get())
@@ -1682,8 +1998,9 @@ def save_passive_settings():
self.shadowing_enabled = self.cb_shadowing_var.get()
self.shadow_direction = self.shadow_direction_var.get()
self.maritime_plots_enabled = self.cb_maritime_var.get()
+ self._save_advanced_analysis_settings()
self.update_visibility()
- settings_window.destroy()
+ outer_window.destroy()
tk.Button(
settings_window,
@@ -1691,7 +2008,7 @@ def save_passive_settings():
command=save_passive_settings,
bg=ACCENT_BLUE_COLOR,
fg=LIGHT_TEXT_COLOR,
- ).grid(row=9, column=0, columnspan=4, pady=20)
+ ).grid(row=_adv_next_row_p, column=0, columnspan=4, pady=20)
elif scan_type_value == "vswr":
# Show settings specific to VNA with organized LabelFrame sections
@@ -1730,7 +2047,7 @@ def save_vswr_settings():
self.saved_limit2_stop = self.limit2_val2.get()
self.cb_groupdelay_sff = self.cb_groupdelay_sff_var.get()
self.saved_min_max_vswr = self.min_max_vswr_var.get()
- settings_window.destroy()
+ outer_window.destroy()
except tk.TclError:
messagebox.showerror("Invalid Input", "Please enter valid numeric values.")
diff --git a/plot_antenna/gui/main_window.py b/plot_antenna/gui/main_window.py
index 28106cb..90ab5f7 100644
--- a/plot_antenna/gui/main_window.py
+++ b/plot_antenna/gui/main_window.py
@@ -559,6 +559,46 @@ def _create_widgets(self):
self.horizon_gain_threshold = tk.DoubleVar(value=-3.0)
self.horizon_theta_cuts_var = tk.StringVar(value="60,70,80,90,100,110,120")
+ # Link Budget / Range Estimation settings
+ self.link_budget_enabled = False
+ self.lb_protocol_preset = tk.StringVar(value="BLE 1Mbps")
+ self.lb_tx_power = tk.DoubleVar(value=0.0)
+ self.lb_rx_sensitivity = tk.DoubleVar(value=-98.0)
+ self.lb_rx_gain = tk.DoubleVar(value=0.0)
+ self.lb_path_loss_exp = tk.DoubleVar(value=2.0)
+ self.lb_misc_loss = tk.DoubleVar(value=10.0)
+ self.lb_target_range = tk.DoubleVar(value=5.0)
+
+ # Indoor Propagation settings
+ self.indoor_analysis_enabled = False
+ self.indoor_environment = tk.StringVar(value="Office")
+ self.indoor_num_walls = tk.IntVar(value=1)
+ self.indoor_wall_material = tk.StringVar(value="drywall")
+ self.indoor_shadow_fading = tk.DoubleVar(value=5.0)
+ self.indoor_max_distance = tk.DoubleVar(value=30.0)
+
+ # Multipath Fading Analysis settings
+ self.fading_analysis_enabled = False
+ self.fading_model = tk.StringVar(value="rayleigh")
+ self.fading_rician_k = tk.DoubleVar(value=10.0)
+ self.fading_target_reliability = tk.DoubleVar(value=99.0)
+
+ # MIMO / Diversity Analysis settings
+ self.mimo_analysis_enabled = False
+ self.mimo_snr = tk.DoubleVar(value=20.0)
+ self.mimo_xpr = tk.DoubleVar(value=6.0)
+
+ # Wearable / Medical Device settings
+ self.wearable_analysis_enabled = False
+ self.wearable_positions_var = {
+ pos: tk.BooleanVar(value=True) for pos in ["wrist", "chest", "hip", "head"]
+ }
+ self.wearable_tx_power_mw = tk.DoubleVar(value=1.0)
+ self.wearable_device_count = tk.IntVar(value=20)
+ self.wearable_room_x = tk.DoubleVar(value=10.0)
+ self.wearable_room_y = tk.DoubleVar(value=10.0)
+ self.wearable_room_z = tk.DoubleVar(value=3.0)
+
# Configure background
self.root.config(bg=DARK_BG_COLOR)
diff --git a/plot_antenna/gui/tools_mixin.py b/plot_antenna/gui/tools_mixin.py
index 29fbbff..547962f 100644
--- a/plot_antenna/gui/tools_mixin.py
+++ b/plot_antenna/gui/tools_mixin.py
@@ -447,6 +447,54 @@ def on_cancel():
# BULK PROCESSING
# ────────────────────────────────────────────────────────────────────────
+ def _collect_advanced_params(self):
+ """Collect advanced analysis parameters into a dict for batch processing.
+
+ Returns None if no advanced features are enabled, otherwise returns a
+ kwargs dict suitable for ``generate_advanced_analysis_plots()``.
+ """
+ any_enabled = (
+ getattr(self, "link_budget_enabled", False)
+ or getattr(self, "indoor_analysis_enabled", False)
+ or getattr(self, "fading_analysis_enabled", False)
+ or getattr(self, "wearable_analysis_enabled", False)
+ )
+ if not any_enabled:
+ return None
+
+ return {
+ "link_budget_enabled": getattr(self, "link_budget_enabled", False),
+ "lb_pt_dbm": self.lb_tx_power.get(),
+ "lb_pr_dbm": self.lb_rx_sensitivity.get(),
+ "lb_gr_dbi": self.lb_rx_gain.get(),
+ "lb_path_loss_exp": self.lb_path_loss_exp.get(),
+ "lb_misc_loss_db": self.lb_misc_loss.get(),
+ "lb_target_range_m": self.lb_target_range.get(),
+ "indoor_enabled": getattr(self, "indoor_analysis_enabled", False),
+ "indoor_environment": self.indoor_environment.get(),
+ "indoor_path_loss_exp": self.lb_path_loss_exp.get(),
+ "indoor_n_walls": self.indoor_num_walls.get(),
+ "indoor_wall_material": self.indoor_wall_material.get(),
+ "indoor_shadow_fading_db": self.indoor_shadow_fading.get(),
+ "indoor_max_distance_m": self.indoor_max_distance.get(),
+ "fading_enabled": getattr(self, "fading_analysis_enabled", False),
+ "fading_pr_sensitivity_dbm": self.lb_rx_sensitivity.get(),
+ "fading_pt_dbm": self.lb_tx_power.get(),
+ "fading_target_reliability": self.fading_target_reliability.get(),
+ "wearable_enabled": getattr(self, "wearable_analysis_enabled", False),
+ "wearable_body_positions": [
+ pos for pos, var in self.wearable_positions_var.items()
+ if var.get()
+ ],
+ "wearable_tx_power_mw": self.wearable_tx_power_mw.get(),
+ "wearable_num_devices": self.wearable_device_count.get(),
+ "wearable_room_size": (
+ self.wearable_room_x.get(),
+ self.wearable_room_y.get(),
+ self.wearable_room_z.get(),
+ ),
+ }
+
def open_bulk_passive_processing(self):
"""Prompt the user for a directory of HPOL/VPOL files and process them in bulk."""
directory = filedialog.askdirectory(title="Select Folder Containing HPOL/VPOL Files")
@@ -558,6 +606,7 @@ def _process_worker():
if hasattr(self, "horizon_gain_threshold")
else -3.0
),
+ advanced_analysis_params=self._collect_advanced_params(),
)
self.root.after(0, lambda: _process_done(True, None))
except Exception as e:
@@ -646,6 +695,7 @@ def _process_worker():
if hasattr(self, "horizon_gain_threshold")
else -3.0
),
+ advanced_analysis_params=self._collect_advanced_params(),
)
self.root.after(0, lambda: _process_done(True, None))
except Exception as e:
diff --git a/plot_antenna/plotting.py b/plot_antenna/plotting.py
index fbbec08..027da04 100644
--- a/plot_antenna/plotting.py
+++ b/plot_antenna/plotting.py
@@ -15,7 +15,27 @@
from .config import THETA_RESOLUTION, PHI_RESOLUTION, polar_dB_max, polar_dB_min
from .file_utils import parse_2port_data
-from .calculations import calculate_trp
+from .calculations import (
+ calculate_trp,
+ friis_range_estimate,
+ min_tx_gain_for_range,
+ link_margin,
+ range_vs_azimuth,
+ free_space_path_loss,
+ log_distance_path_loss,
+ wall_penetration_loss,
+ apply_indoor_propagation,
+ rayleigh_cdf,
+ rician_cdf,
+ fade_margin_for_reliability,
+ apply_statistical_fading,
+ combining_gain,
+ mimo_capacity_vs_snr,
+ body_worn_pattern_analysis,
+ dense_device_interference,
+ PROTOCOL_PRESETS,
+ ENVIRONMENT_PRESETS,
+)
# Suppress noisy warnings during batch processing (worker thread + tight_layout)
warnings.filterwarnings("ignore", message="Starting a Matplotlib GUI outside of the main thread")
@@ -2869,3 +2889,740 @@ def generate_maritime_plots(
zmax=zmax,
save_path=save_path,
)
+
+
+# ——— LINK BUDGET & RANGE ESTIMATION PLOTS ——————————————————————————
+
+def plot_link_budget_summary(
+ freq_mhz,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ pt_dbm=0.0,
+ pr_dbm=-98.0,
+ gr_dbi=0.0,
+ path_loss_exp=2.0,
+ misc_loss_db=10.0,
+ target_range_m=5.0,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+):
+ """
+ Link budget summary: waterfall chart + range-vs-azimuth polar plot.
+
+ Left panel: Link budget table showing all parameters and derived values
+ Right panel: Range vs azimuth polar plot at θ=90° (horizon)
+ """
+ _ = data_unit # kept in signature for API consistency
+ # Determine if active (EIRP) or passive (Gain) data
+ is_active = data_label != "Gain"
+
+ # Get horizon gain/EIRP and range per azimuth
+ range_m, horizon_gain = range_vs_azimuth(
+ gain_2d, theta_deg, phi_deg, freq_mhz,
+ pt_dbm=0.0 if is_active else pt_dbm, # EIRP already includes Pt
+ pr_dbm=pr_dbm, gr_dbi=gr_dbi,
+ path_loss_exp=path_loss_exp, misc_loss_db=misc_loss_db,
+ )
+
+ peak_gain = float(np.max(horizon_gain))
+ worst_gain = float(np.min(horizon_gain))
+
+ # Peak and worst-case range
+ peak_range = friis_range_estimate(
+ pt_dbm if not is_active else 0.0, pr_dbm, peak_gain, gr_dbi,
+ freq_mhz, path_loss_exp, misc_loss_db,
+ )
+ worst_range = friis_range_estimate(
+ pt_dbm if not is_active else 0.0, pr_dbm, worst_gain, gr_dbi,
+ freq_mhz, path_loss_exp, misc_loss_db,
+ )
+
+ # Min Tx gain for target range
+ min_gt = min_tx_gain_for_range(
+ target_range_m, pt_dbm if not is_active else 0.0,
+ pr_dbm, gr_dbi, freq_mhz, path_loss_exp, misc_loss_db,
+ )
+
+ # Link margin at target range with peak gain
+ margin = link_margin(
+ pt_dbm if not is_active else 0.0, peak_gain, gr_dbi,
+ freq_mhz, target_range_m, path_loss_exp, misc_loss_db, pr_dbm,
+ )
+
+ # FSPL at 1m reference
+ fspl_1m = free_space_path_loss(freq_mhz, 1.0)
+ pl_target = fspl_1m + 10 * path_loss_exp * np.log10(max(target_range_m, 0.01))
+
+ # ---- Figure ----
+ fig = plt.figure(figsize=(16, 7))
+ fig_gs = fig.add_gridspec(1, 2, width_ratios=[1, 1.2])
+
+ # --- Left: Link budget table ---
+ ax_table = fig.add_subplot(fig_gs[0])
+ ax_table.axis("off")
+
+ gt_label = "Peak EIRP" if is_active else "Tx Gain (Gt)"
+ gt_value = f"{peak_gain:.1f} dBm" if is_active else f"{peak_gain:.1f} dBi"
+
+ table_data = []
+ if not is_active:
+ table_data.append(["Tx Power (Pt)", f"{pt_dbm:.1f} dBm"])
+ table_data.extend([
+ [gt_label, gt_value],
+ [f"Path Loss @ {target_range_m:.1f}m", f"-{pl_target:.1f} dB"],
+ ["Misc Losses", f"-{misc_loss_db:.1f} dB"],
+ ["Rx Gain (Gr)", f"{gr_dbi:.1f} dBi"],
+ ["Rx Sensitivity", f"{pr_dbm:.1f} dBm"],
+ ["", ""],
+ ["Link Margin @ target", f"{margin:+.1f} dB"],
+ ["Peak Range", f"{peak_range:.1f} m"],
+ ["Worst-Case Range", f"{worst_range:.1f} m"],
+ ["Min Gt for target range", f"{min_gt:.1f} dBi"],
+ ["Frequency", f"{freq_mhz} MHz"],
+ ["Path Loss Exponent (n)", f"{path_loss_exp}"],
+ ])
+
+ table = ax_table.table(
+ cellText=table_data,
+ colLabels=["Parameter", "Value"],
+ cellLoc="left",
+ loc="center",
+ colWidths=[0.55, 0.45],
+ )
+ table.auto_set_font_size(False)
+ table.set_fontsize(10)
+ table.scale(1, 1.4)
+
+ for j in range(2):
+ table[0, j].set_facecolor("#4A90E2")
+ table[0, j].set_text_props(color="white", fontweight="bold")
+
+ # Highlight margin row (after the blank separator row)
+ margin_row_idx = len(table_data) - 5 # "Link Margin" row
+ if margin_row_idx > 0:
+ color = "#4CAF50" if margin >= 0 else "#F44336"
+ for j in range(2):
+ table[margin_row_idx, j].set_facecolor(color)
+ table[margin_row_idx, j].set_text_props(color="white", fontweight="bold")
+
+ ax_table.set_title(
+ f"Link Budget Summary — {freq_mhz} MHz",
+ fontsize=13, fontweight="bold", pad=20,
+ )
+
+ # --- Right: Range vs Azimuth polar plot ---
+ ax_polar = fig.add_subplot(fig_gs[1], projection="polar")
+ phi_rad = np.deg2rad(phi_deg)
+
+ # Colour-code by whether range meets target
+ colors = np.where(range_m >= target_range_m, "#4CAF50", "#F44336")
+ bar_width = np.deg2rad(np.mean(np.diff(phi_deg))) if len(phi_deg) > 1 else np.deg2rad(5)
+ ax_polar.bar(phi_rad, range_m, width=bar_width,
+ color=colors, alpha=0.7, edgecolor="gray", linewidth=0.3)
+
+ # Target range ring
+ target_ring = np.full_like(phi_rad, target_range_m)
+ ax_polar.plot(phi_rad, target_ring, "k--", linewidth=1.5,
+ label=f"Target: {target_range_m:.0f} m")
+
+ ax_polar.set_title(
+ "Range vs Azimuth (θ=90°)\nGreen = meets target, Red = below",
+ fontsize=11, fontweight="bold", pad=15,
+ )
+ ax_polar.set_theta_zero_location("N")
+ ax_polar.set_theta_direction(-1)
+ ax_polar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=9)
+
+ plt.tight_layout()
+
+ if save_path:
+ fname = f"link_budget_{freq_mhz}MHz.png"
+ fig.savefig(os.path.join(save_path, fname), dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ else:
+ plt.show()
+
+
+# ——— INDOOR PROPAGATION PLOTS ————————————————————————————————————
+
+def plot_indoor_coverage_map(
+ freq_mhz,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ pt_dbm=0.0,
+ pr_sensitivity_dbm=-98.0,
+ environment="Office",
+ path_loss_exp=3.0,
+ n_walls=1,
+ wall_material="drywall",
+ shadow_fading_db=5.0,
+ max_distance_m=30.0,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+):
+ """
+ Indoor coverage analysis: path loss curves + azimuthal coverage heatmap + range contour.
+ """
+ _ = data_unit # kept in signature for API consistency
+ is_active = data_label != "Gain"
+ distances = np.linspace(0.5, max_distance_m, 60)
+
+ # Path loss models
+ pl_free = np.array([free_space_path_loss(freq_mhz, d) for d in distances])
+ pl_indoor = log_distance_path_loss(freq_mhz, distances, n=path_loss_exp)
+ wl = wall_penetration_loss(freq_mhz, wall_material)
+ pl_walls = pl_indoor + n_walls * wl
+ pl_shadow = pl_walls + shadow_fading_db
+
+ # Horizon gain per azimuth
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+ horizon_gain = gain_2d[theta_90_idx, :]
+
+ # Received power heatmap: Pr(phi, d) = Pt + G(phi) - PL(d)
+ effective_pt = 0.0 if is_active else pt_dbm
+ pr_map = effective_pt + horizon_gain[np.newaxis, :] - pl_walls[:, np.newaxis]
+
+ # Coverage range per azimuth
+ coverage_range = np.zeros(len(phi_deg))
+ fspl_1m = free_space_path_loss(freq_mhz, 1.0)
+ for i, g in enumerate(horizon_gain):
+ allowed_pl = effective_pt + g - pr_sensitivity_dbm
+ net_pl = allowed_pl - n_walls * wl
+ if path_loss_exp > 0 and net_pl > fspl_1m:
+ coverage_range[i] = 10 ** ((net_pl - fspl_1m) / (10 * path_loss_exp))
+ else:
+ coverage_range[i] = 0.5
+
+ # ---- Figure ----
+ fig = plt.figure(figsize=(18, 6.5))
+ fig_gs = fig.add_gridspec(1, 3, width_ratios=[1, 1.3, 1])
+
+ # --- Left: Path Loss vs Distance ---
+ ax_pl = fig.add_subplot(fig_gs[0])
+ ax_pl.plot(distances, pl_free, "b--", linewidth=1.5, label="Free Space (n=2)")
+ ax_pl.plot(distances, pl_indoor, "g-", linewidth=1.5,
+ label=f"Indoor (n={path_loss_exp})")
+ ax_pl.plot(distances, pl_walls, "r-", linewidth=2,
+ label=f"+ {n_walls}× {wall_material} ({wl:.1f} dB)")
+ ax_pl.plot(distances, pl_shadow, "r:", linewidth=1,
+ label=f"+ {shadow_fading_db:.0f} dB shadow margin")
+ ax_pl.set_xlabel("Distance (m)")
+ ax_pl.set_ylabel("Path Loss (dB)")
+ ax_pl.set_title("Path Loss Models", fontsize=11, fontweight="bold")
+ ax_pl.legend(fontsize=8, loc="upper left")
+ ax_pl.grid(True, alpha=0.3)
+ ax_pl.invert_yaxis()
+
+ # --- Center: Received Power Heatmap ---
+ ax_hm = fig.add_subplot(fig_gs[1])
+ extent = [phi_deg[0], phi_deg[-1], distances[0], distances[-1]]
+ vmin = pr_sensitivity_dbm - 20
+ vmax = float(np.max(pr_map))
+ im = ax_hm.imshow(
+ pr_map, aspect="auto", origin="lower", extent=extent,
+ cmap="RdYlGn", vmin=vmin, vmax=vmax,
+ )
+ ax_hm.contour(
+ phi_deg, distances, pr_map, levels=[pr_sensitivity_dbm],
+ colors=["black"], linewidths=[2], linestyles=["--"],
+ )
+ ax_hm.set_xlabel("Azimuth φ (°)")
+ ax_hm.set_ylabel("Distance (m)")
+ ax_hm.set_title(
+ f"Received Power at Horizon (θ=90°)\n{environment} — {freq_mhz} MHz",
+ fontsize=11, fontweight="bold",
+ )
+ cbar = fig.colorbar(im, ax=ax_hm, shrink=0.8)
+ cbar.set_label("Received Power (dBm)")
+
+ # --- Right: Coverage Range Polar ---
+ ax_polar = fig.add_subplot(fig_gs[2], projection="polar")
+ phi_rad = np.deg2rad(phi_deg)
+ ax_polar.fill(phi_rad, coverage_range, alpha=0.3, color="#4CAF50")
+ ax_polar.plot(phi_rad, coverage_range, "g-", linewidth=2, label="Coverage range")
+ ax_polar.set_title(
+ f"Coverage Range @ {pr_sensitivity_dbm} dBm\n{n_walls}× {wall_material}",
+ fontsize=11, fontweight="bold", pad=15,
+ )
+ ax_polar.set_theta_zero_location("N")
+ ax_polar.set_theta_direction(-1)
+
+ avg_range = np.mean(coverage_range)
+ min_range = np.min(coverage_range)
+ max_range = np.max(coverage_range)
+ summary = f"Avg: {avg_range:.1f}m Min: {min_range:.1f}m Max: {max_range:.1f}m"
+ fig.text(0.5, 0.02, summary, ha="center", fontsize=10,
+ bbox=dict(facecolor="white", edgecolor="gray", alpha=0.8,
+ boxstyle="round,pad=0.3"))
+
+ plt.tight_layout(rect=[0, 0.06, 1, 1])
+
+ if save_path:
+ fname = f"indoor_coverage_{freq_mhz}MHz.png"
+ fig.savefig(os.path.join(save_path, fname), dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ else:
+ plt.show()
+
+
+# ——— MULTIPATH FADING PLOTS ——————————————————————————————————————
+
+def plot_fading_analysis(
+ freq_mhz,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ pr_sensitivity_dbm=-98.0,
+ pt_dbm=0.0,
+ target_reliability=99.0,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+):
+ """
+ Fading analysis: CDF curves, fade margin chart, pattern with fading envelope,
+ and outage probability bar chart.
+ """
+ is_active = data_label != "Gain"
+
+ # Peak direction
+ peak_idx = np.unravel_index(np.argmax(gain_2d), gain_2d.shape)
+ peak_val = gain_2d[peak_idx]
+
+ # Power range for CDF
+ power_range = np.linspace(peak_val - 40, peak_val + 5, 200)
+
+ # CDF curves
+ cdf_rayleigh = rayleigh_cdf(power_range, peak_val)
+ cdf_rician_3 = rician_cdf(power_range, peak_val, K_factor=3)
+ cdf_rician_6 = rician_cdf(power_range, peak_val, K_factor=6)
+ cdf_rician_10 = rician_cdf(power_range, peak_val, K_factor=10)
+
+ # Fade margins for reliability range
+ reliability_range = np.linspace(50, 99.99, 100)
+ margin_rayleigh = [fade_margin_for_reliability(r, "rayleigh")
+ for r in reliability_range]
+ margin_rician_6 = [fade_margin_for_reliability(r, "rician", K=6)
+ for r in reliability_range]
+ margin_rician_10 = [fade_margin_for_reliability(r, "rician", K=10)
+ for r in reliability_range]
+
+ # Monte-Carlo fading at horizon
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+ horizon_slice = gain_2d[theta_90_idx:theta_90_idx + 1, :]
+ mean_db, std_db, p5_db = apply_statistical_fading(
+ horizon_slice, theta_deg[theta_90_idx:theta_90_idx + 1],
+ phi_deg, fading="rayleigh", realizations=500,
+ )
+
+ # ---- Figure ----
+ fig, axes = plt.subplots(2, 2, figsize=(14, 10))
+
+ # --- Top-left: CDF curves ---
+ ax = axes[0, 0]
+ ax.semilogy(power_range, 1 - cdf_rayleigh, "r-", linewidth=2, label="Rayleigh (NLOS)")
+ ax.semilogy(power_range, 1 - cdf_rician_3, "b--", linewidth=1.5, label="Rician K=3")
+ ax.semilogy(power_range, 1 - cdf_rician_6, "g--", linewidth=1.5, label="Rician K=6")
+ ax.semilogy(power_range, 1 - cdf_rician_10, "m--", linewidth=1.5, label="Rician K=10")
+ ax.axhline(y=0.01, color="gray", linestyle=":", alpha=0.7, label="99% reliability")
+ ax.set_xlabel(f"{data_label} ({data_unit})")
+ ax.set_ylabel("P(signal > x) — CCDF")
+ ax.set_title("Fading CCDF at Peak Direction", fontweight="bold")
+ ax.legend(fontsize=8)
+ ax.grid(True, alpha=0.3, which="both")
+ ax.set_ylim(1e-4, 1)
+
+ # --- Top-right: Fade Margin vs Reliability ---
+ ax = axes[0, 1]
+ ax.plot(reliability_range, margin_rayleigh, "r-", linewidth=2, label="Rayleigh")
+ ax.plot(reliability_range, margin_rician_6, "g--", linewidth=1.5, label="Rician K=6")
+ ax.plot(reliability_range, margin_rician_10, "m--", linewidth=1.5, label="Rician K=10")
+ ax.axvline(x=target_reliability, color="gray", linestyle=":", alpha=0.7)
+ target_margin_ray = fade_margin_for_reliability(target_reliability, "rayleigh")
+ ax.plot(target_reliability, target_margin_ray, "ro", markersize=8)
+ ax.annotate(f"{target_margin_ray:.1f} dB",
+ (target_reliability, target_margin_ray),
+ textcoords="offset points", xytext=(10, 5), fontsize=9)
+ ax.set_xlabel("Reliability (%)")
+ ax.set_ylabel("Required Fade Margin (dB)")
+ ax.set_title("Fade Margin vs Reliability", fontweight="bold")
+ ax.legend(fontsize=8)
+ ax.grid(True, alpha=0.3)
+
+ # --- Bottom-left: Pattern with fading envelope at horizon ---
+ ax = axes[1, 0]
+ mean_flat = mean_db.flatten()
+ std_flat = std_db.flatten()
+ p5_flat = p5_db.flatten()
+ ax.fill_between(phi_deg, mean_flat - std_flat, mean_flat + std_flat,
+ alpha=0.2, color="blue", label="±1σ envelope")
+ ax.plot(phi_deg, mean_flat, "b-", linewidth=2, label="Mean (faded)")
+ ax.plot(phi_deg, gain_2d[theta_90_idx, :], "k--", linewidth=1,
+ label="Free-space")
+ ax.plot(phi_deg, p5_flat, "r:", linewidth=1, label="5th percentile")
+ ax.set_xlabel("Azimuth φ (°)")
+ ax.set_ylabel(f"{data_label} ({data_unit})")
+ ax.set_title("Rayleigh Fading Envelope at θ=90°", fontweight="bold")
+ ax.legend(fontsize=8)
+ ax.grid(True, alpha=0.3)
+
+ # --- Bottom-right: Outage probability per azimuth ---
+ ax = axes[1, 1]
+ horizon_vals = gain_2d[theta_90_idx, :]
+ effective_pt = 0.0 if is_active else pt_dbm
+ outage_prob = rayleigh_cdf(
+ np.full_like(horizon_vals, pr_sensitivity_dbm),
+ effective_pt + horizon_vals,
+ )
+ bar_width = np.mean(np.diff(phi_deg)) * 0.8 if len(phi_deg) > 1 else 3.0
+ colors_out = []
+ for op in outage_prob:
+ if op < 0.01:
+ colors_out.append("#4CAF50")
+ elif op < 0.1:
+ colors_out.append("#FFC107")
+ else:
+ colors_out.append("#F44336")
+ ax.bar(phi_deg, outage_prob * 100, width=bar_width,
+ color=colors_out, edgecolor="gray", linewidth=0.3)
+ ax.axhline(y=1.0, color="r", linestyle="--", linewidth=1, label="1% outage")
+ ax.set_xlabel("Azimuth φ (°)")
+ ax.set_ylabel("Outage Probability (%)")
+ ax.set_title("Rayleigh Outage per Azimuth (at Rx Sensitivity)",
+ fontweight="bold")
+ ax.legend(fontsize=8)
+ ax.grid(True, alpha=0.3)
+
+ fig.suptitle(f"Multipath Fading Analysis — {freq_mhz} MHz",
+ fontsize=14, fontweight="bold")
+ plt.tight_layout()
+
+ if save_path:
+ fname = f"fading_analysis_{freq_mhz}MHz.png"
+ fig.savefig(os.path.join(save_path, fname), dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ else:
+ plt.show()
+
+
+# ——— ENHANCED MIMO PLOTS —————————————————————————————————————————
+
+def plot_mimo_analysis(
+ ecc_values,
+ freq_list,
+ gain_data_list,
+ theta_deg,
+ phi_deg,
+ snr_db=20,
+ fading="rayleigh",
+ K=10,
+ save_path=None,
+):
+ """
+ MIMO analysis: capacity curves, combining gain comparison, pattern overlay.
+ """
+ _ = freq_list # reserved for per-frequency analysis in future
+ fig = plt.figure(figsize=(18, 6.5))
+ fig_gs = fig.add_gridspec(1, 3, width_ratios=[1, 1, 1])
+
+ # --- Left: Capacity vs SNR ---
+ ax_cap = fig.add_subplot(fig_gs[0])
+ ecc_median = float(np.median(ecc_values)) if len(ecc_values) > 0 else 0.3
+ snr_axis, siso_cap, awgn_cap, fading_cap = mimo_capacity_vs_snr(
+ ecc_median, snr_range_db=(-5, 30), fading=fading, K=K,
+ )
+ ax_cap.plot(snr_axis, siso_cap, "k--", linewidth=1.5, label="SISO")
+ ax_cap.plot(snr_axis, awgn_cap, "b-", linewidth=2, label="2×2 AWGN")
+ ax_cap.plot(snr_axis, fading_cap, "r-", linewidth=2,
+ label=f"2×2 {fading.capitalize()}")
+ ax_cap.axvline(x=snr_db, color="gray", linestyle=":", alpha=0.7)
+ ax_cap.set_xlabel("SNR (dB)")
+ ax_cap.set_ylabel("Capacity (b/s/Hz)")
+ ax_cap.set_title(f"Channel Capacity (ECC={ecc_median:.3f})",
+ fontweight="bold")
+ ax_cap.legend(fontsize=8)
+ ax_cap.grid(True, alpha=0.3)
+
+ # --- Center: Combining Gain Comparison ---
+ ax_comb = fig.add_subplot(fig_gs[1])
+ if len(gain_data_list) >= 2:
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+ n_phi = len(phi_deg)
+ mrc_imp = np.zeros(n_phi)
+ egc_imp = np.zeros(n_phi)
+ sc_imp = np.zeros(n_phi)
+
+ for i in range(n_phi):
+ element_gains = [g[theta_90_idx, i] for g in gain_data_list]
+ _, mrc_imp[i] = combining_gain(element_gains, method="mrc")
+ _, egc_imp[i] = combining_gain(element_gains, method="egc")
+ _, sc_imp[i] = combining_gain(element_gains, method="sc")
+
+ ax_comb.plot(phi_deg, mrc_imp, "b-", linewidth=2, label="MRC")
+ ax_comb.plot(phi_deg, egc_imp, "g--", linewidth=1.5, label="EGC")
+ ax_comb.plot(phi_deg, sc_imp, "r:", linewidth=1.5, label="Selection")
+ ax_comb.set_xlabel("Azimuth φ (°)")
+ ax_comb.set_ylabel("Combining Improvement (dB)")
+ ax_comb.set_title("Combining Gain at θ=90°", fontweight="bold")
+ ax_comb.legend(fontsize=8)
+ ax_comb.grid(True, alpha=0.3)
+ else:
+ ax_comb.text(0.5, 0.5, "Requires 2+ antenna\npatterns loaded",
+ ha="center", va="center", fontsize=12,
+ transform=ax_comb.transAxes)
+ ax_comb.set_title("Combining Gain", fontweight="bold")
+
+ # --- Right: Pattern Correlation (overlaid polar) ---
+ ax_polar = fig.add_subplot(fig_gs[2], projection="polar")
+ phi_rad = np.deg2rad(phi_deg)
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+
+ colors_list = ["#4A90E2", "#E63946", "#4CAF50", "#FFC107"]
+ for idx, g2d in enumerate(gain_data_list[:4]):
+ color = colors_list[idx % len(colors_list)]
+ pattern = g2d[theta_90_idx, :]
+ pattern_norm = pattern - np.min(pattern)
+ ax_polar.plot(phi_rad, pattern_norm, color=color, linewidth=1.5,
+ label=f"Ant {idx + 1}")
+
+ ax_polar.set_title("Pattern Overlay (θ=90°)\nNormalized",
+ fontweight="bold", pad=15)
+ ax_polar.set_theta_zero_location("N")
+ ax_polar.set_theta_direction(-1)
+ ax_polar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=8)
+
+ fig.suptitle("MIMO / Diversity Analysis", fontsize=14, fontweight="bold")
+ plt.tight_layout()
+
+ if save_path:
+ fname = "mimo_analysis.png"
+ fig.savefig(os.path.join(save_path, fname), dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ else:
+ plt.show()
+
+
+# ——— WEARABLE / MEDICAL DEVICE PLOTS —————————————————————————————
+
+def plot_wearable_assessment(
+ freq_mhz,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ body_positions=None,
+ tx_power_mw=1.0,
+ num_devices=20,
+ room_size=(10, 10, 3),
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+):
+ """
+ Wearable/medical device assessment: body position comparison,
+ overlaid patterns, and dense device SINR analysis.
+ """
+ if body_positions is None:
+ body_positions = ["wrist", "chest", "hip", "head"]
+
+ # Body-worn analysis
+ bw_results = body_worn_pattern_analysis(
+ gain_2d, theta_deg, phi_deg, freq_mhz, body_positions,
+ )
+
+ # Dense device interference
+ tx_dbm = 10 * np.log10(max(tx_power_mw, 0.001))
+ avg_sinr, sinr_dist, noise_floor = dense_device_interference(
+ num_devices, tx_dbm, freq_mhz, room_size_m=room_size,
+ )
+
+ # ---- Figure ----
+ fig = plt.figure(figsize=(18, 7))
+ fig_gs = fig.add_gridspec(1, 3, width_ratios=[1.3, 1, 1])
+
+ # --- Left: Body position comparison table ---
+ ax_table = fig.add_subplot(fig_gs[0])
+ ax_table.axis("off")
+
+ table_data = []
+ for pos in body_positions:
+ if pos not in bw_results:
+ continue
+ r = bw_results[pos]
+ table_data.append([
+ pos.capitalize(),
+ f"{r['avg_gain_db']:.1f} {data_unit}",
+ f"{r['trp_delta_db']:+.1f} dB",
+ f"{r['peak_delta_db']:+.1f} dB",
+ ])
+
+ # Add free-space reference
+ ref_lin = 10 ** (gain_2d / 10.0)
+ sin_w = np.sin(np.deg2rad(theta_deg))
+ ref_avg = 10 * np.log10(np.sum(ref_lin * sin_w[:, np.newaxis])
+ / (np.sum(sin_w) * len(phi_deg)))
+ table_data.insert(0, ["Free Space", f"{ref_avg:.1f} {data_unit}", "ref", "ref"])
+
+ table = ax_table.table(
+ cellText=table_data,
+ colLabels=["Position", f"Avg {data_label}", "TRP Δ", "Peak Δ"],
+ cellLoc="center",
+ loc="center",
+ colWidths=[0.25, 0.25, 0.25, 0.25],
+ )
+ table.auto_set_font_size(False)
+ table.set_fontsize(10)
+ table.scale(1, 1.6)
+
+ for j in range(4):
+ table[0, j].set_facecolor("#4A90E2")
+ table[0, j].set_text_props(color="white", fontweight="bold")
+ for j in range(4):
+ table[1, j].set_facecolor("#E8F0FE")
+
+ ax_table.set_title(
+ f"Body-Worn Performance — {freq_mhz} MHz",
+ fontsize=12, fontweight="bold", pad=20,
+ )
+
+ # --- Center: Overlaid polar patterns ---
+ ax_polar = fig.add_subplot(fig_gs[1], projection="polar")
+ phi_rad = np.deg2rad(phi_deg)
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+
+ fs_pattern = gain_2d[theta_90_idx, :]
+ ax_polar.plot(phi_rad, fs_pattern - np.min(fs_pattern), "k-",
+ linewidth=2, label="Free Space")
+
+ colors_pos = {
+ "wrist": "#4A90E2", "chest": "#E63946",
+ "hip": "#4CAF50", "head": "#FFC107",
+ }
+ for pos in body_positions:
+ if pos not in bw_results:
+ continue
+ pattern = bw_results[pos]["pattern"][theta_90_idx, :]
+ ax_polar.plot(phi_rad, pattern - np.min(fs_pattern),
+ color=colors_pos.get(pos, "gray"),
+ linewidth=1.5, label=pos.capitalize())
+
+ ax_polar.set_title("Pattern at θ=90°\n(Normalized)", fontweight="bold",
+ pad=15)
+ ax_polar.set_theta_zero_location("N")
+ ax_polar.set_theta_direction(-1)
+ ax_polar.legend(loc="upper right", bbox_to_anchor=(1.35, 1.15), fontsize=8)
+
+ # --- Right: Dense device SINR distribution ---
+ ax_sinr = fig.add_subplot(fig_gs[2])
+ ax_sinr.hist(sinr_dist, bins=30, color="#4A90E2", edgecolor="white",
+ alpha=0.8, density=True)
+ ax_sinr.axvline(x=avg_sinr, color="red", linestyle="--", linewidth=2,
+ label=f"Mean SINR: {avg_sinr:.1f} dB")
+ ax_sinr.axvline(x=0, color="gray", linestyle=":", linewidth=1,
+ label="0 dB (breakeven)")
+ ax_sinr.set_xlabel("SINR (dB)")
+ ax_sinr.set_ylabel("Probability Density")
+ ax_sinr.set_title(
+ f"Dense Device Coexistence\n{num_devices} devices in "
+ f"{room_size[0]}×{room_size[1]}×{room_size[2]}m",
+ fontweight="bold",
+ )
+ ax_sinr.legend(fontsize=8)
+ ax_sinr.grid(True, alpha=0.3)
+ ax_sinr.annotate(f"Noise floor: {noise_floor:.0f} dBm",
+ xy=(0.02, 0.95), xycoords="axes fraction", fontsize=8,
+ bbox=dict(facecolor="white", edgecolor="gray", alpha=0.8))
+
+ plt.tight_layout()
+
+ if save_path:
+ fname = f"wearable_assessment_{freq_mhz}MHz.png"
+ fig.savefig(os.path.join(save_path, fname), dpi=300, bbox_inches="tight")
+ plt.close(fig)
+ else:
+ plt.show()
+
+
+# ——— DISPATCHER FOR ADVANCED ANALYSIS PLOTS ——————————————————————
+
+def generate_advanced_analysis_plots(
+ theta_deg,
+ phi_deg,
+ gain_2d,
+ frequency,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=None,
+ # Link Budget params
+ link_budget_enabled=False,
+ lb_pt_dbm=0.0,
+ lb_pr_dbm=-98.0,
+ lb_gr_dbi=0.0,
+ lb_path_loss_exp=2.0,
+ lb_misc_loss_db=10.0,
+ lb_target_range_m=5.0,
+ # Indoor params
+ indoor_enabled=False,
+ indoor_environment="Office",
+ indoor_path_loss_exp=3.0,
+ indoor_n_walls=1,
+ indoor_wall_material="drywall",
+ indoor_shadow_fading_db=5.0,
+ indoor_max_distance_m=30.0,
+ # Fading params
+ fading_enabled=False,
+ fading_pr_sensitivity_dbm=-98.0,
+ fading_pt_dbm=0.0,
+ fading_target_reliability=99.0,
+ # Wearable params
+ wearable_enabled=False,
+ wearable_body_positions=None,
+ wearable_tx_power_mw=1.0,
+ wearable_num_devices=20,
+ wearable_room_size=(10, 10, 3),
+):
+ """
+ Dispatcher for all advanced analysis plots. Called from callbacks/batch
+ after pattern processing, analogous to generate_maritime_plots().
+ """
+ if link_budget_enabled:
+ plot_link_budget_summary(
+ frequency, gain_2d, theta_deg, phi_deg,
+ pt_dbm=lb_pt_dbm, pr_dbm=lb_pr_dbm, gr_dbi=lb_gr_dbi,
+ path_loss_exp=lb_path_loss_exp, misc_loss_db=lb_misc_loss_db,
+ target_range_m=lb_target_range_m,
+ data_label=data_label, data_unit=data_unit, save_path=save_path,
+ )
+
+ if indoor_enabled:
+ plot_indoor_coverage_map(
+ frequency, gain_2d, theta_deg, phi_deg,
+ pt_dbm=lb_pt_dbm, pr_sensitivity_dbm=lb_pr_dbm,
+ environment=indoor_environment, path_loss_exp=indoor_path_loss_exp,
+ n_walls=indoor_n_walls, wall_material=indoor_wall_material,
+ shadow_fading_db=indoor_shadow_fading_db,
+ max_distance_m=indoor_max_distance_m,
+ data_label=data_label, data_unit=data_unit, save_path=save_path,
+ )
+
+ if fading_enabled:
+ plot_fading_analysis(
+ frequency, gain_2d, theta_deg, phi_deg,
+ pr_sensitivity_dbm=fading_pr_sensitivity_dbm,
+ pt_dbm=fading_pt_dbm,
+ target_reliability=fading_target_reliability,
+ data_label=data_label, data_unit=data_unit, save_path=save_path,
+ )
+
+ if wearable_enabled:
+ plot_wearable_assessment(
+ frequency, gain_2d, theta_deg, phi_deg,
+ body_positions=wearable_body_positions,
+ tx_power_mw=wearable_tx_power_mw,
+ num_devices=wearable_num_devices,
+ room_size=wearable_room_size,
+ data_label=data_label, data_unit=data_unit, save_path=save_path,
+ )
diff --git a/tests/test_advanced_analysis.py b/tests/test_advanced_analysis.py
new file mode 100644
index 0000000..3d7c1b2
--- /dev/null
+++ b/tests/test_advanced_analysis.py
@@ -0,0 +1,440 @@
+"""
+Tests for advanced analysis functions in plot_antenna.calculations
+
+Covers:
+- Link Budget: FSPL, Friis range, link margin, range-vs-azimuth
+- Indoor Propagation: Log-distance PL, ITU P.1238, wall penetration
+- Fading: Rayleigh/Rician CDF, fade margin, statistical fading
+- MIMO: Combining gain, capacity-vs-SNR, MEG
+- Wearable: Body-worn analysis, dense device interference, SAR, WBAN
+- Data tables: Protocol presets, environment presets, body positions
+"""
+
+import pytest
+import numpy as np
+
+from plot_antenna.calculations import (
+ # Link Budget
+ free_space_path_loss,
+ friis_range_estimate,
+ min_tx_gain_for_range,
+ link_margin,
+ range_vs_azimuth,
+ # Indoor Propagation
+ log_distance_path_loss,
+ itu_indoor_path_loss,
+ wall_penetration_loss,
+ apply_indoor_propagation,
+ # Fading
+ rayleigh_cdf,
+ rician_cdf,
+ fade_margin_for_reliability,
+ apply_statistical_fading,
+ delay_spread_estimate,
+ # MIMO
+ envelope_correlation_from_patterns,
+ combining_gain,
+ mimo_capacity_vs_snr,
+ mean_effective_gain_mimo,
+ # Wearable / Medical
+ body_worn_pattern_analysis,
+ dense_device_interference,
+ sar_exposure_estimate,
+ wban_link_budget,
+ # Data tables
+ PROTOCOL_PRESETS,
+ ENVIRONMENT_PRESETS,
+ BODY_POSITIONS,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+def _make_isotropic_pattern(n_theta=37, n_phi=73, gain_dbi=0.0):
+ """Create a simple isotropic 2D gain grid for testing."""
+ theta = np.linspace(0, 180, n_theta)
+ phi = np.linspace(0, 360, n_phi)
+ gain_2d = np.full((n_theta, n_phi), gain_dbi)
+ return theta, phi, gain_2d
+
+
+# ---------------------------------------------------------------------------
+# 1. Import smoke test
+# ---------------------------------------------------------------------------
+class TestImports:
+ """Verify all new functions are importable."""
+
+ def test_link_budget_imports(self):
+ assert callable(free_space_path_loss)
+ assert callable(friis_range_estimate)
+ assert callable(min_tx_gain_for_range)
+ assert callable(link_margin)
+ assert callable(range_vs_azimuth)
+
+ def test_indoor_imports(self):
+ assert callable(log_distance_path_loss)
+ assert callable(itu_indoor_path_loss)
+ assert callable(wall_penetration_loss)
+ assert callable(apply_indoor_propagation)
+
+ def test_fading_imports(self):
+ assert callable(rayleigh_cdf)
+ assert callable(rician_cdf)
+ assert callable(fade_margin_for_reliability)
+ assert callable(apply_statistical_fading)
+ assert callable(delay_spread_estimate)
+
+ def test_mimo_imports(self):
+ assert callable(envelope_correlation_from_patterns)
+ assert callable(combining_gain)
+ assert callable(mimo_capacity_vs_snr)
+ assert callable(mean_effective_gain_mimo)
+
+ def test_wearable_imports(self):
+ assert callable(body_worn_pattern_analysis)
+ assert callable(dense_device_interference)
+ assert callable(sar_exposure_estimate)
+ assert callable(wban_link_budget)
+
+ def test_data_tables(self):
+ assert isinstance(PROTOCOL_PRESETS, dict)
+ assert isinstance(ENVIRONMENT_PRESETS, dict)
+ assert isinstance(BODY_POSITIONS, dict)
+ assert "BLE 1Mbps" in PROTOCOL_PRESETS
+ assert "Office" in ENVIRONMENT_PRESETS
+ assert "wrist" in BODY_POSITIONS
+
+
+# ---------------------------------------------------------------------------
+# 2. Link Budget
+# ---------------------------------------------------------------------------
+class TestLinkBudget:
+ """Validate link budget / Friis calculations."""
+
+ def test_fspl_2450mhz_1m(self):
+ """FSPL at 2450 MHz, 1m should be ~40.2 dB."""
+ fspl = free_space_path_loss(2450.0, 1.0)
+ assert 39.5 < fspl < 41.0
+
+ def test_fspl_increases_with_distance(self):
+ fspl_1m = free_space_path_loss(2450.0, 1.0)
+ fspl_10m = free_space_path_loss(2450.0, 10.0)
+ assert fspl_10m > fspl_1m
+ # 10x distance -> +20 dB in free space
+ assert abs((fspl_10m - fspl_1m) - 20.0) < 0.5
+
+ def test_friis_range_positive(self):
+ """Friis range should return a positive finite distance."""
+ d = friis_range_estimate(
+ pt_dbm=0.0, pr_dbm=-98.0, gt_dbi=-10.0, gr_dbi=-10.0,
+ freq_mhz=2450.0, path_loss_exp=4.0, misc_loss_db=0.0,
+ )
+ assert d > 0 and np.isfinite(d)
+
+ def test_friis_range_free_space_longer(self):
+ """Free-space (n=2) should give longer range than n=4."""
+ d_fs = friis_range_estimate(
+ pt_dbm=0.0, pr_dbm=-98.0, gt_dbi=0.0, gr_dbi=0.0,
+ freq_mhz=2450.0, path_loss_exp=2.0,
+ )
+ d_indoor = friis_range_estimate(
+ pt_dbm=0.0, pr_dbm=-98.0, gt_dbi=0.0, gr_dbi=0.0,
+ freq_mhz=2450.0, path_loss_exp=4.0,
+ )
+ assert d_fs > d_indoor
+
+ def test_friis_more_power_more_range(self):
+ """Higher Tx power should yield longer range."""
+ d_low = friis_range_estimate(0.0, -98.0, 0.0, 0.0, 2450.0)
+ d_high = friis_range_estimate(20.0, -98.0, 0.0, 0.0, 2450.0)
+ assert d_high > d_low
+
+ def test_link_margin_positive_close(self):
+ """At 1m with decent gain, margin should be positive."""
+ margin = link_margin(
+ pt_dbm=0.0, gt_dbi=0.0, gr_dbi=0.0, freq_mhz=2450.0,
+ distance_m=1.0, path_loss_exp=2.0, misc_loss_db=0.0,
+ pr_sensitivity_dbm=-98.0,
+ )
+ assert margin > 0
+
+ def test_link_margin_decreases_with_distance(self):
+ m_1m = link_margin(0.0, 0.0, 0.0, 2450.0, 1.0)
+ m_100m = link_margin(0.0, 0.0, 0.0, 2450.0, 100.0)
+ assert m_1m > m_100m
+
+ def test_min_tx_gain_for_range_finite(self):
+ """Required Gt for 5m target should be finite."""
+ gt = min_tx_gain_for_range(
+ target_range_m=5.0, pt_dbm=0.0, pr_dbm=-98.0, gr_dbi=0.0,
+ freq_mhz=2450.0, path_loss_exp=2.0,
+ )
+ assert np.isfinite(gt)
+
+ def test_range_vs_azimuth_returns_arrays(self):
+ """range_vs_azimuth returns (range_m, horizon_gain) arrays."""
+ theta, phi, gain_2d = _make_isotropic_pattern()
+ range_m, horizon_gain = range_vs_azimuth(
+ gain_2d, theta, phi, 2450.0, 0.0, -98.0, 0.0,
+ )
+ assert len(range_m) == len(phi)
+ assert len(horizon_gain) == len(phi)
+
+ def test_range_vs_azimuth_positive_ranges(self):
+ """With reasonable gain, ranges should be positive."""
+ theta, phi, gain_2d = _make_isotropic_pattern(gain_dbi=5.0)
+ range_m, _ = range_vs_azimuth(
+ gain_2d, theta, phi, 2450.0, 0.0, -98.0, 0.0,
+ )
+ assert np.all(range_m >= 0)
+
+
+# ---------------------------------------------------------------------------
+# 3. Indoor Propagation
+# ---------------------------------------------------------------------------
+class TestIndoorPropagation:
+ """Validate indoor propagation models."""
+
+ def test_log_distance_pl_increases(self):
+ """Path loss should increase with distance."""
+ pl_1 = log_distance_path_loss(2450.0, 1.0, n=3.0)
+ pl_10 = log_distance_path_loss(2450.0, 10.0, n=3.0)
+ assert pl_10 > pl_1
+
+ def test_log_distance_pl_exponent_effect(self):
+ """Higher exponent -> more loss at same distance."""
+ pl_n2 = log_distance_path_loss(2450.0, 10.0, n=2.0)
+ pl_n4 = log_distance_path_loss(2450.0, 10.0, n=4.0)
+ assert pl_n4 > pl_n2
+
+ def test_itu_indoor_office(self):
+ """ITU P.1238 office model should give reasonable loss."""
+ pl = itu_indoor_path_loss(2450.0, 10.0, environment="office")
+ assert 30 < pl < 100
+
+ def test_wall_penetration_loss_materials(self):
+ """Concrete should attenuate more than drywall."""
+ loss_drywall = wall_penetration_loss(2450.0, "drywall")
+ loss_concrete = wall_penetration_loss(2450.0, "concrete")
+ loss_metal = wall_penetration_loss(2450.0, "metal")
+ assert loss_concrete > loss_drywall
+ assert loss_metal > loss_concrete
+
+ def test_apply_indoor_propagation_shape(self):
+ """Output shape should match input pattern shape."""
+ theta, phi, gain_2d = _make_isotropic_pattern()
+ # Signature: (gain_2d, theta_deg, phi_deg, freq_mhz, pt_dbm, distance_m, ...)
+ pr_2d, path_loss_total = apply_indoor_propagation(
+ gain_2d, theta, phi, 2450.0, 0.0, 5.0,
+ )
+ assert pr_2d.shape == gain_2d.shape
+ assert path_loss_total > 0
+
+
+# ---------------------------------------------------------------------------
+# 4. Fading
+# ---------------------------------------------------------------------------
+class TestFading:
+ """Validate fading CDF and fade margin calculations."""
+
+ def test_rayleigh_cdf_monotonic(self):
+ """CDF should be monotonically non-decreasing."""
+ powers = np.linspace(-30, 10, 50)
+ cdf_vals = [rayleigh_cdf(p) for p in powers]
+ for i in range(1, len(cdf_vals)):
+ assert cdf_vals[i] >= cdf_vals[i - 1] - 1e-10
+
+ def test_rayleigh_cdf_bounds(self):
+ """CDF should be between 0 and 1."""
+ assert 0 <= rayleigh_cdf(-40.0) <= 1
+ assert 0 <= rayleigh_cdf(20.0) <= 1
+
+ def test_rician_cdf_callable(self):
+ """Rician CDF should return a value between 0 and 1."""
+ val = rician_cdf(0.0, mean_power_db=0.0, K_factor=10.0)
+ assert 0 <= val <= 1
+
+ def test_rician_cdf_k0_matches_rayleigh(self):
+ """Rician with K=0 should approximate Rayleigh."""
+ p = 0.0
+ # K_factor=0 -> falls back to rayleigh_cdf
+ cdf_ric = rician_cdf(p, K_factor=0.0)
+ cdf_ray = rayleigh_cdf(p)
+ assert abs(cdf_ric - cdf_ray) < 0.01
+
+ def test_fade_margin_rayleigh_99pct(self):
+ """99% Rayleigh fade margin should be ~20 dB."""
+ fm = fade_margin_for_reliability(99.0, fading="rayleigh")
+ assert 15 < fm < 25, f"Expected ~20 dB, got {fm:.1f} dB"
+
+ def test_fade_margin_increases_with_reliability(self):
+ """Higher reliability -> higher fade margin."""
+ fm_90 = fade_margin_for_reliability(90.0, fading="rayleigh")
+ fm_99 = fade_margin_for_reliability(99.0, fading="rayleigh")
+ assert fm_99 > fm_90
+
+ def test_apply_statistical_fading_output(self):
+ """Monte-Carlo fading should return 3 arrays (mean, std, outage)."""
+ theta, phi, gain_2d = _make_isotropic_pattern(n_theta=19, n_phi=37)
+ mean_db, std_db, outage_5pct_db = apply_statistical_fading(
+ gain_2d, theta, phi, fading="rayleigh", realizations=100,
+ )
+ assert mean_db.shape == gain_2d.shape
+ assert std_db.shape == gain_2d.shape
+ assert outage_5pct_db.shape == gain_2d.shape
+
+ def test_delay_spread_positive(self):
+ """Delay spread should be positive for any environment."""
+ ds = delay_spread_estimate(10.0, "indoor")
+ assert ds > 0
+
+
+# ---------------------------------------------------------------------------
+# 5. MIMO
+# ---------------------------------------------------------------------------
+class TestMIMO:
+ """Validate MIMO / diversity calculations."""
+
+ def test_combining_gain_mrc(self):
+ """MRC of two equal-power branches -> ~3 dB gain."""
+ gains = np.array([0.0, 0.0]) # two 0 dBi branches
+ combined, improvement = combining_gain(gains, method="mrc")
+ assert improvement > 2.0 # MRC should give ~3 dB
+
+ def test_combining_gain_sc(self):
+ """Selection combining picks the best branch."""
+ gains = np.array([5.0, 0.0, -3.0])
+ combined, improvement = combining_gain(gains, method="sc")
+ assert combined == 5.0
+
+ def test_mimo_capacity_vs_snr_shape(self):
+ """Capacity curve returns (snr, siso_cap, awgn_cap, fading_cap)."""
+ snr_axis, siso_cap, awgn_cap, fading_cap = mimo_capacity_vs_snr(
+ ecc=0.1, snr_range_db=(-5, 25), num_points=31,
+ )
+ assert len(snr_axis) == 31
+ assert len(siso_cap) == 31
+ assert len(awgn_cap) == 31
+ assert len(fading_cap) == 31
+ # Capacity should generally increase with SNR
+ assert siso_cap[-1] > siso_cap[0]
+
+ def test_mimo_capacity_low_ecc_better(self):
+ """Lower ECC (better isolation) -> higher fading capacity."""
+ _, _, _, fading_low = mimo_capacity_vs_snr(
+ ecc=0.1, snr_range_db=(20, 20), num_points=1,
+ )
+ _, _, _, fading_high = mimo_capacity_vs_snr(
+ ecc=0.9, snr_range_db=(20, 20), num_points=1,
+ )
+ assert fading_low[0] >= fading_high[0]
+
+ def test_mean_effective_gain_shape(self):
+ """MEG should return one value per antenna element."""
+ theta, phi, g1 = _make_isotropic_pattern()
+ _, _, g2 = _make_isotropic_pattern(gain_dbi=-3.0)
+ meg = mean_effective_gain_mimo([g1, g2], theta, phi, xpr_db=6.0)
+ assert len(meg) == 2
+
+
+# ---------------------------------------------------------------------------
+# 6. Wearable / Medical
+# ---------------------------------------------------------------------------
+class TestWearable:
+ """Validate wearable / medical device functions."""
+
+ def test_body_worn_returns_dict(self):
+ """Body-worn analysis should return per-position results."""
+ theta, phi, gain_2d = _make_isotropic_pattern()
+ result = body_worn_pattern_analysis(
+ gain_2d, theta, phi, 2450.0,
+ body_positions=["wrist", "chest"],
+ )
+ assert isinstance(result, dict)
+ assert "wrist" in result
+ assert "chest" in result
+
+ def test_body_worn_shows_degradation(self):
+ """Body shadowing should reduce peak gain."""
+ theta, phi, gain_2d = _make_isotropic_pattern(gain_dbi=5.0)
+ result = body_worn_pattern_analysis(
+ gain_2d, theta, phi, 2450.0, body_positions=["chest"],
+ )
+ # The shadowed pattern should have lower peak than original
+ shadowed_peak = np.max(result["chest"]["pattern"])
+ original_peak = np.max(gain_2d)
+ assert shadowed_peak <= original_peak + 0.1 # small tolerance
+
+ def test_dense_device_interference_returns_tuple(self):
+ """dense_device_interference returns (avg_sinr, sinr_dist, noise_floor)."""
+ avg_sinr, sinr_dist, noise_floor = dense_device_interference(
+ num_devices=20, tx_power_dbm=0.0, freq_mhz=2450.0,
+ )
+ assert np.isfinite(avg_sinr)
+ assert len(sinr_dist) > 0
+ assert noise_floor < 0 # noise floor is negative dBm
+
+ def test_sar_exposure_returns_tuple(self):
+ """SAR returns (sar, fcc_limit, icnirp_limit, compliant)."""
+ sar, fcc_limit, icnirp_limit, compliant = sar_exposure_estimate(
+ tx_power_mw=1.0, antenna_gain_dbi=0.0,
+ distance_cm=1.0, freq_mhz=2450.0,
+ )
+ assert sar > 0
+ assert fcc_limit == 1.6
+ assert icnirp_limit == 2.0
+ assert isinstance(compliant, bool)
+
+ def test_sar_low_power_compliant(self):
+ """Very low power at distance should be compliant."""
+ sar, _, _, compliant = sar_exposure_estimate(
+ tx_power_mw=0.01, antenna_gain_dbi=-10.0,
+ distance_cm=10.0, freq_mhz=2450.0,
+ )
+ assert compliant is True
+
+ def test_wban_link_budget_returns_tuple(self):
+ """WBAN returns (path_loss_db, received_power_dbm)."""
+ path_loss, rx_power = wban_link_budget(
+ tx_power_dbm=0.0, freq_mhz=2450.0,
+ body_channel="on_body", distance_cm=30,
+ )
+ assert path_loss > 0
+ assert rx_power < 0 # received power should be negative dBm
+
+
+# ---------------------------------------------------------------------------
+# 7. Protocol & Environment Presets
+# ---------------------------------------------------------------------------
+class TestPresets:
+ """Validate preset data tables."""
+
+ def test_protocol_presets_have_tuples(self):
+ """Each protocol preset should be a 3-tuple (rx_sens, tx_power, freq)."""
+ for name, vals in PROTOCOL_PRESETS.items():
+ assert len(vals) == 3, f"Preset '{name}' should have 3 values"
+
+ def test_protocol_ble_1mbps(self):
+ rx_sens, tx_pwr, freq = PROTOCOL_PRESETS["BLE 1Mbps"]
+ assert rx_sens == -98.0
+ assert tx_pwr == 0.0
+ assert freq == 2450.0
+
+ def test_environment_presets_have_tuples(self):
+ """Each environment preset should be a 5-tuple."""
+ for name, vals in ENVIRONMENT_PRESETS.items():
+ assert len(vals) == 5, f"Environment '{name}' should have 5 values"
+
+ def test_environment_office(self):
+ n, sigma, fading_m, k, walls = ENVIRONMENT_PRESETS["Office"]
+ assert n == 3.0
+ assert sigma == 5.0
+ assert fading_m == "rician"
+
+ def test_body_positions_have_required_keys(self):
+ """Each body position should have axis, cone_deg, tissue_cm."""
+ for pos, data in BODY_POSITIONS.items():
+ assert "axis" in data, f"Position '{pos}' missing 'axis'"
+ assert "cone_deg" in data, f"Position '{pos}' missing 'cone_deg'"
+ assert "tissue_cm" in data, f"Position '{pos}' missing 'tissue_cm'"
From fdb0710487536569c91f3017d5827fbfed6a70f4 Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Wed, 11 Feb 2026 21:20:40 -0600
Subject: [PATCH 2/6] fix: Improve bulk processing reporting, non-blocking
updates, matplotlib compat
- Bulk processing now reports per-job/per-file outcomes instead of blanket success
- Update checker runs in background thread (startup-safe, non-blocking)
- Replace deprecated cm.get_cmap with plt.get_cmap for matplotlib compat
- Add fixture-based parser tests replacing skipped TODOs
- Add batch failure regression integration tests
- Add Rician fading test coverage
Co-Authored-By: Claude Opus 4.6
---
plot_antenna/file_utils.py | 650 +++++++++++---------
plot_antenna/gui/callbacks_mixin.py | 155 ++---
plot_antenna/gui/dialogs_mixin.py | 87 ++-
plot_antenna/gui/main_window.py | 95 ++-
plot_antenna/gui/tools_mixin.py | 197 ++++--
plot_antenna/plotting.py | 344 ++++++++---
tests/test_advanced_analysis.py | 13 +
tests/test_advanced_analysis_integration.py | 617 +++++++++++++++++++
tests/test_file_utils.py | 158 ++++-
9 files changed, 1754 insertions(+), 562 deletions(-)
create mode 100644 tests/test_advanced_analysis_integration.py
diff --git a/plot_antenna/file_utils.py b/plot_antenna/file_utils.py
index cec06e9..2675e89 100644
--- a/plot_antenna/file_utils.py
+++ b/plot_antenna/file_utils.py
@@ -1025,24 +1025,27 @@ def batch_process_passive_scans(
advanced_analysis_params=None,
):
"""
- Batch‑process all HPOL/VPOL pairs in a directory.
+ Batch process all HPOL/VPOL pairs in a directory.
Parameters:
folder_path (str): Directory containing measurement files.
freq_list (list of float): Full list of available frequencies (MHz).
selected_frequencies (list of float): Frequencies to process for each pair (MHz).
cable_loss (float): Cable loss applied to all datasets.
- datasheet_plots (bool): Whether to generate datasheet‑style plots.
+ datasheet_plots (bool): Whether to generate datasheet-style plots.
save_base (str or None): Optional directory to write results; a subfolder per pair will be created.
axis_mode (str): 'auto' or 'manual' axis scaling for 3D plots.
- zmin (float): Minimum z‑axis limit when axis_mode='manual'.
- zmax (float): Maximum z‑axis limit when axis_mode='manual'.
+ zmin (float): Minimum z-axis limit when axis_mode='manual'.
+ zmax (float): Maximum z-axis limit when axis_mode='manual'.
This routine scans ``folder_path`` for files ending in ``AP_HPol.txt`` and
- ``AP_VPol.txt``. For each matching pair it computes passive gain data
- using :func:`calculate_passive_variables` and then generates 2D and 3D plots
- using :mod:`plotting`. Results are saved into per‑pair subfolders in
+ ``AP_VPol.txt``. For each matching pair it computes passive gain data and
+ generates 2D and 3D plots. Results are saved into per-pair subfolders in
``save_base`` if provided.
+
+ Returns:
+ dict: Summary containing counts and error details:
+ {total_pairs, total_jobs, processed, failed, skipped, errors}
"""
import os
@@ -1053,160 +1056,209 @@ def batch_process_passive_scans(
# Disable interactive mode so figures don't pop up during batch processing
plt.ioff()
- # Find all HPOL and VPOL files
- files = os.listdir(folder_path)
- hpol_files = [f for f in files if f.endswith("AP_HPol.txt")]
- vpol_files = [f for f in files if f.endswith("AP_VPol.txt")]
-
- # Match by filename prefix
- pairs = []
- for h_file in hpol_files:
- base = h_file.replace("AP_HPol.txt", "")
- match = base + "AP_VPol.txt"
- if match in vpol_files:
- pairs.append((os.path.join(folder_path, h_file), os.path.join(folder_path, match)))
-
- if not pairs:
- print(f"No HPOL/VPOL pairs found in {folder_path}.")
- plt.ion()
- return
+ summary = {
+ "total_pairs": 0,
+ "total_jobs": 0,
+ "processed": 0,
+ "failed": 0,
+ "skipped": 0,
+ "errors": [],
+ }
- for h_path, v_path in pairs:
- print(f"Processing pair: {os.path.basename(h_path)}, {os.path.basename(v_path)}")
- # Parse both files
- parsed_h, start_phi_h, stop_phi_h, inc_phi_h, start_theta_h, stop_theta_h, inc_theta_h = (
- read_passive_file(h_path)
- )
- parsed_v, start_phi_v, stop_phi_v, inc_phi_v, start_theta_v, stop_theta_v, inc_theta_v = (
- read_passive_file(v_path)
- )
+ try:
+ # Find all HPOL and VPOL files
+ files = os.listdir(folder_path)
+ hpol_files = [f for f in files if f.endswith("AP_HPol.txt")]
+ vpol_files = [f for f in files if f.endswith("AP_VPol.txt")]
+
+ # Match by filename prefix
+ pairs = []
+ for h_file in hpol_files:
+ base = h_file.replace("AP_HPol.txt", "")
+ match = base + "AP_VPol.txt"
+ if match in vpol_files:
+ pairs.append((os.path.join(folder_path, h_file), os.path.join(folder_path, match)))
+
+ summary["total_pairs"] = len(pairs)
+ summary["total_jobs"] = len(pairs) * len(selected_frequencies)
+
+ if not pairs:
+ print(f"No HPOL/VPOL pairs found in {folder_path}.")
+ return summary
+
+ for h_path, v_path in pairs:
+ pair_label = f"{os.path.basename(h_path)} | {os.path.basename(v_path)}"
+ print(f"Processing pair: {os.path.basename(h_path)}, {os.path.basename(v_path)}")
+
+ try:
+ # Parse both files
+ (
+ parsed_h,
+ start_phi_h,
+ stop_phi_h,
+ inc_phi_h,
+ start_theta_h,
+ stop_theta_h,
+ inc_theta_h,
+ ) = read_passive_file(h_path)
+ (
+ parsed_v,
+ start_phi_v,
+ stop_phi_v,
+ inc_phi_v,
+ start_theta_v,
+ stop_theta_v,
+ inc_theta_v,
+ ) = read_passive_file(v_path)
+ except Exception as e:
+ jobs_for_pair = len(selected_frequencies)
+ summary["failed"] += jobs_for_pair
+ summary["errors"].append(
+ {"pair": pair_label, "frequency_mhz": None, "error": str(e)}
+ )
+ print(f" X Error reading pair {pair_label}: {e}")
+ continue
- # Verify angle grids match
- if not angles_match(
- start_phi_h,
- stop_phi_h,
- inc_phi_h,
- start_theta_h,
- stop_theta_h,
- inc_theta_h,
- start_phi_v,
- stop_phi_v,
- inc_phi_v,
- start_theta_v,
- stop_theta_v,
- inc_theta_v,
- ):
- print(f" Warning: angle mismatch between {h_path} and {v_path}. Skipping.")
- continue
-
- for sel_freq in selected_frequencies:
- print(f" Processing frequency {sel_freq} MHz…")
- # Compute gains for this frequency
- theta_deg, phi_deg, v_gain_dB, h_gain_dB, total_gain_dB = calculate_passive_variables(
- parsed_h,
- parsed_v,
- cable_loss,
+ # Verify angle grids match
+ if not angles_match(
start_phi_h,
stop_phi_h,
inc_phi_h,
start_theta_h,
stop_theta_h,
inc_theta_h,
- freq_list,
- sel_freq,
- )
-
- # Create per‑pair/frequency subfolder if requested
- if save_base:
- base_name = os.path.splitext(os.path.basename(h_path))[0].replace("AP_HPol", "")
- subfolder = os.path.join(save_base, f"{base_name}_{sel_freq}MHz")
- os.makedirs(subfolder, exist_ok=True)
- else:
- subfolder = None
-
- # 2D plots
- plot_2d_passive_data(
- theta_deg,
- phi_deg,
- v_gain_dB,
- h_gain_dB,
- total_gain_dB,
- freq_list,
- sel_freq,
- datasheet_plots,
- save_path=subfolder,
- )
-
- # 3D plots (total, hpol and vpol)
- for pol in ("total", "hpol", "vpol"):
- plot_passive_3d_component(
- theta_deg,
- phi_deg,
- h_gain_dB,
- v_gain_dB,
- total_gain_dB,
- freq_list,
- sel_freq,
- pol,
- axis_mode=axis_mode,
- zmin=zmin,
- zmax=zmax,
- save_path=subfolder,
- )
-
- # Maritime / Horizon plots
- if maritime_plots_enabled and subfolder:
- from .plotting import _prepare_gain_grid, generate_maritime_plots
+ start_phi_v,
+ stop_phi_v,
+ inc_phi_v,
+ start_theta_v,
+ stop_theta_v,
+ inc_theta_v,
+ ):
+ print(f" Warning: angle mismatch between {h_path} and {v_path}. Skipping.")
+ summary["skipped"] += len(selected_frequencies)
+ continue
- freq_idx = freq_list.index(sel_freq) if sel_freq in freq_list else 0
- unique_theta, unique_phi, gain_grid = _prepare_gain_grid(
- theta_deg, phi_deg, total_gain_dB, freq_idx
- )
- if gain_grid is not None:
- maritime_sub = os.path.join(subfolder, "Maritime Plots")
- os.makedirs(maritime_sub, exist_ok=True)
- generate_maritime_plots(
- unique_theta,
- unique_phi,
- gain_grid,
+ for sel_freq in selected_frequencies:
+ try:
+ print(f" Processing frequency {sel_freq} MHz...")
+ # Compute gains for this frequency
+ theta_deg, phi_deg, v_gain_dB, h_gain_dB, total_gain_dB = calculate_passive_variables(
+ parsed_h,
+ parsed_v,
+ cable_loss,
+ start_phi_h,
+ stop_phi_h,
+ inc_phi_h,
+ start_theta_h,
+ stop_theta_h,
+ inc_theta_h,
+ freq_list,
sel_freq,
- data_label="Gain",
- data_unit="dBi",
- theta_min=maritime_theta_min,
- theta_max=maritime_theta_max,
- theta_cuts=maritime_theta_cuts,
- gain_threshold=maritime_gain_threshold,
- axis_mode=axis_mode,
- zmin=zmin,
- zmax=zmax,
- save_path=maritime_sub,
)
- # Advanced analysis plots
- if advanced_analysis_params and subfolder:
- from .plotting import _prepare_gain_grid as _pgrid
- from .plotting import generate_advanced_analysis_plots
-
- freq_idx = freq_list.index(sel_freq) if sel_freq in freq_list else 0
- unique_theta, unique_phi, gain_grid = _pgrid(
- theta_deg, phi_deg, total_gain_dB, freq_idx
- )
- if gain_grid is not None:
- adv_sub = os.path.join(subfolder, "Advanced Analysis")
- os.makedirs(adv_sub, exist_ok=True)
- generate_advanced_analysis_plots(
- unique_theta,
- unique_phi,
- gain_grid,
+ # Create per-pair/frequency subfolder if requested
+ if save_base:
+ base_name = os.path.splitext(os.path.basename(h_path))[0].replace("AP_HPol", "")
+ subfolder = os.path.join(save_base, f"{base_name}_{sel_freq}MHz")
+ os.makedirs(subfolder, exist_ok=True)
+ else:
+ subfolder = None
+
+ # 2D plots
+ plot_2d_passive_data(
+ theta_deg,
+ phi_deg,
+ v_gain_dB,
+ h_gain_dB,
+ total_gain_dB,
+ freq_list,
sel_freq,
- data_label="Gain",
- data_unit="dBi",
- save_path=adv_sub,
- **advanced_analysis_params,
+ datasheet_plots,
+ save_path=subfolder,
)
- # Re-enable interactive mode after batch processing
- plt.ion()
+ # 3D plots (total, hpol and vpol)
+ for pol in ("total", "hpol", "vpol"):
+ plot_passive_3d_component(
+ theta_deg,
+ phi_deg,
+ h_gain_dB,
+ v_gain_dB,
+ total_gain_dB,
+ freq_list,
+ sel_freq,
+ pol,
+ axis_mode=axis_mode,
+ zmin=zmin,
+ zmax=zmax,
+ save_path=subfolder,
+ )
+
+ # Maritime / Horizon plots
+ if maritime_plots_enabled and subfolder:
+ from .plotting import _prepare_gain_grid, generate_maritime_plots
+
+ freq_idx = freq_list.index(sel_freq) if sel_freq in freq_list else 0
+ unique_theta, unique_phi, gain_grid = _prepare_gain_grid(
+ theta_deg, phi_deg, total_gain_dB, freq_idx
+ )
+ if gain_grid is not None:
+ maritime_sub = os.path.join(subfolder, "Maritime Plots")
+ os.makedirs(maritime_sub, exist_ok=True)
+ generate_maritime_plots(
+ unique_theta,
+ unique_phi,
+ gain_grid,
+ sel_freq,
+ data_label="Gain",
+ data_unit="dBi",
+ theta_min=maritime_theta_min,
+ theta_max=maritime_theta_max,
+ theta_cuts=maritime_theta_cuts,
+ gain_threshold=maritime_gain_threshold,
+ axis_mode=axis_mode,
+ zmin=zmin,
+ zmax=zmax,
+ save_path=maritime_sub,
+ )
+
+ # Advanced analysis plots
+ if advanced_analysis_params and subfolder:
+ from .plotting import _prepare_gain_grid as _pgrid
+ from .plotting import generate_advanced_analysis_plots
+
+ freq_idx = freq_list.index(sel_freq) if sel_freq in freq_list else 0
+ unique_theta, unique_phi, gain_grid = _pgrid(
+ theta_deg, phi_deg, total_gain_dB, freq_idx
+ )
+ if gain_grid is not None:
+ adv_sub = os.path.join(subfolder, "Advanced Analysis")
+ os.makedirs(adv_sub, exist_ok=True)
+ adv_kwargs = dict(advanced_analysis_params)
+ adv_kwargs.setdefault("mimo_gain_data_list", [gain_grid])
+ generate_advanced_analysis_plots(
+ unique_theta,
+ unique_phi,
+ gain_grid,
+ sel_freq,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=adv_sub,
+ **adv_kwargs,
+ )
+
+ summary["processed"] += 1
+ except Exception as e:
+ summary["failed"] += 1
+ summary["errors"].append(
+ {"pair": pair_label, "frequency_mhz": sel_freq, "error": str(e)}
+ )
+ print(f" X Error processing pair {pair_label} at {sel_freq} MHz: {e}")
+
+ return summary
+ finally:
+ # Re-enable interactive mode after batch processing
+ plt.ion()
def batch_process_active_scans(
@@ -1224,22 +1276,19 @@ def batch_process_active_scans(
advanced_analysis_params=None,
):
"""
- Batch‑process all active TRP measurement files in a directory.
+ Batch process all active TRP measurement files in a directory.
Parameters:
folder_path (str): Directory containing TRP measurement files.
save_base (str or None): Optional directory to write results; a subfolder per file will be created.
interpolate (bool): Whether to interpolate 3D plots for smoother visualization.
axis_mode (str): 'auto' or 'manual' axis scaling for 3D plots.
- zmin (float): Minimum z‑axis limit (dBm) when axis_mode='manual'.
- zmax (float): Maximum z‑axis limit (dBm) when axis_mode='manual'.
-
- This routine scans ``folder_path`` for TRP data files (e.g., files ending in ``.txt``).
- For each file, it:
- 1. Reads and parses the TRP data using :func:`read_active_file`.
- 2. Calculates active variables using :func:`calculate_active_variables`.
- 3. Generates 2D azimuth/elevation cuts and 3D TRP plots.
- 4. Saves results to per‑file subfolders in ``save_base`` if provided.
+ zmin (float): Minimum z-axis limit (dBm) when axis_mode='manual'.
+ zmax (float): Maximum z-axis limit (dBm) when axis_mode='manual'.
+
+ Returns:
+ dict: Summary containing counts and error details:
+ {total_files, processed, failed, errors}
"""
import os
@@ -1251,155 +1300,170 @@ def batch_process_active_scans(
# Disable interactive mode so figures don't pop up during batch processing
plt.ioff()
- # Find all TRP files in the folder
- files = os.listdir(folder_path)
- trp_files = [f for f in files if f.endswith(".txt") and "TRP" in f.upper()]
+ summary = {
+ "total_files": 0,
+ "processed": 0,
+ "failed": 0,
+ "errors": [],
+ }
- if not trp_files:
- print(f"No TRP files found in {folder_path}.")
- plt.ion()
- return
+ try:
+ # Find all TRP files in the folder
+ files = os.listdir(folder_path)
+ trp_files = [f for f in files if f.endswith(".txt") and "TRP" in f.upper()]
+ summary["total_files"] = len(trp_files)
+
+ if not trp_files:
+ print(f"No TRP files found in {folder_path}.")
+ return summary
+
+ for trp_file in trp_files:
+ file_path = os.path.join(folder_path, trp_file)
+ print(f"Processing TRP file: {trp_file}")
+
+ try:
+ # Read active file
+ data = read_active_file(file_path)
+
+ # Extract data
+ frequency = data["Frequency"]
+ start_phi = data["Start Phi"]
+ start_theta = data["Start Theta"]
+ stop_phi = data["Stop Phi"]
+ stop_theta = data["Stop Theta"]
+ inc_phi = data["Inc Phi"]
+ inc_theta = data["Inc Theta"]
+ h_power_dBm = data["H_Power_dBm"]
+ v_power_dBm = data["V_Power_dBm"]
+
+ # Calculate active variables
+ active_vars = calculate_active_variables(
+ start_phi,
+ stop_phi,
+ start_theta,
+ stop_theta,
+ inc_phi,
+ inc_theta,
+ h_power_dBm,
+ v_power_dBm,
+ )
- for trp_file in trp_files:
- file_path = os.path.join(folder_path, trp_file)
- print(f"Processing TRP file: {trp_file}")
-
- try:
- # Read active file
- data = read_active_file(file_path)
-
- # Extract data
- frequency = data["Frequency"]
- start_phi = data["Start Phi"]
- start_theta = data["Start Theta"]
- stop_phi = data["Stop Phi"]
- stop_theta = data["Stop Theta"]
- inc_phi = data["Inc Phi"]
- inc_theta = data["Inc Theta"]
- h_power_dBm = data["H_Power_dBm"]
- v_power_dBm = data["V_Power_dBm"]
-
- # Calculate active variables
- active_vars = calculate_active_variables(
- start_phi,
- stop_phi,
- start_theta,
- stop_theta,
- inc_phi,
- inc_theta,
- h_power_dBm,
- v_power_dBm,
- )
-
- # Unpack variables
- (
- data_points,
- theta_angles_deg,
- phi_angles_deg,
- theta_angles_rad,
- phi_angles_rad,
- total_power_dBm_2d,
- h_power_dBm_2d,
- v_power_dBm_2d,
- phi_angles_deg_plot,
- phi_angles_rad_plot,
- total_power_dBm_2d_plot,
- h_power_dBm_2d_plot,
- v_power_dBm_2d_plot,
- total_power_dBm_min,
- total_power_dBm_nom,
- h_power_dBm_min,
- h_power_dBm_nom,
- v_power_dBm_min,
- v_power_dBm_nom,
- TRP_dBm,
- h_TRP_dBm,
- v_TRP_dBm,
- ) = active_vars
-
- # Create subfolder for this file if save_base is provided
- if save_base:
- base_name = os.path.splitext(trp_file)[0]
- subfolder = os.path.join(save_base, f"{base_name}_{frequency}MHz")
- os.makedirs(subfolder, exist_ok=True)
- else:
- subfolder = None
-
- # Generate 2D plots
- plot_active_2d_data(
- data_points,
- theta_angles_rad,
- phi_angles_rad_plot,
- total_power_dBm_2d_plot,
- frequency,
- save_path=subfolder,
- )
-
- # Generate 3D plots for total, hpol, and vpol
- for power_type, power_2d, power_2d_plot in [
- ("total", total_power_dBm_2d, total_power_dBm_2d_plot),
- ("hpol", h_power_dBm_2d, h_power_dBm_2d_plot),
- ("vpol", v_power_dBm_2d, v_power_dBm_2d_plot),
- ]:
- plot_active_3d_data(
+ # Unpack variables
+ (
+ data_points,
theta_angles_deg,
phi_angles_deg,
- power_2d,
+ theta_angles_rad,
+ phi_angles_rad,
+ total_power_dBm_2d,
+ h_power_dBm_2d,
+ v_power_dBm_2d,
phi_angles_deg_plot,
- power_2d_plot,
+ phi_angles_rad_plot,
+ total_power_dBm_2d_plot,
+ h_power_dBm_2d_plot,
+ v_power_dBm_2d_plot,
+ total_power_dBm_min,
+ total_power_dBm_nom,
+ h_power_dBm_min,
+ h_power_dBm_nom,
+ v_power_dBm_min,
+ v_power_dBm_nom,
+ TRP_dBm,
+ h_TRP_dBm,
+ v_TRP_dBm,
+ ) = active_vars
+
+ # Create subfolder for this file if save_base is provided
+ if save_base:
+ base_name = os.path.splitext(trp_file)[0]
+ subfolder = os.path.join(save_base, f"{base_name}_{frequency}MHz")
+ os.makedirs(subfolder, exist_ok=True)
+ else:
+ subfolder = None
+
+ # Generate 2D plots
+ plot_active_2d_data(
+ data_points,
+ theta_angles_rad,
+ phi_angles_rad_plot,
+ total_power_dBm_2d_plot,
frequency,
- power_type=power_type,
- interpolate=interpolate,
- axis_mode=axis_mode,
- zmin=zmin,
- zmax=zmax,
save_path=subfolder,
)
- # Maritime / Horizon plots
- if maritime_plots_enabled and subfolder:
- from .plotting import generate_maritime_plots
+ # Generate 3D plots for total, hpol, and vpol
+ for power_type, power_2d, power_2d_plot in [
+ ("total", total_power_dBm_2d, total_power_dBm_2d_plot),
+ ("hpol", h_power_dBm_2d, h_power_dBm_2d_plot),
+ ("vpol", v_power_dBm_2d, v_power_dBm_2d_plot),
+ ]:
+ plot_active_3d_data(
+ theta_angles_deg,
+ phi_angles_deg,
+ power_2d,
+ phi_angles_deg_plot,
+ power_2d_plot,
+ frequency,
+ power_type=power_type,
+ interpolate=interpolate,
+ axis_mode=axis_mode,
+ zmin=zmin,
+ zmax=zmax,
+ save_path=subfolder,
+ )
- maritime_sub = os.path.join(subfolder, "Maritime Plots")
- os.makedirs(maritime_sub, exist_ok=True)
- generate_maritime_plots(
- theta_angles_deg,
- phi_angles_deg,
- total_power_dBm_2d,
- frequency,
- data_label="Power",
- data_unit="dBm",
- theta_min=maritime_theta_min,
- theta_max=maritime_theta_max,
- theta_cuts=maritime_theta_cuts,
- gain_threshold=maritime_gain_threshold,
- axis_mode=axis_mode,
- zmin=zmin,
- zmax=zmax,
- save_path=maritime_sub,
- )
+ # Maritime / Horizon plots
+ if maritime_plots_enabled and subfolder:
+ from .plotting import generate_maritime_plots
+
+ maritime_sub = os.path.join(subfolder, "Maritime Plots")
+ os.makedirs(maritime_sub, exist_ok=True)
+ generate_maritime_plots(
+ theta_angles_deg,
+ phi_angles_deg,
+ total_power_dBm_2d,
+ frequency,
+ data_label="Power",
+ data_unit="dBm",
+ theta_min=maritime_theta_min,
+ theta_max=maritime_theta_max,
+ theta_cuts=maritime_theta_cuts,
+ gain_threshold=maritime_gain_threshold,
+ axis_mode=axis_mode,
+ zmin=zmin,
+ zmax=zmax,
+ save_path=maritime_sub,
+ )
- # Advanced analysis plots
- if advanced_analysis_params and subfolder:
- from .plotting import generate_advanced_analysis_plots
+ # Advanced analysis plots
+ if advanced_analysis_params and subfolder:
+ from .plotting import generate_advanced_analysis_plots
- adv_sub = os.path.join(subfolder, "Advanced Analysis")
- os.makedirs(adv_sub, exist_ok=True)
- generate_advanced_analysis_plots(
- theta_angles_deg,
- phi_angles_deg,
- total_power_dBm_2d,
- frequency,
- data_label="Power",
- data_unit="dBm",
- save_path=adv_sub,
- **advanced_analysis_params,
- )
+ adv_sub = os.path.join(subfolder, "Advanced Analysis")
+ os.makedirs(adv_sub, exist_ok=True)
+ adv_kwargs = dict(advanced_analysis_params)
+ adv_kwargs.setdefault("mimo_gain_data_list", [total_power_dBm_2d])
+ generate_advanced_analysis_plots(
+ theta_angles_deg,
+ phi_angles_deg,
+ total_power_dBm_2d,
+ frequency,
+ data_label="Power",
+ data_unit="dBm",
+ save_path=adv_sub,
+ **adv_kwargs,
+ )
- print(f" ✓ Completed {trp_file} at {frequency} MHz (TRP={TRP_dBm:.2f} dBm)")
+ summary["processed"] += 1
+ print(f" OK Completed {trp_file} at {frequency} MHz (TRP={TRP_dBm:.2f} dBm)")
- except Exception as e:
- print(f" ✗ Error processing {trp_file}: {e}")
+ except Exception as e:
+ summary["failed"] += 1
+ summary["errors"].append({"file": trp_file, "error": str(e)})
+ print(f" X Error processing {trp_file}: {e}")
- # Re-enable interactive mode after batch processing
- plt.ion()
+ return summary
+ finally:
+ # Re-enable interactive mode after batch processing
+ plt.ion()
diff --git a/plot_antenna/gui/callbacks_mixin.py b/plot_antenna/gui/callbacks_mixin.py
index 26e890c..317e352 100644
--- a/plot_antenna/gui/callbacks_mixin.py
+++ b/plot_antenna/gui/callbacks_mixin.py
@@ -154,6 +154,80 @@ def _parse_theta_cuts(self):
except (ValueError, AttributeError):
return [60, 70, 80, 90, 100, 110, 120]
+ def _collect_advanced_plot_params(self, mimo_gain_data_list=None, mimo_ecc_values=None):
+ """Collect advanced analysis parameters for plot dispatcher calls."""
+ any_enabled = (
+ getattr(self, "link_budget_enabled", False)
+ or getattr(self, "indoor_analysis_enabled", False)
+ or getattr(self, "fading_analysis_enabled", False)
+ or getattr(self, "mimo_analysis_enabled", False)
+ or getattr(self, "wearable_analysis_enabled", False)
+ )
+ if not any_enabled:
+ return None
+
+ indoor_n = (
+ self.indoor_path_loss_exp.get()
+ if hasattr(self, "indoor_path_loss_exp")
+ else self.lb_path_loss_exp.get()
+ )
+
+ params = {
+ "link_budget_enabled": getattr(self, "link_budget_enabled", False),
+ "lb_pt_dbm": self.lb_tx_power.get(),
+ "lb_pr_dbm": self.lb_rx_sensitivity.get(),
+ "lb_gr_dbi": self.lb_rx_gain.get(),
+ "lb_path_loss_exp": self.lb_path_loss_exp.get(),
+ "lb_misc_loss_db": self.lb_misc_loss.get(),
+ "lb_target_range_m": self.lb_target_range.get(),
+ "indoor_enabled": getattr(self, "indoor_analysis_enabled", False),
+ "indoor_environment": self.indoor_environment.get(),
+ "indoor_path_loss_exp": indoor_n,
+ "indoor_n_walls": self.indoor_num_walls.get(),
+ "indoor_wall_material": self.indoor_wall_material.get(),
+ "indoor_shadow_fading_db": self.indoor_shadow_fading.get(),
+ "indoor_max_distance_m": self.indoor_max_distance.get(),
+ "fading_enabled": getattr(self, "fading_analysis_enabled", False),
+ "fading_pr_sensitivity_dbm": self.lb_rx_sensitivity.get(),
+ "fading_pt_dbm": self.lb_tx_power.get(),
+ "fading_target_reliability": self.fading_target_reliability.get(),
+ "fading_model": self.fading_model.get(),
+ "fading_rician_k": self.fading_rician_k.get(),
+ "fading_realizations": (
+ self.fading_realizations.get() if hasattr(self, "fading_realizations") else 1000
+ ),
+ "mimo_enabled": getattr(self, "mimo_analysis_enabled", False),
+ "mimo_snr_db": self.mimo_snr.get(),
+ "mimo_fading_model": (
+ self.mimo_fading_model.get()
+ if hasattr(self, "mimo_fading_model")
+ else self.fading_model.get()
+ ),
+ "mimo_rician_k": (
+ self.mimo_rician_k.get()
+ if hasattr(self, "mimo_rician_k")
+ else self.fading_rician_k.get()
+ ),
+ "mimo_xpr_db": self.mimo_xpr.get(),
+ "wearable_enabled": getattr(self, "wearable_analysis_enabled", False),
+ "wearable_body_positions": [
+ pos for pos, var in self.wearable_positions_var.items()
+ if var.get()
+ ],
+ "wearable_tx_power_mw": self.wearable_tx_power_mw.get(),
+ "wearable_num_devices": self.wearable_device_count.get(),
+ "wearable_room_size": (
+ self.wearable_room_x.get(),
+ self.wearable_room_y.get(),
+ self.wearable_room_z.get(),
+ ),
+ }
+ if mimo_gain_data_list is not None:
+ params["mimo_gain_data_list"] = mimo_gain_data_list
+ if mimo_ecc_values is not None:
+ params["mimo_ecc_values"] = mimo_ecc_values
+ return params
+
# ────────────────────────────────────────────────────────────────────────
# DATA RESET
# ────────────────────────────────────────────────────────────────────────
@@ -1300,13 +1374,10 @@ def _process_active_data(self):
)
# Advanced analysis plots (active)
- _any_advanced = (
- self.link_budget_enabled
- or self.indoor_analysis_enabled
- or self.fading_analysis_enabled
- or self.wearable_analysis_enabled
+ _adv_params = self._collect_advanced_plot_params(
+ mimo_gain_data_list=[total_power_dBm_2d]
)
- if _any_advanced:
+ if _adv_params:
self.log_message("Generating advanced analysis plots (active)...")
generate_advanced_analysis_plots(
np.rad2deg(theta_angles_rad),
@@ -1316,36 +1387,7 @@ def _process_active_data(self):
data_label="Power",
data_unit="dBm",
save_path=None,
- link_budget_enabled=self.link_budget_enabled,
- lb_pt_dbm=self.lb_tx_power.get(),
- lb_pr_dbm=self.lb_rx_sensitivity.get(),
- lb_gr_dbi=self.lb_rx_gain.get(),
- lb_path_loss_exp=self.lb_path_loss_exp.get(),
- lb_misc_loss_db=self.lb_misc_loss.get(),
- lb_target_range_m=self.lb_target_range.get(),
- indoor_enabled=self.indoor_analysis_enabled,
- indoor_environment=self.indoor_environment.get(),
- indoor_path_loss_exp=self.lb_path_loss_exp.get(),
- indoor_n_walls=self.indoor_num_walls.get(),
- indoor_wall_material=self.indoor_wall_material.get(),
- indoor_shadow_fading_db=self.indoor_shadow_fading.get(),
- indoor_max_distance_m=self.indoor_max_distance.get(),
- fading_enabled=self.fading_analysis_enabled,
- fading_pr_sensitivity_dbm=self.lb_rx_sensitivity.get(),
- fading_pt_dbm=self.lb_tx_power.get(),
- fading_target_reliability=self.fading_target_reliability.get(),
- wearable_enabled=self.wearable_analysis_enabled,
- wearable_body_positions=[
- pos for pos, var in self.wearable_positions_var.items()
- if var.get()
- ],
- wearable_tx_power_mw=self.wearable_tx_power_mw.get(),
- wearable_num_devices=self.wearable_device_count.get(),
- wearable_room_size=(
- self.wearable_room_x.get(),
- self.wearable_room_y.get(),
- self.wearable_room_z.get(),
- ),
+ **_adv_params,
)
# Update measurement context for AI awareness
@@ -1575,13 +1617,8 @@ def _process_passive_data(self):
)
# Advanced analysis plots (passive)
- _any_advanced_p = (
- self.link_budget_enabled
- or self.indoor_analysis_enabled
- or self.fading_analysis_enabled
- or self.wearable_analysis_enabled
- )
- if _any_advanced_p:
+ _adv_params_p = self._collect_advanced_plot_params()
+ if _adv_params_p:
self.log_message("Generating advanced analysis plots (passive)...")
_adv_freq_idx = (
self.freq_list.index(float(self.selected_frequency.get()))
@@ -1592,6 +1629,7 @@ def _process_passive_data(self):
theta_angles_deg, phi_angles_deg, Total_Gain_dB, _adv_freq_idx
)
if _adv_grid is not None:
+ _adv_params_p["mimo_gain_data_list"] = [_adv_grid]
generate_advanced_analysis_plots(
_adv_theta,
_adv_phi,
@@ -1600,36 +1638,7 @@ def _process_passive_data(self):
data_label="Gain",
data_unit="dBi",
save_path=None,
- link_budget_enabled=self.link_budget_enabled,
- lb_pt_dbm=self.lb_tx_power.get(),
- lb_pr_dbm=self.lb_rx_sensitivity.get(),
- lb_gr_dbi=self.lb_rx_gain.get(),
- lb_path_loss_exp=self.lb_path_loss_exp.get(),
- lb_misc_loss_db=self.lb_misc_loss.get(),
- lb_target_range_m=self.lb_target_range.get(),
- indoor_enabled=self.indoor_analysis_enabled,
- indoor_environment=self.indoor_environment.get(),
- indoor_path_loss_exp=self.lb_path_loss_exp.get(),
- indoor_n_walls=self.indoor_num_walls.get(),
- indoor_wall_material=self.indoor_wall_material.get(),
- indoor_shadow_fading_db=self.indoor_shadow_fading.get(),
- indoor_max_distance_m=self.indoor_max_distance.get(),
- fading_enabled=self.fading_analysis_enabled,
- fading_pr_sensitivity_dbm=self.lb_rx_sensitivity.get(),
- fading_pt_dbm=self.lb_tx_power.get(),
- fading_target_reliability=self.fading_target_reliability.get(),
- wearable_enabled=self.wearable_analysis_enabled,
- wearable_body_positions=[
- pos for pos, var in self.wearable_positions_var.items()
- if var.get()
- ],
- wearable_tx_power_mw=self.wearable_tx_power_mw.get(),
- wearable_num_devices=self.wearable_device_count.get(),
- wearable_room_size=(
- self.wearable_room_x.get(),
- self.wearable_room_y.get(),
- self.wearable_room_z.get(),
- ),
+ **_adv_params_p,
)
else:
self.log_message(
diff --git a/plot_antenna/gui/dialogs_mixin.py b/plot_antenna/gui/dialogs_mixin.py
index 44a690d..b29ace1 100644
--- a/plot_antenna/gui/dialogs_mixin.py
+++ b/plot_antenna/gui/dialogs_mixin.py
@@ -1213,11 +1213,25 @@ def _on_destroy(event):
# ADVANCED ANALYSIS SETTINGS (shared builder for Active & Passive)
# ────────────────────────────────────────────────────────────────────────
+ def _cleanup_advanced_analysis_traces(self):
+ """Remove variable trace handlers registered by the advanced settings UI."""
+ handles = getattr(self, "_advanced_trace_handles", [])
+ for var, mode, trace_id in handles:
+ try:
+ var.trace_remove(mode, trace_id)
+ except Exception:
+ # Ignore stale handles; this cleanup is best-effort.
+ pass
+ self._advanced_trace_handles = []
+
def _build_advanced_analysis_frames(self, parent, start_row):
"""Build all advanced analysis LabelFrame sections.
Returns the next available row index and a callback to read values.
"""
+ # Ensure we don't accumulate callbacks if settings is reopened repeatedly.
+ self._cleanup_advanced_analysis_traces()
+ self._advanced_trace_handles = []
row = start_row
# ── Link Budget / Range Estimation ──
@@ -1256,7 +1270,8 @@ def _on_protocol_change(*_args):
self.lb_rx_sensitivity.set(sens)
if pwr is not None:
self.lb_tx_power.set(pwr)
- self.lb_protocol_preset.trace_add("write", _on_protocol_change)
+ trace_id = self.lb_protocol_preset.trace_add("write", _on_protocol_change)
+ self._advanced_trace_handles.append((self.lb_protocol_preset, "write", trace_id))
# Tx Power
tk.Label(lb_frame, text="Tx Power (dBm):", bg=DARK_BG_COLOR,
@@ -1331,14 +1346,21 @@ def _on_env_change(*_args):
env = self.indoor_environment.get()
if env in ENVIRONMENT_PRESETS:
n, sigma, fading_m, k, walls = ENVIRONMENT_PRESETS[env]
- self.lb_path_loss_exp.set(n)
+ self.indoor_path_loss_exp.set(n)
self.indoor_shadow_fading.set(sigma)
self.indoor_num_walls.set(walls)
if fading_m != "none":
self.fading_model.set(fading_m)
if k > 0:
self.fading_rician_k.set(float(k))
- self.indoor_environment.trace_add("write", _on_env_change)
+ trace_id = self.indoor_environment.trace_add("write", _on_env_change)
+ self._advanced_trace_handles.append((self.indoor_environment, "write", trace_id))
+
+ tk.Label(indoor_frame, text="Path Loss Exp (n):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=2, sticky=tk.W, padx=5)
+ tk.Entry(indoor_frame, textvariable=self.indoor_path_loss_exp, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=1, column=3, padx=5)
# Walls + material
tk.Label(indoor_frame, text="Walls:", bg=DARK_BG_COLOR,
@@ -1407,6 +1429,57 @@ def _on_env_change(*_args):
bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=2, padx=5)
+ tk.Label(fading_frame, text="Realizations:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=3, column=0, sticky=tk.W, padx=5)
+ tk.Spinbox(
+ fading_frame, textvariable=self.fading_realizations,
+ from_=50, to=10000, increment=50, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ ).grid(row=3, column=1, sticky=tk.W, padx=5)
+
+ # ── MIMO / Diversity ──
+ mimo_frame = tk.LabelFrame(
+ parent, text="MIMO / Diversity",
+ bg=DARK_BG_COLOR, fg=ACCENT_BLUE_COLOR, font=SECTION_HEADER_FONT,
+ )
+ mimo_frame.grid(row=row, column=0, columnspan=4, sticky="ew", padx=15, pady=5)
+ row += 1
+
+ self._cb_mimo_var = tk.BooleanVar(
+ value=getattr(self, "mimo_analysis_enabled", False)
+ )
+ tk.Checkbutton(
+ mimo_frame, text="Enable MIMO Analysis",
+ variable=self._cb_mimo_var,
+ bg=DARK_BG_COLOR, fg=LIGHT_TEXT_COLOR, selectcolor=SURFACE_COLOR,
+ activebackground=DARK_BG_COLOR, activeforeground=LIGHT_TEXT_COLOR,
+ ).grid(row=0, column=0, columnspan=4, sticky=tk.W, padx=5, pady=2)
+
+ tk.Label(mimo_frame, text="SNR (dB):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=0, sticky=tk.W, padx=5)
+ tk.Entry(mimo_frame, textvariable=self.mimo_snr, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=1, column=1, padx=5)
+
+ tk.Label(mimo_frame, text="Fading:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=1, column=2, sticky=tk.W, padx=5)
+ ttk.Combobox(
+ mimo_frame, textvariable=self.mimo_fading_model,
+ values=["rayleigh", "rician"], width=12, state="readonly",
+ ).grid(row=1, column=3, sticky=tk.W, padx=5)
+
+ tk.Label(mimo_frame, text="K-factor:", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=0, sticky=tk.W, padx=5)
+ tk.Entry(mimo_frame, textvariable=self.mimo_rician_k, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=1, padx=5)
+
+ tk.Label(mimo_frame, text="XPR (dB):", bg=DARK_BG_COLOR,
+ fg=LIGHT_TEXT_COLOR).grid(row=2, column=2, sticky=tk.W, padx=5)
+ tk.Entry(mimo_frame, textvariable=self.mimo_xpr, width=8,
+ bg=SURFACE_COLOR, fg=LIGHT_TEXT_COLOR,
+ insertbackground=LIGHT_TEXT_COLOR).grid(row=2, column=3, padx=5)
+
# ── Wearable / Medical ──
wear_frame = tk.LabelFrame(
parent, text="Wearable / Medical",
@@ -1475,6 +1548,7 @@ def _save_advanced_analysis_settings(self):
self.link_budget_enabled = self._cb_link_budget_var.get()
self.indoor_analysis_enabled = self._cb_indoor_var.get()
self.fading_analysis_enabled = self._cb_fading_var.get()
+ self.mimo_analysis_enabled = self._cb_mimo_var.get()
self.wearable_analysis_enabled = self._cb_wearable_var.get()
# ────────────────────────────────────────────────────────────────────────
@@ -1522,6 +1596,13 @@ def _on_leave(_e):
_canvas.bind("", _on_enter)
_canvas.bind("", _on_leave)
+ def _on_outer_destroy(event):
+ if event.widget is outer_window:
+ _canvas.unbind_all("")
+ self._cleanup_advanced_analysis_traces()
+
+ outer_window.bind("", _on_outer_destroy)
+
_scrollbar.pack(side="right", fill="y")
_canvas.pack(side="left", fill="both", expand=True)
diff --git a/plot_antenna/gui/main_window.py b/plot_antenna/gui/main_window.py
index 90ab5f7..64435c8 100644
--- a/plot_antenna/gui/main_window.py
+++ b/plot_antenna/gui/main_window.py
@@ -55,6 +55,36 @@
PAD_SM,
PAD_MD,
PAD_LG,
+ LINK_BUDGET_ENABLED,
+ LINK_BUDGET_PROTOCOL_PRESET,
+ LINK_BUDGET_TX_POWER_DBM,
+ LINK_BUDGET_RX_SENSITIVITY_DBM,
+ LINK_BUDGET_RX_GAIN_DBI,
+ LINK_BUDGET_PATH_LOSS_EXP,
+ LINK_BUDGET_MISC_LOSS_DB,
+ LINK_BUDGET_TARGET_RANGE_M,
+ INDOOR_ANALYSIS_ENABLED,
+ INDOOR_ENVIRONMENT,
+ INDOOR_PATH_LOSS_EXP,
+ INDOOR_NUM_WALLS,
+ INDOOR_WALL_MATERIAL,
+ INDOOR_SHADOW_FADING_DB,
+ INDOOR_MAX_DISTANCE_M,
+ FADING_ANALYSIS_ENABLED,
+ FADING_MODEL,
+ FADING_RICIAN_K,
+ FADING_TARGET_RELIABILITY,
+ FADING_REALIZATIONS,
+ MIMO_ANALYSIS_ENABLED,
+ MIMO_SNR_DB,
+ MIMO_FADING_MODEL,
+ MIMO_RICIAN_K,
+ MIMO_XPR_DB,
+ WEARABLE_ANALYSIS_ENABLED,
+ WEARABLE_BODY_POSITIONS,
+ WEARABLE_TX_POWER_MW,
+ WEARABLE_DENSE_DEVICE_COUNT,
+ WEARABLE_ROOM_SIZE_M,
)
from ..calculations import extract_passive_frequencies
@@ -184,8 +214,8 @@ def __init__(self, root):
# Set up window close handler
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
- # Check for updates on startup
- self.check_for_updates()
+ # Check for updates on startup without blocking initial UI render.
+ self.root.after(500, lambda: self.check_for_updates(silent=True))
def _load_logo(self):
"""Load application logo."""
@@ -560,44 +590,49 @@ def _create_widgets(self):
self.horizon_theta_cuts_var = tk.StringVar(value="60,70,80,90,100,110,120")
# Link Budget / Range Estimation settings
- self.link_budget_enabled = False
- self.lb_protocol_preset = tk.StringVar(value="BLE 1Mbps")
- self.lb_tx_power = tk.DoubleVar(value=0.0)
- self.lb_rx_sensitivity = tk.DoubleVar(value=-98.0)
- self.lb_rx_gain = tk.DoubleVar(value=0.0)
- self.lb_path_loss_exp = tk.DoubleVar(value=2.0)
- self.lb_misc_loss = tk.DoubleVar(value=10.0)
- self.lb_target_range = tk.DoubleVar(value=5.0)
+ self.link_budget_enabled = bool(LINK_BUDGET_ENABLED)
+ self.lb_protocol_preset = tk.StringVar(value=LINK_BUDGET_PROTOCOL_PRESET)
+ self.lb_tx_power = tk.DoubleVar(value=float(LINK_BUDGET_TX_POWER_DBM))
+ self.lb_rx_sensitivity = tk.DoubleVar(value=float(LINK_BUDGET_RX_SENSITIVITY_DBM))
+ self.lb_rx_gain = tk.DoubleVar(value=float(LINK_BUDGET_RX_GAIN_DBI))
+ self.lb_path_loss_exp = tk.DoubleVar(value=float(LINK_BUDGET_PATH_LOSS_EXP))
+ self.lb_misc_loss = tk.DoubleVar(value=float(LINK_BUDGET_MISC_LOSS_DB))
+ self.lb_target_range = tk.DoubleVar(value=float(LINK_BUDGET_TARGET_RANGE_M))
# Indoor Propagation settings
- self.indoor_analysis_enabled = False
- self.indoor_environment = tk.StringVar(value="Office")
- self.indoor_num_walls = tk.IntVar(value=1)
- self.indoor_wall_material = tk.StringVar(value="drywall")
- self.indoor_shadow_fading = tk.DoubleVar(value=5.0)
- self.indoor_max_distance = tk.DoubleVar(value=30.0)
+ self.indoor_analysis_enabled = bool(INDOOR_ANALYSIS_ENABLED)
+ self.indoor_environment = tk.StringVar(value=INDOOR_ENVIRONMENT)
+ self.indoor_path_loss_exp = tk.DoubleVar(value=float(INDOOR_PATH_LOSS_EXP))
+ self.indoor_num_walls = tk.IntVar(value=int(INDOOR_NUM_WALLS))
+ self.indoor_wall_material = tk.StringVar(value=INDOOR_WALL_MATERIAL)
+ self.indoor_shadow_fading = tk.DoubleVar(value=float(INDOOR_SHADOW_FADING_DB))
+ self.indoor_max_distance = tk.DoubleVar(value=float(INDOOR_MAX_DISTANCE_M))
# Multipath Fading Analysis settings
- self.fading_analysis_enabled = False
- self.fading_model = tk.StringVar(value="rayleigh")
- self.fading_rician_k = tk.DoubleVar(value=10.0)
- self.fading_target_reliability = tk.DoubleVar(value=99.0)
+ self.fading_analysis_enabled = bool(FADING_ANALYSIS_ENABLED)
+ self.fading_model = tk.StringVar(value=FADING_MODEL)
+ self.fading_rician_k = tk.DoubleVar(value=float(FADING_RICIAN_K))
+ self.fading_target_reliability = tk.DoubleVar(value=float(FADING_TARGET_RELIABILITY))
+ self.fading_realizations = tk.IntVar(value=int(FADING_REALIZATIONS))
# MIMO / Diversity Analysis settings
- self.mimo_analysis_enabled = False
- self.mimo_snr = tk.DoubleVar(value=20.0)
- self.mimo_xpr = tk.DoubleVar(value=6.0)
+ self.mimo_analysis_enabled = bool(MIMO_ANALYSIS_ENABLED)
+ self.mimo_snr = tk.DoubleVar(value=float(MIMO_SNR_DB))
+ self.mimo_fading_model = tk.StringVar(value=MIMO_FADING_MODEL)
+ self.mimo_rician_k = tk.DoubleVar(value=float(MIMO_RICIAN_K))
+ self.mimo_xpr = tk.DoubleVar(value=float(MIMO_XPR_DB))
# Wearable / Medical Device settings
- self.wearable_analysis_enabled = False
+ self.wearable_analysis_enabled = bool(WEARABLE_ANALYSIS_ENABLED)
self.wearable_positions_var = {
- pos: tk.BooleanVar(value=True) for pos in ["wrist", "chest", "hip", "head"]
+ pos: tk.BooleanVar(value=(pos in WEARABLE_BODY_POSITIONS))
+ for pos in ["wrist", "chest", "hip", "head"]
}
- self.wearable_tx_power_mw = tk.DoubleVar(value=1.0)
- self.wearable_device_count = tk.IntVar(value=20)
- self.wearable_room_x = tk.DoubleVar(value=10.0)
- self.wearable_room_y = tk.DoubleVar(value=10.0)
- self.wearable_room_z = tk.DoubleVar(value=3.0)
+ self.wearable_tx_power_mw = tk.DoubleVar(value=float(WEARABLE_TX_POWER_MW))
+ self.wearable_device_count = tk.IntVar(value=int(WEARABLE_DENSE_DEVICE_COUNT))
+ self.wearable_room_x = tk.DoubleVar(value=float(WEARABLE_ROOM_SIZE_M[0]))
+ self.wearable_room_y = tk.DoubleVar(value=float(WEARABLE_ROOM_SIZE_M[1]))
+ self.wearable_room_z = tk.DoubleVar(value=float(WEARABLE_ROOM_SIZE_M[2]))
# Configure background
self.root.config(bg=DARK_BG_COLOR)
diff --git a/plot_antenna/gui/tools_mixin.py b/plot_antenna/gui/tools_mixin.py
index 547962f..59d831a 100644
--- a/plot_antenna/gui/tools_mixin.py
+++ b/plot_antenna/gui/tools_mixin.py
@@ -14,6 +14,7 @@
import os
import datetime
+import threading
import webbrowser
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
@@ -457,11 +458,18 @@ def _collect_advanced_params(self):
getattr(self, "link_budget_enabled", False)
or getattr(self, "indoor_analysis_enabled", False)
or getattr(self, "fading_analysis_enabled", False)
+ or getattr(self, "mimo_analysis_enabled", False)
or getattr(self, "wearable_analysis_enabled", False)
)
if not any_enabled:
return None
+ indoor_n = (
+ self.indoor_path_loss_exp.get()
+ if hasattr(self, "indoor_path_loss_exp")
+ else self.lb_path_loss_exp.get()
+ )
+
return {
"link_budget_enabled": getattr(self, "link_budget_enabled", False),
"lb_pt_dbm": self.lb_tx_power.get(),
@@ -472,7 +480,7 @@ def _collect_advanced_params(self):
"lb_target_range_m": self.lb_target_range.get(),
"indoor_enabled": getattr(self, "indoor_analysis_enabled", False),
"indoor_environment": self.indoor_environment.get(),
- "indoor_path_loss_exp": self.lb_path_loss_exp.get(),
+ "indoor_path_loss_exp": indoor_n,
"indoor_n_walls": self.indoor_num_walls.get(),
"indoor_wall_material": self.indoor_wall_material.get(),
"indoor_shadow_fading_db": self.indoor_shadow_fading.get(),
@@ -481,6 +489,24 @@ def _collect_advanced_params(self):
"fading_pr_sensitivity_dbm": self.lb_rx_sensitivity.get(),
"fading_pt_dbm": self.lb_tx_power.get(),
"fading_target_reliability": self.fading_target_reliability.get(),
+ "fading_model": self.fading_model.get(),
+ "fading_rician_k": self.fading_rician_k.get(),
+ "fading_realizations": (
+ self.fading_realizations.get() if hasattr(self, "fading_realizations") else 1000
+ ),
+ "mimo_enabled": getattr(self, "mimo_analysis_enabled", False),
+ "mimo_snr_db": self.mimo_snr.get(),
+ "mimo_fading_model": (
+ self.mimo_fading_model.get()
+ if hasattr(self, "mimo_fading_model")
+ else self.fading_model.get()
+ ),
+ "mimo_rician_k": (
+ self.mimo_rician_k.get()
+ if hasattr(self, "mimo_rician_k")
+ else self.fading_rician_k.get()
+ ),
+ "mimo_xpr_db": self.mimo_xpr.get(),
"wearable_enabled": getattr(self, "wearable_analysis_enabled", False),
"wearable_body_positions": [
pos for pos, var in self.wearable_positions_var.items()
@@ -579,7 +605,7 @@ def open_bulk_passive_processing(self):
def _process_worker():
try:
- batch_process_passive_scans(
+ summary = batch_process_passive_scans(
folder_path=directory,
freq_list=freq_list,
selected_frequencies=selected_freqs,
@@ -608,22 +634,49 @@ def _process_worker():
),
advanced_analysis_params=self._collect_advanced_params(),
)
- self.root.after(0, lambda: _process_done(True, None))
+ self.root.after(0, lambda: _process_done(summary=summary, error_msg=None))
except Exception as e:
err = str(e)
- self.root.after(0, lambda: _process_done(False, err))
+ self.root.after(0, lambda: _process_done(summary=None, error_msg=err))
- def _process_done(success, error_msg):
+ def _process_done(summary, error_msg):
progress_bar.stop()
progress_window.destroy()
- if success:
- messagebox.showinfo(
- "Success", f"Bulk processing complete. Results saved to {save_base}"
- )
- else:
+ if error_msg:
messagebox.showerror("Error", f"An error occurred during processing: {error_msg}")
+ return
+
+ summary = summary or {}
+ processed = int(summary.get("processed", 0))
+ failed = int(summary.get("failed", 0))
+ skipped = int(summary.get("skipped", 0))
+ total_jobs = int(summary.get("total_jobs", processed + failed + skipped))
+
+ if failed > 0:
+ self.log_message(
+ f"Bulk passive processing completed with failures: {processed} processed, {failed} failed, {skipped} skipped."
+ )
+ for err in (summary.get("errors") or [])[:3]:
+ self.log_message(f"Batch error: {err}")
+ messagebox.showwarning(
+ "Completed with Errors",
+ "Bulk processing finished with partial failures.\n\n"
+ f"Processed: {processed}\n"
+ f"Failed: {failed}\n"
+ f"Skipped: {skipped}\n"
+ f"Total Jobs: {total_jobs}\n\n"
+ f"Results saved to {save_base}",
+ )
+ return
+
+ if processed == 0:
+ messagebox.showwarning(
+ "No Data Processed",
+ f"No passive jobs were processed.\n\nResults directory: {save_base}",
+ )
+ return
- import threading
+ messagebox.showinfo("Success", f"Bulk processing complete. Results saved to {save_base}")
threading.Thread(target=_process_worker, daemon=True).start()
@@ -671,7 +724,7 @@ def open_bulk_active_processing(self):
def _process_worker():
try:
- batch_process_active_scans(
+ summary = batch_process_active_scans(
folder_path=directory,
save_base=save_base,
interpolate=interpolate,
@@ -697,22 +750,49 @@ def _process_worker():
),
advanced_analysis_params=self._collect_advanced_params(),
)
- self.root.after(0, lambda: _process_done(True, None))
+ self.root.after(0, lambda: _process_done(summary=summary, error_msg=None))
except Exception as e:
err = str(e)
- self.root.after(0, lambda: _process_done(False, err))
+ self.root.after(0, lambda: _process_done(summary=None, error_msg=err))
- def _process_done(success, error_msg):
+ def _process_done(summary, error_msg):
progress_bar.stop()
progress_window.destroy()
- if success:
- messagebox.showinfo(
- "Success", f"Bulk active processing complete. Results saved to {save_base}"
- )
- else:
+ if error_msg:
messagebox.showerror("Error", f"An error occurred during processing: {error_msg}")
+ return
- import threading
+ summary = summary or {}
+ processed = int(summary.get("processed", 0))
+ failed = int(summary.get("failed", 0))
+ total_files = int(summary.get("total_files", processed + failed))
+
+ if failed > 0:
+ self.log_message(
+ f"Bulk active processing completed with failures: {processed} processed, {failed} failed."
+ )
+ for err in (summary.get("errors") or [])[:3]:
+ self.log_message(f"Batch error: {err}")
+ messagebox.showwarning(
+ "Completed with Errors",
+ "Bulk active processing finished with partial failures.\n\n"
+ f"Processed: {processed}\n"
+ f"Failed: {failed}\n"
+ f"Total Files: {total_files}\n\n"
+ f"Results saved to {save_base}",
+ )
+ return
+
+ if processed == 0:
+ messagebox.showwarning(
+ "No Data Processed",
+ f"No active files were processed.\n\nResults directory: {save_base}",
+ )
+ return
+
+ messagebox.showinfo(
+ "Success", f"Bulk active processing complete. Results saved to {save_base}"
+ )
threading.Thread(target=_process_worker, daemon=True).start()
@@ -1282,29 +1362,66 @@ def download_latest_release(self, url):
"""Open the given URL in the default web browser to download the release."""
webbrowser.open(url)
- def check_for_updates(self):
- """Check for software updates."""
- try:
+ @staticmethod
+ def _parse_version_tuple(version):
+ """Parse version text (e.g. 'v4.2.0') into an integer tuple."""
+ cleaned = str(version).strip().lstrip("vV")
+ if not cleaned:
+ return ()
+ parts = []
+ for token in cleaned.split("."):
+ digits = "".join(ch for ch in token if ch.isdigit())
+ if not digits:
+ break
+ parts.append(int(digits))
+ return tuple(parts)
+
+ def _prompt_update_download(self, latest_version, release_url):
+ """Prompt user on the UI thread and optionally open release page."""
+ self.log_message(f"Update Available. A new version {latest_version} is available!")
+ answer = messagebox.askyesno(
+ "Update Available",
+ f"A new version {latest_version} is available! Would you like to download it?",
+ )
+ if answer and release_url:
+ self.download_latest_release(release_url)
- def _parse_version(v):
- """Parse version string like 'v4.2.0' into comparable tuple."""
- return tuple(int(x) for x in v.lstrip("v").split("."))
+ def _finish_update_check(self):
+ """Clear in-progress flag after a background update check."""
+ self._update_check_in_progress = False
- latest_version, release_url = self.get_latest_release()
- if latest_version and _parse_version(latest_version) > _parse_version(
- self.CURRENT_VERSION
- ):
- self.log_message(f"Update Available. A new version {latest_version} is available!")
+ def check_for_updates(self, silent=False):
+ """Check for software updates without blocking the Tkinter main loop."""
+ if getattr(self, "_update_check_in_progress", False):
+ return
+ self._update_check_in_progress = True
- answer = messagebox.askyesno(
- "Update Available",
- f"A new version {latest_version} is available! Would you like to download it?",
- )
+ def _worker():
+ try:
+ latest_version, release_url = self.get_latest_release()
+ if not latest_version:
+ return
- if answer:
- self.download_latest_release(release_url)
- except Exception:
- pass # Never crash on update check
+ latest = self._parse_version_tuple(latest_version)
+ current = self._parse_version_tuple(self.CURRENT_VERSION)
+ if latest and (not current or latest > current):
+ self.root.after(
+ 0,
+ lambda: self._prompt_update_download(latest_version, release_url),
+ )
+ elif not silent:
+ self.root.after(
+ 0,
+ lambda: self.log_message(
+ f"No updates available (current: {self.CURRENT_VERSION})."
+ ),
+ )
+ except Exception:
+ pass # Never crash on update check
+ finally:
+ self.root.after(0, self._finish_update_check)
+
+ threading.Thread(target=_worker, daemon=True).start()
# ────────────────────────────────────────────────────────────────────────
# HOVER EFFECTS
diff --git a/plot_antenna/plotting.py b/plot_antenna/plotting.py
index 027da04..bf99e4f 100644
--- a/plot_antenna/plotting.py
+++ b/plot_antenna/plotting.py
@@ -13,7 +13,13 @@
import numpy as np
import scipy.interpolate as spi
-from .config import THETA_RESOLUTION, PHI_RESOLUTION, polar_dB_max, polar_dB_min
+from .config import (
+ THETA_RESOLUTION,
+ PHI_RESOLUTION,
+ polar_dB_max,
+ polar_dB_min,
+ MIMO_SNR_RANGE_DB,
+)
from .file_utils import parse_2port_data
from .calculations import (
calculate_trp,
@@ -31,15 +37,15 @@
apply_statistical_fading,
combining_gain,
mimo_capacity_vs_snr,
+ mean_effective_gain_mimo,
body_worn_pattern_analysis,
dense_device_interference,
PROTOCOL_PRESETS,
ENVIRONMENT_PRESETS,
)
-# Suppress noisy warnings during batch processing (worker thread + tight_layout)
+# Suppress noisy warnings during batch processing worker threads.
warnings.filterwarnings("ignore", message="Starting a Matplotlib GUI outside of the main thread")
-warnings.filterwarnings("ignore", message="Tight layout not applied")
# _____________Active Plotting Functions___________
@@ -2223,7 +2229,7 @@ def plot_conical_cuts(
else:
ax = fig.add_subplot(111)
- colors = cm.get_cmap("viridis")(np.linspace(0, 1, len(theta_cuts)))
+ colors = plt.get_cmap("viridis")(np.linspace(0, 1, len(theta_cuts)))
for i, theta_cut in enumerate(theta_cuts):
theta_idx = np.argmin(np.abs(theta_deg - theta_cut))
@@ -2309,7 +2315,7 @@ def plot_gain_over_azimuth(
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
- colors = cm.get_cmap("viridis")(np.linspace(0, 1, len(theta_cuts)))
+ colors = plt.get_cmap("viridis")(np.linspace(0, 1, len(theta_cuts)))
for i, theta_cut in enumerate(theta_cuts):
theta_idx = np.argmin(np.abs(theta_deg - theta_cut))
@@ -2536,7 +2542,7 @@ def plot_horizon_statistics(
)
# Remove near-duplicates
band_thetas = np.unique(np.round(band_thetas, 1))
- cmap = cm.get_cmap("viridis")
+ cmap = plt.get_cmap("viridis")
colors = cmap(np.linspace(0, 1, len(band_thetas)))
phi_rad = np.deg2rad(phi_deg)
@@ -3179,6 +3185,9 @@ def plot_fading_analysis(
pr_sensitivity_dbm=-98.0,
pt_dbm=0.0,
target_reliability=99.0,
+ fading_model="rayleigh",
+ fading_rician_k=10.0,
+ realizations=1000,
data_label="Gain",
data_unit="dBi",
save_path=None,
@@ -3188,6 +3197,11 @@ def plot_fading_analysis(
and outage probability bar chart.
"""
is_active = data_label != "Gain"
+ model = str(fading_model).strip().lower()
+ if model not in ("rayleigh", "rician"):
+ model = "rayleigh"
+ k_factor = max(float(fading_rician_k), 0.0)
+ n_realizations = max(int(realizations), 10)
# Peak direction
peak_idx = np.unravel_index(np.argmax(gain_2d), gain_2d.shape)
@@ -3197,26 +3211,46 @@ def plot_fading_analysis(
power_range = np.linspace(peak_val - 40, peak_val + 5, 200)
# CDF curves
- cdf_rayleigh = rayleigh_cdf(power_range, peak_val)
- cdf_rician_3 = rician_cdf(power_range, peak_val, K_factor=3)
- cdf_rician_6 = rician_cdf(power_range, peak_val, K_factor=6)
- cdf_rician_10 = rician_cdf(power_range, peak_val, K_factor=10)
+ if model == "rayleigh":
+ cdf_selected = rayleigh_cdf(power_range, peak_val)
+ cdf_reference = rician_cdf(power_range, peak_val, K_factor=max(k_factor, 1.0))
+ selected_label = "Rayleigh (selected)"
+ reference_label = f"Rician K={max(k_factor, 1.0):.1f}"
+ else:
+ cdf_selected = rician_cdf(power_range, peak_val, K_factor=max(k_factor, 0.1))
+ cdf_reference = rayleigh_cdf(power_range, peak_val)
+ selected_label = f"Rician K={max(k_factor, 0.1):.1f} (selected)"
+ reference_label = "Rayleigh"
# Fade margins for reliability range
reliability_range = np.linspace(50, 99.99, 100)
- margin_rayleigh = [fade_margin_for_reliability(r, "rayleigh")
- for r in reliability_range]
- margin_rician_6 = [fade_margin_for_reliability(r, "rician", K=6)
- for r in reliability_range]
- margin_rician_10 = [fade_margin_for_reliability(r, "rician", K=10)
- for r in reliability_range]
+ margin_selected = [
+ fade_margin_for_reliability(r, model, K=max(k_factor, 0.1))
+ for r in reliability_range
+ ]
+ if model == "rayleigh":
+ margin_reference = [
+ fade_margin_for_reliability(r, "rician", K=max(k_factor, 1.0))
+ for r in reliability_range
+ ]
+ margin_reference_label = f"Rician K={max(k_factor, 1.0):.1f}"
+ else:
+ margin_reference = [
+ fade_margin_for_reliability(r, "rayleigh")
+ for r in reliability_range
+ ]
+ margin_reference_label = "Rayleigh"
# Monte-Carlo fading at horizon
theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
- horizon_slice = gain_2d[theta_90_idx:theta_90_idx + 1, :]
+ horizon_slice = gain_2d[theta_90_idx : theta_90_idx + 1, :]
mean_db, std_db, p5_db = apply_statistical_fading(
- horizon_slice, theta_deg[theta_90_idx:theta_90_idx + 1],
- phi_deg, fading="rayleigh", realizations=500,
+ horizon_slice,
+ theta_deg[theta_90_idx : theta_90_idx + 1],
+ phi_deg,
+ fading=model,
+ K=max(k_factor, 0.1),
+ realizations=n_realizations,
)
# ---- Figure ----
@@ -3224,13 +3258,11 @@ def plot_fading_analysis(
# --- Top-left: CDF curves ---
ax = axes[0, 0]
- ax.semilogy(power_range, 1 - cdf_rayleigh, "r-", linewidth=2, label="Rayleigh (NLOS)")
- ax.semilogy(power_range, 1 - cdf_rician_3, "b--", linewidth=1.5, label="Rician K=3")
- ax.semilogy(power_range, 1 - cdf_rician_6, "g--", linewidth=1.5, label="Rician K=6")
- ax.semilogy(power_range, 1 - cdf_rician_10, "m--", linewidth=1.5, label="Rician K=10")
+ ax.semilogy(power_range, 1 - cdf_selected, "b-", linewidth=2, label=selected_label)
+ ax.semilogy(power_range, 1 - cdf_reference, "k--", linewidth=1.2, label=reference_label)
ax.axhline(y=0.01, color="gray", linestyle=":", alpha=0.7, label="99% reliability")
ax.set_xlabel(f"{data_label} ({data_unit})")
- ax.set_ylabel("P(signal > x) — CCDF")
+ ax.set_ylabel("P(signal > x) - CCDF")
ax.set_title("Fading CCDF at Peak Direction", fontweight="bold")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3, which="both")
@@ -3238,15 +3270,26 @@ def plot_fading_analysis(
# --- Top-right: Fade Margin vs Reliability ---
ax = axes[0, 1]
- ax.plot(reliability_range, margin_rayleigh, "r-", linewidth=2, label="Rayleigh")
- ax.plot(reliability_range, margin_rician_6, "g--", linewidth=1.5, label="Rician K=6")
- ax.plot(reliability_range, margin_rician_10, "m--", linewidth=1.5, label="Rician K=10")
+ ax.plot(reliability_range, margin_selected, "b-", linewidth=2, label=selected_label)
+ ax.plot(
+ reliability_range,
+ margin_reference,
+ "k--",
+ linewidth=1.2,
+ label=margin_reference_label,
+ )
ax.axvline(x=target_reliability, color="gray", linestyle=":", alpha=0.7)
- target_margin_ray = fade_margin_for_reliability(target_reliability, "rayleigh")
- ax.plot(target_reliability, target_margin_ray, "ro", markersize=8)
- ax.annotate(f"{target_margin_ray:.1f} dB",
- (target_reliability, target_margin_ray),
- textcoords="offset points", xytext=(10, 5), fontsize=9)
+ target_margin = fade_margin_for_reliability(
+ target_reliability, model, K=max(k_factor, 0.1)
+ )
+ ax.plot(target_reliability, target_margin, "bo", markersize=8)
+ ax.annotate(
+ f"{target_margin:.1f} dB",
+ (target_reliability, target_margin),
+ textcoords="offset points",
+ xytext=(10, 5),
+ fontsize=9,
+ )
ax.set_xlabel("Reliability (%)")
ax.set_ylabel("Required Fade Margin (dB)")
ax.set_title("Fade Margin vs Reliability", fontweight="bold")
@@ -3258,15 +3301,24 @@ def plot_fading_analysis(
mean_flat = mean_db.flatten()
std_flat = std_db.flatten()
p5_flat = p5_db.flatten()
- ax.fill_between(phi_deg, mean_flat - std_flat, mean_flat + std_flat,
- alpha=0.2, color="blue", label="±1σ envelope")
+ ax.fill_between(
+ phi_deg,
+ mean_flat - std_flat,
+ mean_flat + std_flat,
+ alpha=0.2,
+ color="blue",
+ label="+-1 sigma envelope",
+ )
ax.plot(phi_deg, mean_flat, "b-", linewidth=2, label="Mean (faded)")
- ax.plot(phi_deg, gain_2d[theta_90_idx, :], "k--", linewidth=1,
- label="Free-space")
+ ax.plot(phi_deg, gain_2d[theta_90_idx, :], "k--", linewidth=1, label="Free-space")
ax.plot(phi_deg, p5_flat, "r:", linewidth=1, label="5th percentile")
- ax.set_xlabel("Azimuth φ (°)")
+ ax.set_xlabel("Azimuth phi (deg)")
ax.set_ylabel(f"{data_label} ({data_unit})")
- ax.set_title("Rayleigh Fading Envelope at θ=90°", fontweight="bold")
+ if model == "rician":
+ fade_title = f"Rician Fading Envelope at theta=90 deg (K={max(k_factor, 0.1):.1f})"
+ else:
+ fade_title = "Rayleigh Fading Envelope at theta=90 deg"
+ ax.set_title(f"{fade_title}\n({n_realizations} realizations)", fontweight="bold")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
@@ -3274,10 +3326,14 @@ def plot_fading_analysis(
ax = axes[1, 1]
horizon_vals = gain_2d[theta_90_idx, :]
effective_pt = 0.0 if is_active else pt_dbm
- outage_prob = rayleigh_cdf(
- np.full_like(horizon_vals, pr_sensitivity_dbm),
- effective_pt + horizon_vals,
- )
+ rx_threshold = np.full_like(horizon_vals, pr_sensitivity_dbm)
+ mean_rx = effective_pt + horizon_vals
+ if model == "rician":
+ outage_prob = rician_cdf(rx_threshold, mean_rx, K_factor=max(k_factor, 0.1))
+ outage_title = f"Rician Outage per Azimuth (K={max(k_factor, 0.1):.1f})"
+ else:
+ outage_prob = rayleigh_cdf(rx_threshold, mean_rx)
+ outage_title = "Rayleigh Outage per Azimuth"
bar_width = np.mean(np.diff(phi_deg)) * 0.8 if len(phi_deg) > 1 else 3.0
colors_out = []
for op in outage_prob:
@@ -3287,18 +3343,22 @@ def plot_fading_analysis(
colors_out.append("#FFC107")
else:
colors_out.append("#F44336")
- ax.bar(phi_deg, outage_prob * 100, width=bar_width,
- color=colors_out, edgecolor="gray", linewidth=0.3)
+ ax.bar(
+ phi_deg,
+ outage_prob * 100,
+ width=bar_width,
+ color=colors_out,
+ edgecolor="gray",
+ linewidth=0.3,
+ )
ax.axhline(y=1.0, color="r", linestyle="--", linewidth=1, label="1% outage")
- ax.set_xlabel("Azimuth φ (°)")
+ ax.set_xlabel("Azimuth phi (deg)")
ax.set_ylabel("Outage Probability (%)")
- ax.set_title("Rayleigh Outage per Azimuth (at Rx Sensitivity)",
- fontweight="bold")
+ ax.set_title(f"{outage_title}\n(at Rx Sensitivity)", fontweight="bold")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
- fig.suptitle(f"Multipath Fading Analysis — {freq_mhz} MHz",
- fontsize=14, fontweight="bold")
+ fig.suptitle(f"Multipath Fading Analysis - {freq_mhz} MHz", fontsize=14, fontweight="bold")
plt.tight_layout()
if save_path:
@@ -3308,9 +3368,6 @@ def plot_fading_analysis(
else:
plt.show()
-
-# ——— ENHANCED MIMO PLOTS —————————————————————————————————————————
-
def plot_mimo_analysis(
ecc_values,
freq_list,
@@ -3318,32 +3375,51 @@ def plot_mimo_analysis(
theta_deg,
phi_deg,
snr_db=20,
+ snr_range_db=MIMO_SNR_RANGE_DB,
fading="rayleigh",
K=10,
+ xpr_db=6.0,
save_path=None,
):
"""
MIMO analysis: capacity curves, combining gain comparison, pattern overlay.
"""
_ = freq_list # reserved for per-frequency analysis in future
+ if ecc_values is None:
+ ecc_values = []
+ if gain_data_list is None:
+ gain_data_list = []
+
fig = plt.figure(figsize=(18, 6.5))
fig_gs = fig.add_gridspec(1, 3, width_ratios=[1, 1, 1])
# --- Left: Capacity vs SNR ---
ax_cap = fig.add_subplot(fig_gs[0])
- ecc_median = float(np.median(ecc_values)) if len(ecc_values) > 0 else 0.3
+ ecc_arr = np.asarray(ecc_values, dtype=float).reshape(-1)
+ ecc_arr = ecc_arr[np.isfinite(ecc_arr)]
+ ecc_median = float(np.median(ecc_arr)) if ecc_arr.size > 0 else 0.3
+ ecc_median = float(np.clip(ecc_median, 0.0, 1.0))
+
+ try:
+ snr_lo, snr_hi = snr_range_db
+ except Exception:
+ snr_lo, snr_hi = -5, 30
+ if snr_lo >= snr_hi:
+ snr_lo, snr_hi = -5, 30
+
snr_axis, siso_cap, awgn_cap, fading_cap = mimo_capacity_vs_snr(
- ecc_median, snr_range_db=(-5, 30), fading=fading, K=K,
+ ecc_median,
+ snr_range_db=(snr_lo, snr_hi),
+ fading=fading,
+ K=K,
)
ax_cap.plot(snr_axis, siso_cap, "k--", linewidth=1.5, label="SISO")
- ax_cap.plot(snr_axis, awgn_cap, "b-", linewidth=2, label="2×2 AWGN")
- ax_cap.plot(snr_axis, fading_cap, "r-", linewidth=2,
- label=f"2×2 {fading.capitalize()}")
+ ax_cap.plot(snr_axis, awgn_cap, "b-", linewidth=2, label="2x2 AWGN")
+ ax_cap.plot(snr_axis, fading_cap, "r-", linewidth=2, label=f"2x2 {fading.capitalize()}")
ax_cap.axvline(x=snr_db, color="gray", linestyle=":", alpha=0.7)
ax_cap.set_xlabel("SNR (dB)")
ax_cap.set_ylabel("Capacity (b/s/Hz)")
- ax_cap.set_title(f"Channel Capacity (ECC={ecc_median:.3f})",
- fontweight="bold")
+ ax_cap.set_title(f"Channel Capacity (ECC={ecc_median:.3f})", fontweight="bold")
ax_cap.legend(fontsize=8)
ax_cap.grid(True, alpha=0.3)
@@ -3365,35 +3441,61 @@ def plot_mimo_analysis(
ax_comb.plot(phi_deg, mrc_imp, "b-", linewidth=2, label="MRC")
ax_comb.plot(phi_deg, egc_imp, "g--", linewidth=1.5, label="EGC")
ax_comb.plot(phi_deg, sc_imp, "r:", linewidth=1.5, label="Selection")
- ax_comb.set_xlabel("Azimuth φ (°)")
+ ax_comb.set_xlabel("Azimuth phi (deg)")
ax_comb.set_ylabel("Combining Improvement (dB)")
- ax_comb.set_title("Combining Gain at θ=90°", fontweight="bold")
+ ax_comb.set_title("Combining Gain at theta=90 deg", fontweight="bold")
ax_comb.legend(fontsize=8)
ax_comb.grid(True, alpha=0.3)
else:
- ax_comb.text(0.5, 0.5, "Requires 2+ antenna\npatterns loaded",
- ha="center", va="center", fontsize=12,
- transform=ax_comb.transAxes)
+ ax_comb.text(
+ 0.5,
+ 0.5,
+ "Insufficient element patterns\n(need 2+ patterns)",
+ ha="center",
+ va="center",
+ fontsize=12,
+ transform=ax_comb.transAxes,
+ )
ax_comb.set_title("Combining Gain", fontweight="bold")
# --- Right: Pattern Correlation (overlaid polar) ---
ax_polar = fig.add_subplot(fig_gs[2], projection="polar")
- phi_rad = np.deg2rad(phi_deg)
- theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
+ if len(gain_data_list) >= 2:
+ phi_rad = np.deg2rad(phi_deg)
+ theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
- colors_list = ["#4A90E2", "#E63946", "#4CAF50", "#FFC107"]
- for idx, g2d in enumerate(gain_data_list[:4]):
- color = colors_list[idx % len(colors_list)]
- pattern = g2d[theta_90_idx, :]
- pattern_norm = pattern - np.min(pattern)
- ax_polar.plot(phi_rad, pattern_norm, color=color, linewidth=1.5,
- label=f"Ant {idx + 1}")
+ colors_list = ["#4A90E2", "#E63946", "#4CAF50", "#FFC107"]
+ for idx, g2d in enumerate(gain_data_list[:4]):
+ color = colors_list[idx % len(colors_list)]
+ pattern = g2d[theta_90_idx, :]
+ pattern_norm = pattern - np.min(pattern)
+ ax_polar.plot(phi_rad, pattern_norm, color=color, linewidth=1.5, label=f"Ant {idx + 1}")
+ ax_polar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=8)
+ else:
+ ax_polar.text(
+ 0.5,
+ 0.5,
+ "Insufficient element patterns\n(need 2+ patterns)",
+ ha="center",
+ va="center",
+ fontsize=11,
+ transform=ax_polar.transAxes,
+ )
- ax_polar.set_title("Pattern Overlay (θ=90°)\nNormalized",
- fontweight="bold", pad=15)
+ ax_polar.set_title("Pattern Overlay (theta=90 deg)\nNormalized", fontweight="bold", pad=15)
ax_polar.set_theta_zero_location("N")
ax_polar.set_theta_direction(-1)
- ax_polar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=8)
+
+ if len(gain_data_list) > 0:
+ meg_vals = mean_effective_gain_mimo(gain_data_list, theta_deg, phi_deg, xpr_db=xpr_db)
+ meg_summary = ", ".join([f"A{i + 1}:{v:.1f} dB" for i, v in enumerate(meg_vals[:4])])
+ ax_cap.annotate(
+ f"MEG @ XPR={xpr_db:.1f} dB: {meg_summary}",
+ xy=(0.02, 0.03),
+ xycoords="axes fraction",
+ fontsize=8,
+ bbox=dict(facecolor="white", alpha=0.75, edgecolor="gray"),
+ )
fig.suptitle("MIMO / Diversity Analysis", fontsize=14, fontweight="bold")
plt.tight_layout()
@@ -3405,9 +3507,6 @@ def plot_mimo_analysis(
else:
plt.show()
-
-# ——— WEARABLE / MEDICAL DEVICE PLOTS —————————————————————————————
-
def plot_wearable_assessment(
freq_mhz,
gain_2d,
@@ -3577,6 +3676,17 @@ def generate_advanced_analysis_plots(
fading_pr_sensitivity_dbm=-98.0,
fading_pt_dbm=0.0,
fading_target_reliability=99.0,
+ fading_model="rayleigh",
+ fading_rician_k=10.0,
+ fading_realizations=1000,
+ # MIMO params
+ mimo_enabled=False,
+ mimo_snr_db=20.0,
+ mimo_fading_model="rayleigh",
+ mimo_rician_k=10.0,
+ mimo_xpr_db=6.0,
+ mimo_ecc_values=None,
+ mimo_gain_data_list=None,
# Wearable params
wearable_enabled=False,
wearable_body_positions=None,
@@ -3590,39 +3700,89 @@ def generate_advanced_analysis_plots(
"""
if link_budget_enabled:
plot_link_budget_summary(
- frequency, gain_2d, theta_deg, phi_deg,
- pt_dbm=lb_pt_dbm, pr_dbm=lb_pr_dbm, gr_dbi=lb_gr_dbi,
- path_loss_exp=lb_path_loss_exp, misc_loss_db=lb_misc_loss_db,
+ frequency,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ pt_dbm=lb_pt_dbm,
+ pr_dbm=lb_pr_dbm,
+ gr_dbi=lb_gr_dbi,
+ path_loss_exp=lb_path_loss_exp,
+ misc_loss_db=lb_misc_loss_db,
target_range_m=lb_target_range_m,
- data_label=data_label, data_unit=data_unit, save_path=save_path,
+ data_label=data_label,
+ data_unit=data_unit,
+ save_path=save_path,
)
if indoor_enabled:
plot_indoor_coverage_map(
- frequency, gain_2d, theta_deg, phi_deg,
- pt_dbm=lb_pt_dbm, pr_sensitivity_dbm=lb_pr_dbm,
- environment=indoor_environment, path_loss_exp=indoor_path_loss_exp,
- n_walls=indoor_n_walls, wall_material=indoor_wall_material,
+ frequency,
+ gain_2d,
+ theta_deg,
+ phi_deg,
+ pt_dbm=lb_pt_dbm,
+ pr_sensitivity_dbm=lb_pr_dbm,
+ environment=indoor_environment,
+ path_loss_exp=indoor_path_loss_exp,
+ n_walls=indoor_n_walls,
+ wall_material=indoor_wall_material,
shadow_fading_db=indoor_shadow_fading_db,
max_distance_m=indoor_max_distance_m,
- data_label=data_label, data_unit=data_unit, save_path=save_path,
+ data_label=data_label,
+ data_unit=data_unit,
+ save_path=save_path,
)
if fading_enabled:
plot_fading_analysis(
- frequency, gain_2d, theta_deg, phi_deg,
+ frequency,
+ gain_2d,
+ theta_deg,
+ phi_deg,
pr_sensitivity_dbm=fading_pr_sensitivity_dbm,
pt_dbm=fading_pt_dbm,
target_reliability=fading_target_reliability,
- data_label=data_label, data_unit=data_unit, save_path=save_path,
+ fading_model=fading_model,
+ fading_rician_k=fading_rician_k,
+ realizations=fading_realizations,
+ data_label=data_label,
+ data_unit=data_unit,
+ save_path=save_path,
+ )
+
+ if mimo_enabled:
+ gain_list = mimo_gain_data_list if mimo_gain_data_list is not None else [gain_2d]
+ if not isinstance(gain_list, (list, tuple)):
+ gain_list = [gain_list]
+ gain_list = [g for g in gain_list if g is not None]
+ if not gain_list:
+ gain_list = [gain_2d]
+
+ plot_mimo_analysis(
+ ecc_values=mimo_ecc_values if mimo_ecc_values is not None else [],
+ freq_list=[frequency],
+ gain_data_list=list(gain_list),
+ theta_deg=theta_deg,
+ phi_deg=phi_deg,
+ snr_db=mimo_snr_db,
+ fading=mimo_fading_model,
+ K=mimo_rician_k,
+ xpr_db=mimo_xpr_db,
+ save_path=save_path,
)
if wearable_enabled:
plot_wearable_assessment(
- frequency, gain_2d, theta_deg, phi_deg,
+ frequency,
+ gain_2d,
+ theta_deg,
+ phi_deg,
body_positions=wearable_body_positions,
tx_power_mw=wearable_tx_power_mw,
num_devices=wearable_num_devices,
room_size=wearable_room_size,
- data_label=data_label, data_unit=data_unit, save_path=save_path,
+ data_label=data_label,
+ data_unit=data_unit,
+ save_path=save_path,
)
diff --git a/tests/test_advanced_analysis.py b/tests/test_advanced_analysis.py
index 3d7c1b2..f7105b8 100644
--- a/tests/test_advanced_analysis.py
+++ b/tests/test_advanced_analysis.py
@@ -284,6 +284,19 @@ def test_apply_statistical_fading_output(self):
assert std_db.shape == gain_2d.shape
assert outage_5pct_db.shape == gain_2d.shape
+ def test_apply_statistical_fading_rician_output(self):
+ """Rician fading path should also return correctly-shaped outputs."""
+ theta, phi, gain_2d = _make_isotropic_pattern(n_theta=19, n_phi=37)
+ mean_db, std_db, outage_5pct_db = apply_statistical_fading(
+ gain_2d, theta, phi, fading="rician", K=6.0, realizations=120,
+ )
+ assert mean_db.shape == gain_2d.shape
+ assert std_db.shape == gain_2d.shape
+ assert outage_5pct_db.shape == gain_2d.shape
+ assert np.all(np.isfinite(mean_db))
+ assert np.all(np.isfinite(std_db))
+ assert np.all(np.isfinite(outage_5pct_db))
+
def test_delay_spread_positive(self):
"""Delay spread should be positive for any environment."""
ds = delay_spread_estimate(10.0, "indoor")
diff --git a/tests/test_advanced_analysis_integration.py b/tests/test_advanced_analysis_integration.py
new file mode 100644
index 0000000..6f3f8a8
--- /dev/null
+++ b/tests/test_advanced_analysis_integration.py
@@ -0,0 +1,617 @@
+"""Integration and dispatcher tests for advanced analysis wiring."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+import plot_antenna.calculations as calculations
+import plot_antenna.file_utils as file_utils
+import plot_antenna.plotting as plotting
+from plot_antenna.gui.callbacks_mixin import CallbacksMixin
+from plot_antenna.gui.dialogs_mixin import DialogsMixin
+
+
+class DummyVar:
+ """Simple stand-in for Tkinter variables in unit tests."""
+
+ def __init__(self, value):
+ self._value = value
+
+ def get(self):
+ return self._value
+
+ def set(self, value):
+ self._value = value
+
+
+class _CallbacksHarness(CallbacksMixin):
+ """Lightweight harness to call callback logic without a GUI."""
+
+ def __init__(self):
+ self.TRP_file_path = "dummy_trp.txt"
+ self.interpolate_3d_plots = False
+ self.axis_scale_mode = DummyVar("auto")
+ self.axis_min = DummyVar(-20.0)
+ self.axis_max = DummyVar(6.0)
+ self.maritime_plots_enabled = False
+ self.horizon_theta_min = DummyVar(60.0)
+ self.horizon_theta_max = DummyVar(120.0)
+ self.horizon_gain_threshold = DummyVar(-3.0)
+ self.horizon_theta_cuts_var = DummyVar("60,90,120")
+
+ # Advanced analysis toggles
+ self.link_budget_enabled = True
+ self.indoor_analysis_enabled = True
+ self.fading_analysis_enabled = True
+ self.mimo_analysis_enabled = True
+ self.wearable_analysis_enabled = False
+
+ # Link budget / indoor
+ self.lb_tx_power = DummyVar(3.0)
+ self.lb_rx_sensitivity = DummyVar(-96.0)
+ self.lb_rx_gain = DummyVar(0.5)
+ self.lb_path_loss_exp = DummyVar(2.2)
+ self.lb_misc_loss = DummyVar(8.0)
+ self.lb_target_range = DummyVar(15.0)
+ self.indoor_environment = DummyVar("Office")
+ self.indoor_path_loss_exp = DummyVar(3.1)
+ self.indoor_num_walls = DummyVar(2)
+ self.indoor_wall_material = DummyVar("drywall")
+ self.indoor_shadow_fading = DummyVar(4.5)
+ self.indoor_max_distance = DummyVar(25.0)
+
+ # Fading / MIMO
+ self.fading_target_reliability = DummyVar(98.0)
+ self.fading_model = DummyVar("rician")
+ self.fading_rician_k = DummyVar(7.5)
+ self.fading_realizations = DummyVar(321)
+ self.mimo_snr = DummyVar(16.0)
+ self.mimo_fading_model = DummyVar("rician")
+ self.mimo_rician_k = DummyVar(6.0)
+ self.mimo_xpr = DummyVar(4.0)
+
+ # Wearable values are always collected even if disabled
+ self.wearable_positions_var = {
+ "wrist": DummyVar(True),
+ "chest": DummyVar(False),
+ "hip": DummyVar(False),
+ "head": DummyVar(False),
+ }
+ self.wearable_tx_power_mw = DummyVar(1.0)
+ self.wearable_device_count = DummyVar(10)
+ self.wearable_room_x = DummyVar(10.0)
+ self.wearable_room_y = DummyVar(8.0)
+ self.wearable_room_z = DummyVar(3.0)
+
+ self._measurement_context = {"processing_complete": False, "key_metrics": {}}
+
+ def log_message(self, message: str, level: str = "info") -> None:
+ _ = (message, level)
+
+
+class _DialogHarness(DialogsMixin):
+ """Harness for trace cleanup tests."""
+
+
+class _TraceVar:
+ """Trace var stub that records removals."""
+
+ def __init__(self):
+ self.removals = []
+
+ def trace_remove(self, mode, trace_id):
+ self.removals.append((mode, trace_id))
+
+
+def _simple_grid(n_theta=7, n_phi=13, value=0.0):
+ theta = np.linspace(0, 180, n_theta)
+ phi = np.linspace(0, 360, n_phi)
+ gain = np.full((n_theta, n_phi), value, dtype=float)
+ return theta, phi, gain
+
+
+class _AxisStub:
+ """Minimal matplotlib axis stub for non-GUI plotting tests."""
+
+ def semilogy(self, *args, **kwargs): ...
+ def axhline(self, *args, **kwargs): ...
+ def set_xlabel(self, *args, **kwargs): ...
+ def set_ylabel(self, *args, **kwargs): ...
+ def set_title(self, *args, **kwargs): ...
+ def legend(self, *args, **kwargs): ...
+ def grid(self, *args, **kwargs): ...
+ def set_ylim(self, *args, **kwargs): ...
+ def plot(self, *args, **kwargs): ...
+ def axvline(self, *args, **kwargs): ...
+ def annotate(self, *args, **kwargs): ...
+ def fill_between(self, *args, **kwargs): ...
+ def bar(self, *args, **kwargs): ...
+
+
+class _FigureStub:
+ """Minimal matplotlib figure stub for non-GUI plotting tests."""
+
+ def suptitle(self, *args, **kwargs): ...
+
+ def savefig(self, path, *args, **kwargs):
+ Path(path).write_text("stub")
+
+
+def test_generate_advanced_analysis_plots_all_enabled_creates_expected_artifacts(
+ monkeypatch, tmp_path
+):
+ theta, phi, gain_2d = _simple_grid()
+
+ def _artifact_stub(filename):
+ def _fn(*args, save_path=None, **kwargs):
+ Path(save_path, filename).write_text("ok")
+
+ return _fn
+
+ monkeypatch.setattr(
+ plotting,
+ "plot_link_budget_summary",
+ _artifact_stub("link_budget_2450.0MHz.png"),
+ )
+ monkeypatch.setattr(
+ plotting,
+ "plot_indoor_coverage_map",
+ _artifact_stub("indoor_coverage_2450.0MHz.png"),
+ )
+ monkeypatch.setattr(
+ plotting,
+ "plot_fading_analysis",
+ _artifact_stub("fading_analysis_2450.0MHz.png"),
+ )
+ monkeypatch.setattr(
+ plotting,
+ "plot_mimo_analysis",
+ _artifact_stub("mimo_analysis.png"),
+ )
+ monkeypatch.setattr(
+ plotting,
+ "plot_wearable_assessment",
+ _artifact_stub("wearable_assessment_2450.0MHz.png"),
+ )
+
+ plotting.generate_advanced_analysis_plots(
+ theta,
+ phi,
+ gain_2d,
+ 2450.0,
+ data_label="Gain",
+ data_unit="dBi",
+ save_path=str(tmp_path),
+ link_budget_enabled=True,
+ indoor_enabled=True,
+ fading_enabled=True,
+ fading_model="rician",
+ fading_rician_k=6.0,
+ fading_realizations=80,
+ mimo_enabled=True,
+ mimo_gain_data_list=[gain_2d],
+ wearable_enabled=True,
+ wearable_body_positions=["wrist"],
+ )
+
+ expected = {
+ "link_budget_2450.0MHz.png",
+ "indoor_coverage_2450.0MHz.png",
+ "fading_analysis_2450.0MHz.png",
+ "mimo_analysis.png",
+ "wearable_assessment_2450.0MHz.png",
+ }
+ generated = {p.name for p in tmp_path.iterdir() if p.is_file()}
+ assert expected.issubset(generated)
+
+
+@pytest.mark.parametrize(
+ "model, expected_outage_fn",
+ [
+ ("rayleigh", "rayleigh"),
+ ("rician", "rician"),
+ ],
+)
+def test_plot_fading_analysis_uses_selected_runtime_model(
+ model, expected_outage_fn, monkeypatch, tmp_path
+):
+ theta, phi, gain_2d = _simple_grid()
+ calls = {"apply": [], "rayleigh": [], "rician": []}
+
+ def _fake_apply(g2d, theta_deg, phi_deg, fading="rayleigh", K=10, realizations=1000):
+ calls["apply"].append((fading, K, realizations, g2d.shape))
+ return np.zeros_like(g2d), np.ones_like(g2d), np.zeros_like(g2d)
+
+ def _fake_rayleigh(power_db, mean_power_db=0.0):
+ shape = np.asarray(power_db).shape
+ calls["rayleigh"].append(shape)
+ return np.full(shape, 0.5, dtype=float)
+
+ def _fake_rician(power_db, mean_power_db=0.0, K_factor=10.0):
+ shape = np.asarray(power_db).shape
+ calls["rician"].append(shape)
+ return np.full(shape, 0.5, dtype=float)
+
+ monkeypatch.setattr(plotting, "apply_statistical_fading", _fake_apply)
+ monkeypatch.setattr(plotting, "rayleigh_cdf", _fake_rayleigh)
+ monkeypatch.setattr(plotting, "rician_cdf", _fake_rician)
+ monkeypatch.setattr(
+ plotting.plt,
+ "subplots",
+ lambda *args, **kwargs: (
+ _FigureStub(),
+ np.array([[_AxisStub(), _AxisStub()], [_AxisStub(), _AxisStub()]], dtype=object),
+ ),
+ )
+ monkeypatch.setattr(plotting.plt, "tight_layout", lambda *args, **kwargs: None)
+ monkeypatch.setattr(plotting.plt, "close", lambda *args, **kwargs: None)
+ monkeypatch.setattr(plotting.plt, "show", lambda *args, **kwargs: None)
+
+ plotting.plot_fading_analysis(
+ 2450.0,
+ gain_2d,
+ theta,
+ phi,
+ fading_model=model,
+ fading_rician_k=5.5,
+ realizations=111,
+ save_path=str(tmp_path),
+ )
+
+ assert calls["apply"], "apply_statistical_fading should be invoked"
+ fading_name, k_used, n_used, shape_used = calls["apply"][0]
+ assert fading_name == model
+ assert shape_used == (1, len(phi))
+ assert n_used == 111
+ if model == "rician":
+ assert abs(k_used - 5.5) < 1e-12
+
+ # The outage branch uses horizon-size arrays (len(phi),).
+ assert (len(phi),) in calls[expected_outage_fn]
+
+
+def test_generate_advanced_analysis_plots_invokes_mimo_with_runtime_controls(monkeypatch):
+ theta, phi, gain_2d = _simple_grid()
+ captured = {}
+
+ def _spy_mimo(*args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr(plotting, "plot_mimo_analysis", _spy_mimo)
+
+ plotting.generate_advanced_analysis_plots(
+ theta,
+ phi,
+ gain_2d,
+ 5800.0,
+ mimo_enabled=True,
+ mimo_snr_db=18.0,
+ mimo_fading_model="rician",
+ mimo_rician_k=4.0,
+ mimo_xpr_db=7.0,
+ mimo_ecc_values=[0.11, 0.22],
+ mimo_gain_data_list=[gain_2d],
+ )
+
+ assert "kwargs" in captured
+ assert captured["kwargs"]["snr_db"] == 18.0
+ assert captured["kwargs"]["fading"] == "rician"
+ assert captured["kwargs"]["K"] == 4.0
+ assert captured["kwargs"]["xpr_db"] == 7.0
+ assert captured["kwargs"]["ecc_values"] == [0.11, 0.22]
+ assert len(captured["kwargs"]["gain_data_list"]) == 1
+
+
+def test_callbacks_active_forwarding_passes_full_advanced_params(monkeypatch):
+ harness = _CallbacksHarness()
+ theta = np.array([0.0, 90.0, 180.0])
+ phi = np.array([0.0, 180.0, 360.0])
+ theta_rad = np.deg2rad(theta)
+ phi_rad = np.deg2rad(phi)
+ grid = np.zeros((len(theta), len(phi)))
+
+ monkeypatch.setattr(
+ plotting,
+ "plot_active_2d_data",
+ lambda *args, **kwargs: None,
+ )
+ monkeypatch.setattr(
+ plotting,
+ "plot_active_3d_data",
+ lambda *args, **kwargs: None,
+ )
+ monkeypatch.setattr(
+ plotting,
+ "generate_maritime_plots",
+ lambda *args, **kwargs: None,
+ )
+
+ captured = {}
+
+ def _spy_advanced(*args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.generate_advanced_analysis_plots",
+ _spy_advanced,
+ )
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.plot_active_2d_data",
+ lambda *args, **kwargs: None,
+ )
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.plot_active_3d_data",
+ lambda *args, **kwargs: None,
+ )
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.generate_maritime_plots",
+ lambda *args, **kwargs: None,
+ )
+
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.read_active_file",
+ lambda _path: {
+ "Frequency": 2450.0,
+ "Start Phi": 0.0,
+ "Start Theta": 0.0,
+ "Stop Phi": 360.0,
+ "Stop Theta": 180.0,
+ "Inc Phi": 180.0,
+ "Inc Theta": 90.0,
+ "Calculated TRP(dBm)": 0.0,
+ "Theta_Angles_Deg": theta,
+ "Phi_Angles_Deg": phi,
+ "H_Power_dBm": grid,
+ "V_Power_dBm": grid,
+ },
+ )
+ monkeypatch.setattr(
+ "plot_antenna.gui.callbacks_mixin.calculate_active_variables",
+ lambda *args, **kwargs: (
+ 9,
+ theta,
+ phi,
+ theta_rad,
+ phi_rad,
+ grid,
+ grid,
+ grid,
+ phi,
+ phi_rad,
+ grid,
+ grid,
+ grid,
+ -1.0,
+ 1.0,
+ -1.0,
+ 1.0,
+ -1.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ )
+
+ harness._process_active_data()
+ assert "kwargs" in captured
+ kwargs = captured["kwargs"]
+ assert kwargs["fading_model"] == "rician"
+ assert kwargs["fading_rician_k"] == 7.5
+ assert kwargs["fading_realizations"] == 321
+ assert kwargs["mimo_enabled"] is True
+ assert kwargs["mimo_fading_model"] == "rician"
+ assert kwargs["mimo_rician_k"] == 6.0
+ assert kwargs["mimo_xpr_db"] == 4.0
+ assert len(kwargs["mimo_gain_data_list"]) == 1
+
+
+def test_batch_passive_forwarding_injects_mimo_gain_data(monkeypatch, tmp_path):
+ theta, phi, grid = _simple_grid(n_theta=3, n_phi=3)
+ base_dir = tmp_path / "in"
+ base_dir.mkdir()
+ out_dir = tmp_path / "out"
+
+ monkeypatch.setattr(file_utils.os, "listdir", lambda _path: ["DemoAP_HPol.txt", "DemoAP_VPol.txt"])
+ monkeypatch.setattr(
+ file_utils,
+ "read_passive_file",
+ lambda _path: ([{"frequency": 2450.0}], 0, 360, 180, 0, 180, 90),
+ )
+ monkeypatch.setattr(file_utils, "angles_match", lambda *args, **kwargs: True)
+ monkeypatch.setattr(
+ file_utils,
+ "calculate_passive_variables",
+ lambda *args, **kwargs: (theta, phi, grid, grid, grid),
+ )
+
+ monkeypatch.setattr(plotting, "plot_2d_passive_data", lambda *args, **kwargs: None)
+ monkeypatch.setattr(plotting, "plot_passive_3d_component", lambda *args, **kwargs: None)
+ monkeypatch.setattr(plotting, "_prepare_gain_grid", lambda *args, **kwargs: (theta, phi, grid))
+
+ captured = {}
+
+ def _spy_advanced(*args, **kwargs):
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr(plotting, "generate_advanced_analysis_plots", _spy_advanced)
+
+ file_utils.batch_process_passive_scans(
+ folder_path=str(base_dir),
+ freq_list=[2450.0],
+ selected_frequencies=[2450.0],
+ save_base=str(out_dir),
+ advanced_analysis_params={
+ "fading_enabled": True,
+ "fading_model": "rician",
+ "fading_rician_k": 4.0,
+ "fading_realizations": 77,
+ "mimo_enabled": True,
+ },
+ )
+
+ assert "kwargs" in captured
+ kwargs = captured["kwargs"]
+ assert kwargs["fading_model"] == "rician"
+ assert kwargs["fading_rician_k"] == 4.0
+ assert kwargs["fading_realizations"] == 77
+ assert kwargs["mimo_enabled"] is True
+ assert "mimo_gain_data_list" in kwargs
+ assert len(kwargs["mimo_gain_data_list"]) == 1
+ assert np.array_equal(kwargs["mimo_gain_data_list"][0], grid)
+
+
+def test_batch_active_forwarding_injects_mimo_gain_data(monkeypatch, tmp_path):
+ theta = np.array([0.0, 90.0, 180.0])
+ phi = np.array([0.0, 180.0, 360.0])
+ phi_rad = np.deg2rad(phi)
+ grid = np.zeros((len(theta), len(phi)))
+ base_dir = tmp_path / "in"
+ base_dir.mkdir()
+ out_dir = tmp_path / "out"
+
+ monkeypatch.setattr(file_utils.os, "listdir", lambda _path: ["Demo_TRP.txt"])
+ monkeypatch.setattr(
+ file_utils,
+ "read_active_file",
+ lambda _path: {
+ "Frequency": 2450.0,
+ "Start Phi": 0.0,
+ "Start Theta": 0.0,
+ "Stop Phi": 360.0,
+ "Stop Theta": 180.0,
+ "Inc Phi": 180.0,
+ "Inc Theta": 90.0,
+ "H_Power_dBm": grid,
+ "V_Power_dBm": grid,
+ },
+ )
+ monkeypatch.setattr(
+ calculations,
+ "calculate_active_variables",
+ lambda *args, **kwargs: (
+ 9,
+ theta,
+ phi,
+ np.deg2rad(theta),
+ phi_rad,
+ grid,
+ grid,
+ grid,
+ phi,
+ phi_rad,
+ grid,
+ grid,
+ grid,
+ -1.0,
+ 1.0,
+ -1.0,
+ 1.0,
+ -1.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ ),
+ )
+
+ monkeypatch.setattr(plotting, "plot_active_2d_data", lambda *args, **kwargs: None)
+ monkeypatch.setattr(plotting, "plot_active_3d_data", lambda *args, **kwargs: None)
+
+ captured = {}
+
+ def _spy_advanced(*args, **kwargs):
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr(plotting, "generate_advanced_analysis_plots", _spy_advanced)
+
+ file_utils.batch_process_active_scans(
+ folder_path=str(base_dir),
+ save_base=str(out_dir),
+ advanced_analysis_params={
+ "fading_enabled": True,
+ "fading_model": "rician",
+ "fading_rician_k": 8.0,
+ "fading_realizations": 66,
+ "mimo_enabled": True,
+ },
+ )
+
+ assert "kwargs" in captured
+ kwargs = captured["kwargs"]
+ assert kwargs["fading_model"] == "rician"
+ assert kwargs["fading_rician_k"] == 8.0
+ assert kwargs["fading_realizations"] == 66
+ assert kwargs["mimo_enabled"] is True
+ assert "mimo_gain_data_list" in kwargs
+ assert len(kwargs["mimo_gain_data_list"]) == 1
+ assert np.array_equal(kwargs["mimo_gain_data_list"][0], grid)
+
+
+def test_batch_active_summary_tracks_per_file_failures(monkeypatch, tmp_path):
+ base_dir = tmp_path / "in"
+ base_dir.mkdir()
+
+ monkeypatch.setattr(file_utils.os, "listdir", lambda _path: ["Broken_TRP.txt"])
+ monkeypatch.setattr(
+ file_utils,
+ "read_active_file",
+ lambda _path: (_ for _ in ()).throw(ValueError("malformed active file")),
+ )
+
+ summary = file_utils.batch_process_active_scans(folder_path=str(base_dir))
+
+ assert summary["total_files"] == 1
+ assert summary["processed"] == 0
+ assert summary["failed"] == 1
+ assert len(summary["errors"]) == 1
+ assert summary["errors"][0]["file"] == "Broken_TRP.txt"
+
+
+def test_batch_passive_summary_tracks_pair_read_failures(monkeypatch, tmp_path):
+ base_dir = tmp_path / "in"
+ base_dir.mkdir()
+
+ monkeypatch.setattr(file_utils.os, "listdir", lambda _path: ["DemoAP_HPol.txt", "DemoAP_VPol.txt"])
+ monkeypatch.setattr(
+ file_utils,
+ "read_passive_file",
+ lambda _path: (_ for _ in ()).throw(ValueError("malformed passive file")),
+ )
+
+ summary = file_utils.batch_process_passive_scans(
+ folder_path=str(base_dir),
+ freq_list=[2450.0, 5800.0],
+ selected_frequencies=[2450.0, 5800.0],
+ )
+
+ assert summary["total_pairs"] == 1
+ assert summary["total_jobs"] == 2
+ assert summary["processed"] == 0
+ assert summary["failed"] == 2
+ assert summary["skipped"] == 0
+ assert len(summary["errors"]) == 1
+ assert summary["errors"][0]["pair"].endswith("DemoAP_HPol.txt | DemoAP_VPol.txt")
+
+
+def test_cleanup_advanced_analysis_traces_clears_registered_handlers():
+ harness = _DialogHarness()
+ trace_var = _TraceVar()
+
+ harness._advanced_trace_handles = [
+ (trace_var, "write", "trace_a"),
+ (trace_var, "write", "trace_b"),
+ ]
+ harness._cleanup_advanced_analysis_traces()
+ assert trace_var.removals == [("write", "trace_a"), ("write", "trace_b")]
+ assert harness._advanced_trace_handles == []
+
+ harness._advanced_trace_handles = [(trace_var, "write", "trace_c")]
+ harness._cleanup_advanced_analysis_traces()
+ assert trace_var.removals[-1] == ("write", "trace_c")
+ assert harness._advanced_trace_handles == []
diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py
index f01a654..a243b08 100644
--- a/tests/test_file_utils.py
+++ b/tests/test_file_utils.py
@@ -1,18 +1,9 @@
"""
-Smoke tests for plot_antenna.file_utils module
-
-These are basic smoke tests to verify imports and basic functionality.
-Comprehensive file parsing tests to be added in future iterations.
-
-TODO for v4.1:
-- Add tests with actual sample data files in fixtures/
-- Test all supported file formats (Howland, CST, VNA)
-- Test error handling for malformed files
-- Test edge cases (empty files, unicode paths, etc.)
+Core tests for plot_antenna.file_utils module.
"""
+import numpy as np
import pytest
-from pathlib import Path
# Verify imports work
from plot_antenna.file_utils import (
@@ -22,6 +13,47 @@
)
+def _build_minimal_passive_file_text():
+ """Build a tiny passive export text that parse_passive_file can consume."""
+ lines = [
+ "Axis1 Start Angle: 0 Deg",
+ "Axis1 Stop Angle: 180 Deg",
+ "Axis1 Increment: 180 Deg",
+ "Axis2 Start Angle: 0 Deg",
+ "Axis2 Stop Angle: 90 Deg",
+ "Axis2 Increment: 90 Deg",
+ "Cal Std Antenna Peak Gain Factor = 2.5 dB",
+ "Test Frequency = 2450 MHz",
+ "THETA\t PHI\t Mag\t Phase",
+ "",
+ "0\t0\t1.0\t0.0",
+ "0\t180\t2.0\t10.0",
+ "90\t0\t3.0\t20.0",
+ "90\t180\t4.0\t30.0",
+ ]
+ return "\n".join(lines) + "\n"
+
+
+def _build_minimal_active_file_text():
+ """Build a minimal V5.03 active TRP export with one data sample."""
+ lines = [f"Header line {i}" for i in range(55)]
+ lines[0] = "Howland Chamber Export V5.03"
+ lines[5] = "Total Radiated Power Test"
+ lines[13] = "Test Frequency: 2450 MHz"
+ lines[15] = "Test Type: Discrete Test"
+ lines[31] = "Start Phi: 0 Deg"
+ lines[32] = "Stop Phi: 360 Deg"
+ lines[33] = "Inc Phi: 180 Deg"
+ lines[38] = "Start Theta: 0 Deg"
+ lines[39] = "Stop Theta: 180 Deg"
+ lines[40] = "Inc Theta: 90 Deg"
+ lines[46] = "H Cal Factor = 1.0 dB"
+ lines[47] = "V Cal Factor = 2.0 dB"
+ lines[49] = "Calculated TRP = -3.0 dBm"
+ lines[54] = "0 0 -10 -20"
+ return "\n".join(lines) + "\n"
+
+
class TestImports:
"""Test that all file_utils functions can be imported"""
@@ -51,26 +83,90 @@ def test_nonexistent_vna_file(self):
parse_2port_data("nonexistent_file_12345.csv")
-# Mark comprehensive tests as TODO for future implementation
-@pytest.mark.skip(reason="TODO: Add sample data files and implement comprehensive tests")
-class TestPassiveFileParsingTODO:
- """Placeholder for comprehensive passive file parsing tests"""
-
- pass
-
-
-@pytest.mark.skip(reason="TODO: Add sample data files and implement comprehensive tests")
-class TestActiveFileParsingTODO:
- """Placeholder for comprehensive active file parsing tests"""
-
- pass
-
-
-@pytest.mark.skip(reason="TODO: Add sample data files and implement comprehensive tests")
-class TestVNAFileParsingTODO:
- """Placeholder for comprehensive VNA file parsing tests"""
-
- pass
+class TestPassiveFileParsing:
+ """Validate passive file parsing with a minimal fixture."""
+
+ def test_read_passive_file_minimal_fixture(self, tmp_path):
+ file_path = tmp_path / "sample_AP_HPol.txt"
+ file_path.write_text(_build_minimal_passive_file_text(), encoding="utf-8")
+
+ all_data, start_phi, stop_phi, inc_phi, start_theta, stop_theta, inc_theta = (
+ read_passive_file(str(file_path))
+ )
+
+ assert start_phi == 0.0
+ assert stop_phi == 180.0
+ assert inc_phi == 180.0
+ assert start_theta == 0.0
+ assert stop_theta == 90.0
+ assert inc_theta == 90.0
+ assert len(all_data) == 1
+
+ freq_data = all_data[0]
+ assert freq_data["frequency"] == 2450.0
+ assert freq_data["cal_factor"] == 2.5
+ assert freq_data["theta"] == [0.0, 0.0, 90.0, 90.0]
+ assert freq_data["phi"] == [0.0, 180.0, 0.0, 180.0]
+ assert freq_data["mag"] == [1.0, 2.0, 3.0, 4.0]
+ assert freq_data["phase"] == [0.0, 10.0, 20.0, 30.0]
+
+
+class TestActiveFileParsing:
+ """Validate active file parsing with a minimal fixture."""
+
+ def test_read_active_file_minimal_fixture(self, tmp_path):
+ file_path = tmp_path / "sample_TRP.txt"
+ file_path.write_text(_build_minimal_active_file_text(), encoding="utf-8")
+
+ data = read_active_file(str(file_path))
+
+ assert data["Frequency"] == 2450.0
+ assert data["Start Phi"] == 0.0
+ assert data["Stop Phi"] == 360.0
+ assert data["Inc Phi"] == 180.0
+ assert data["Start Theta"] == 0.0
+ assert data["Stop Theta"] == 180.0
+ assert data["Inc Theta"] == 90.0
+ assert data["Calculated TRP(dBm)"] == -3.0
+ np.testing.assert_allclose(data["Theta_Angles_Deg"], np.array([0.0]))
+ np.testing.assert_allclose(data["Phi_Angles_Deg"], np.array([0.0]))
+ # Parsed values include calibration factors from the header lines.
+ np.testing.assert_allclose(data["H_Power_dBm"], np.array([-9.0]))
+ np.testing.assert_allclose(data["V_Power_dBm"], np.array([-18.0]))
+
+
+class TestVNAFileParsing:
+ """Validate CSV VNA parsing with a minimal 2-port fixture."""
+
+ def test_parse_2port_data_minimal_fixture(self, tmp_path):
+ file_path = tmp_path / "sample_vna.csv"
+ file_path.write_text(
+ "\n".join(
+ [
+ "Metadata row 1",
+ "Metadata row 2",
+ "! Stimulus(Hz),S11(SWR),S22(SWR),S11(dB),S22(dB),S21(dB),S21(s)",
+ "1000000000,1.5,1.4,-10.0,-11.0,-3.0,1.2e-9",
+ "2000000000,1.7,1.6,-9.5,-10.2,-2.7,1.1e-9",
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ parsed = parse_2port_data(str(file_path))
+
+ assert list(parsed.columns) == [
+ "! Stimulus(Hz)",
+ "S11(SWR)",
+ "S22(SWR)",
+ "S11(dB)",
+ "S22(dB)",
+ "S21(dB)",
+ "S21(s)",
+ ]
+ assert parsed.shape == (2, 7)
+ np.testing.assert_allclose(parsed["S21(dB)"].to_numpy(), np.array([-3.0, -2.7]))
class TestAllImports:
From ffc2a635e1383ed8e110fb2928a8e36792f95c6a Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Wed, 11 Feb 2026 21:20:46 -0600
Subject: [PATCH 3/6] =?UTF-8?q?Bump=20version:=204.1.4=20=E2=86=92=204.1.5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.bumpversion.cfg | 2 +-
README.md | 2 +-
plot_antenna/__init__.py | 2 +-
pyproject.toml | 2 +-
settings.json | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index a981e6a..1f3558b 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 4.1.4
+current_version = 4.1.5
commit = True
tag = True
tag_name = v{new_version}
diff --git a/README.md b/README.md
index 42c9d4c..bf3edff 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
diff --git a/plot_antenna/__init__.py b/plot_antenna/__init__.py
index 37fb8ac..61ae0b2 100644
--- a/plot_antenna/__init__.py
+++ b/plot_antenna/__init__.py
@@ -16,7 +16,7 @@
- Professional report generation
"""
-__version__ = "4.1.4"
+__version__ = "4.1.5"
__author__ = "Adam"
__license__ = "GPL-3.0"
diff --git a/pyproject.toml b/pyproject.toml
index 5df96fd..0b5a58a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rflect"
-version = "4.1.4"
+version = "4.1.5"
description = "Antenna measurement visualization and analysis tool"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/settings.json b/settings.json
index f53f198..2a642ed 100644
--- a/settings.json
+++ b/settings.json
@@ -1,3 +1,3 @@
{
- "CURRENT_VERSION": "v4.1.4"
+ "CURRENT_VERSION": "v4.1.5"
}
From c96e51d3f40b47f20868bb3b20fcb881f641bff4 Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Wed, 11 Feb 2026 23:04:04 -0600
Subject: [PATCH 4/6] docs: Add v4.1.5 release notes, update stale references
across docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- RELEASE_NOTES.md: Add v4.1.5 section (advanced analysis suite)
- README.md: Update "New in v4.1" section, test badge (450), MCP tools (25)
- AI_STATUS.md: Update version, roadmap with v4.1.5 milestone
- CONTRIBUTING.md: Fix stale test counts (227→450) and coverage (22%→26%)
- .bumpversion.cfg: Add installer.iss to auto-update on version bump
- .gitignore: Add installer_output/
- installer.iss: Update default version to 4.1.5
Co-Authored-By: Claude Opus 4.6
---
.bumpversion.cfg | 4 ++++
.gitignore | 1 +
AI_STATUS.md | 29 +++++++++++++++++++++++------
CONTRIBUTING.md | 8 ++++----
README.md | 15 ++++++++-------
RELEASE_NOTES.md | 33 +++++++++++++++++++++++++++++++++
installer.iss | 2 +-
7 files changed, 74 insertions(+), 18 deletions(-)
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 1f3558b..c9d3447 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -20,3 +20,7 @@ replace = version-{new_version}-blue
[bumpversion:file:plot_antenna/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
+
+[bumpversion:file:installer.iss]
+search = #define RFLECT_VERSION "{current_version}"
+replace = #define RFLECT_VERSION "{new_version}"
diff --git a/.gitignore b/.gitignore
index 875537a..9e5f77e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ htmlcov/
build/
dist/
debug/
+installer_output/
*.egg-info/
.eggs/
diff --git a/AI_STATUS.md b/AI_STATUS.md
index 508f734..2b7e23b 100644
--- a/AI_STATUS.md
+++ b/AI_STATUS.md
@@ -1,7 +1,7 @@
# RFlect AI Features - Status & Roadmap
-**Last Updated**: February 7, 2026
-**Current Version**: v4.0.0
+**Last Updated**: February 11, 2026
+**Current Version**: v4.1.5
**Status**: Beta / Enabled in GUI
---
@@ -158,7 +158,7 @@ If no provider is configured:
## Roadmap
-### v4.0.0 (Current - February 2026)
+### v4.0.0 (February 2026)
- Complete architecture refactoring (mixin-based GUI)
- Multi-provider AI support (OpenAI, Anthropic, Ollama)
- Secure API key management (Fernet encryption, OS keyring, machine-ID binding)
@@ -170,7 +170,26 @@ If no provider is configured:
- turbo colormap, DPI 300 for saved figures
- 227 tests, 22% code coverage
-### v4.1+ (Planned)
+### v4.1.0-4.1.4 (February 2026)
+- Maritime / horizon antenna plots (5 plot types, configurable theta band)
+- Horizon TRP, efficiency, and enhanced statistics
+- Windows installer overhaul (icon, no console, upgrade handling)
+- Non-blocking update checker (background thread)
+
+### v4.1.5 (Current - February 2026)
+- Advanced RF analysis suite with 5 new modules:
+ - Link Budget / Range Estimation (Friis, protocol presets)
+ - Indoor Propagation (ITU-R P.1238, wall penetration P.2040)
+ - Multipath Fading (Rayleigh/Rician CDF, Monte-Carlo)
+ - Enhanced MIMO (capacity curves, combining gain, MEG with XPR)
+ - Wearable/Medical (body-worn patterns, dense device SINR, SAR screening)
+- Smart presets: protocol and environment dropdowns auto-populate parameters
+- Scrollable settings dialogs for advanced analysis configuration
+- Per-job bulk processing failure reporting
+- 450 tests (302 passing), 26% code coverage
+
+### v4.2+ (Planned)
+- AI datasheet extraction (vision-based parameter extraction from PDF/images)
- Sidelobe detection and reporting
- Automated figure insertion in reports
- Complete branding integration
@@ -178,8 +197,6 @@ If no provider is configured:
- Enhanced vision integration for all providers
- Simulation vs measurement comparison
- AI-powered anomaly detection
-- Multi-antenna system analysis
-- MIMO antenna analysis
- Integration with electromagnetic simulation tools
---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5fe6d13..13987f5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -220,7 +220,7 @@ def sample_data():
### Test Coverage Goals
-- **Current**: 227 tests passing, 22% overall coverage
+- **Current**: 450 tests (302 passing, 148 skipped), 26% overall coverage
- **Target Overall**: ≥60% coverage
- **Core modules** (calculations, file_utils): ≥80% coverage
- **GUI modules**: Best effort (GUI testing is harder)
@@ -368,7 +368,7 @@ RFlect/
│ ├── templates/
│ │ └── default.yaml # Report template
│ └── README.md
-├── tests/ # Test suite (227 tests)
+├── tests/ # Test suite (450 tests)
│ ├── conftest.py # Pytest fixtures
│ ├── test_calculations.py
│ ├── test_ai_analysis.py
@@ -393,10 +393,10 @@ RFlect/
### Completed (v4.0.0)
- ~~HPBW and F/B ratio in pattern analysis~~ (implemented and verified with boundary wrapping fix)
-- ~~Test coverage expansion~~ (227 tests achieved, up from 82; 22% overall coverage)
+- ~~Test coverage expansion~~ (450 tests achieved, up from 82; 26% overall coverage)
### High Priority
-- 🔴 Increase test coverage toward 60% target (currently 22% with 227 tests)
+- 🔴 Increase test coverage toward 60% target (currently 26% with 450 tests)
- 🔴 Sidelobe detection and reporting in pattern analysis
- 🔴 Automated figure insertion in DOCX reports
- 🔴 System Fidelity Factor calculation (#31)
diff --git a/README.md b/README.md
index bf3edff..0681983 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
---
@@ -25,10 +25,11 @@ RFlect takes raw antenna measurement data and turns it into publication-ready 2D
## New in v4.1
-- **Maritime/horizon antenna plots** — 5 new plot types for on-water antenna analysis: Mercator heatmap, conical cuts, gain-over-azimuth, horizon statistics table, and 3D pattern with horizon band highlighting. Configurable via settings toggle.
-- **Windows installer overhaul** — Proper app icon on shortcuts, no console window, seamless in-place upgrades, release notes shown after install, and old-version cleanup.
-- **Startup crash fix** — Update checker no longer crashes when network is unavailable or GUI isn't fully initialized.
-- **391 tests** — Up from 346 in v4.0, with 45 new maritime plot tests.
+- **Advanced RF analysis suite** — 5 new analysis modules: Link Budget/Range Estimation (Friis with protocol presets), Indoor Propagation (ITU-R P.1238/P.2040), Multipath Fading (Rayleigh/Rician CDF + Monte-Carlo), Enhanced MIMO (capacity curves, combining gain, MEG), and Wearable/Medical (body-worn patterns, dense device SINR, SAR screening).
+- **Maritime/horizon antenna plots** — 5 plot types for on-water antenna analysis: Mercator heatmap, conical cuts, gain-over-azimuth, horizon statistics table, and 3D pattern with horizon band highlighting.
+- **Smart presets** — Protocol presets (BLE, WiFi, LoRa, Zigbee, LTE, NB-IoT) and environment presets (Office, Hospital, Industrial, etc.) auto-populate analysis parameters.
+- **Non-blocking update checker** — Startup update check runs in a background thread instead of blocking the GUI.
+- **450 tests** — Up from 346 in v4.0, with 55+ new tests for advanced analysis, maritime plots, and batch processing.
## New in v4.0
@@ -137,7 +138,7 @@ See [AI_STATUS.md](AI_STATUS.md) for provider details and supported models.
## MCP Server
-RFlect ships with an [MCP](https://modelcontextprotocol.io/) server — 23 tools that let AI assistants like Claude Code import your measurements, run analysis, generate reports, and perform UWB characterization programmatically. No GUI required.
+RFlect ships with an [MCP](https://modelcontextprotocol.io/) server — 25 tools that let AI assistants like Claude Code import your measurements, run analysis, generate reports, and perform UWB characterization programmatically. No GUI required.
See [rflect-mcp/README.md](rflect-mcp/README.md) for setup and the full tool reference.
@@ -157,7 +158,7 @@ RFlect/
api_keys.py # Secure key storage (keyring + Fernet)
save.py # DOCX report generation
rflect-mcp/ # MCP server for programmatic access
- tests/ # 346 tests (pytest)
+ tests/ # 450 tests (pytest)
```
## Development
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 236aca0..12b42ad 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,38 @@
# RFlect - Release Notes
+## Version 4.1.5 (02/11/2026)
+
+**Feature release — advanced RF analysis suite with 5 new analysis modules.**
+
+### New Features: Advanced Antenna Analysis
+
+- **Link Budget / Range Estimation**: Integrated Friis path-loss range calculator using measured antenna gain. Includes protocol presets (BLE, WiFi, LoRa, Zigbee, LTE, NB-IoT) that auto-populate Tx power and Rx sensitivity. Two-panel plot: waterfall bar chart + range-vs-azimuth polar plot with target range overlay.
+- **Indoor Propagation Analysis**: ITU-R P.1238 indoor path loss model with ITU-R P.2040 wall penetration loss. Environment presets (Office, Residential, Hospital, etc.) auto-set path loss exponent, shadow fading, and fading model. Three-panel plot: path loss vs distance, received power heatmap, and coverage range contour.
+- **Multipath Fading Assessment**: Rayleigh and Rician fading CDF models with Monte-Carlo pattern degradation. Four-panel plot: CDF curves, fade margin vs reliability, pattern with fading envelope (mean +/- 1 sigma), and outage probability polar map.
+- **Enhanced MIMO Analysis**: Capacity-vs-SNR curves (SISO, 2x2 AWGN, Rayleigh, Rician), MRC/EGC/Selection combining gain comparison, and Mean Effective Gain with cross-polarization ratio (Taga model). Builds on existing ECC/diversity infrastructure.
+- **Wearable / Medical Device Assessment**: Body-worn pattern analysis across configurable positions (wrist, chest, hip, head) using directional human shadow model. Dense device SINR estimation via Monte-Carlo random placement. SAR exposure screening with FCC/ICNIRP limits. IEEE 802.15.6 WBAN link budget.
+
+### New Features: GUI & Settings
+
+- **Scrollable settings dialogs**: Active and passive settings windows now scroll to accommodate the new analysis sections
+- **Smart presets**: Protocol dropdown auto-fills Tx power, Rx sensitivity; environment dropdown auto-fills path loss exponent, shadow fading, wall count, and fading model
+- **Cross-feature parameter sharing**: Shared parameters (Tx power, fading model, etc.) stay consistent across analysis modules
+
+### Improvements
+
+- **Bulk processing failure reporting**: Per-job/per-file outcome tracking replaces blanket success-on-partial-failure messages
+- **Non-blocking update checker**: Startup update check runs in a background thread — no more GUI freezes on slow networks
+- **Matplotlib deprecation cleanup**: Replaced deprecated `cm.get_cmap` calls for forward compatibility
+
+### Tests
+
+- 450 tests collected (302 passing, 148 skipped), up from 391 in v4.1.0
+- 45 new unit tests for advanced analysis functions (Friis, ITU, CDF, MIMO, wearable)
+- 10 new batch failure regression integration tests
+- Fixture-based parser tests replacing placeholder TODOs
+
+---
+
## Version 4.1.4 (02/11/2026)
**Feature release — horizon band TRP, efficiency calculations, and enhanced maritime statistics.**
diff --git a/installer.iss b/installer.iss
index bed85aa..551aa86 100644
--- a/installer.iss
+++ b/installer.iss
@@ -7,7 +7,7 @@
#define MyAppName "RFlect"
#ifndef RFLECT_VERSION
- #define RFLECT_VERSION "4.0.0"
+ #define RFLECT_VERSION "4.1.5"
#endif
#define MyAppVersion RFLECT_VERSION
#define MyAppPublisher "RFingAdam"
From a3c1af7b200f2cba3cf0749c5a6c599c5d7cbccd Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Tue, 24 Feb 2026 11:19:02 -0600
Subject: [PATCH 5/6] fix: Professional 3D antenna pattern plots with DUT
orientation markers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactored all three 3D plot routines (active, passive, masked) to share a
common _setup_3d_axes() helper that provides:
- Equal aspect ratio via set_box_aspect([1,1,1])
- Symmetric limits centred on the origin
- Box-edge axis labels (X/Y/Z) rendered in the 2D overlay layer so they
are never occluded by the surface
- Short coloured orientation arrows (X=green, Y=red, Z=blue) on the
negative side of each axis, matching the physical DUT marker used in the
anechoic chamber
- Tighter figure layout (10×8, subplots_adjust) with suptitle and colorbar
max-value annotation
Also vectorised free_space_path_loss() so it accepts arrays for distance,
fixing the indoor propagation heatmap calculation.
Co-Authored-By: Claude Opus 4.6
---
plot_antenna/calculations.py | 11 +-
plot_antenna/plotting.py | 505 +++++++++++++----------------------
2 files changed, 190 insertions(+), 326 deletions(-)
diff --git a/plot_antenna/calculations.py b/plot_antenna/calculations.py
index 0d0a06e..1a20512 100644
--- a/plot_antenna/calculations.py
+++ b/plot_antenna/calculations.py
@@ -932,15 +932,16 @@ def free_space_path_loss(freq_mhz, distance_m):
Parameters:
freq_mhz: frequency in MHz
- distance_m: distance in metres (must be > 0)
+ distance_m: distance in metres (scalar or array, must be > 0)
Returns:
- Path loss in dB (positive value).
+ Path loss in dB (positive value, scalar or array matching distance_m).
"""
- if distance_m <= 0:
- return 0.0
+ distance_m = np.asarray(distance_m, dtype=float)
+ d_safe = np.maximum(distance_m, 1e-6)
freq_hz = freq_mhz * 1e6
- return 20 * np.log10(distance_m) + 20 * np.log10(freq_hz) - 147.55
+ result = 20 * np.log10(d_safe) + 20 * np.log10(freq_hz) - 147.55
+ return float(result) if result.ndim == 0 else result
def friis_range_estimate(pt_dbm, pr_dbm, gt_dbi, gr_dbi, freq_mhz,
diff --git a/plot_antenna/plotting.py b/plot_antenna/plotting.py
index bf99e4f..be819bf 100644
--- a/plot_antenna/plotting.py
+++ b/plot_antenna/plotting.py
@@ -351,6 +351,101 @@ def plot_polar_power_pattern(
plt.show()
+def _setup_3d_axes(ax, X, Y, Z):
+ """Configure a 3D Axes for antenna pattern display.
+
+ Professional-style 3-D antenna gain plot with DUT orientation:
+
+ * **Equal aspect ratio** via ``set_box_aspect([1,1,1])`` — no
+ axis stretching.
+ * **Symmetric limits** centred on the origin so the pattern sits
+ in the middle of the bounding box.
+ * **Box-edge axis labels** via ``set_xlabel / ylabel / zlabel``
+ — rendered in the 2D overlay layer, never occluded.
+ * **Short origin arrows** that mirror the physical orientation
+ marker used in the anechoic chamber (X green, Y red, Z blue).
+ They extend from the *negative* side of each axis toward the
+ origin so they sit in the empty space behind the pattern and
+ remain visible regardless of view angle.
+
+ Parameters
+ ----------
+ ax : mpl_toolkits.mplot3d.axes3d.Axes3D
+ The 3-D axes to decorate.
+ X, Y, Z : ndarray
+ Cartesian coordinate arrays of the plotted surface.
+ """
+ # ---- Data extent ----
+ max_ext = max(
+ np.nanmax(np.abs(X)),
+ np.nanmax(np.abs(Y)),
+ np.nanmax(np.abs(Z)),
+ )
+
+ # ---- Symmetric limits — centres the pattern in the box ----
+ lim = 1.02 * max_ext
+ ax.set_xlim(-lim, lim)
+ ax.set_ylim(-lim, lim)
+ ax.set_zlim(-lim, lim)
+ ax.set_box_aspect([1, 1, 1])
+
+ # ---- Transparent panes ----
+ ax.xaxis.pane.fill = False # type: ignore
+ ax.yaxis.pane.fill = False # type: ignore
+ ax.zaxis.pane.fill = False # type: ignore
+ ax.xaxis.pane.set_edgecolor("gray") # type: ignore
+ ax.yaxis.pane.set_edgecolor("gray") # type: ignore
+ ax.zaxis.pane.set_edgecolor("gray") # type: ignore
+ ax.xaxis.pane.set_alpha(0.2) # type: ignore
+ ax.yaxis.pane.set_alpha(0.2) # type: ignore
+ ax.zaxis.pane.set_alpha(0.2) # type: ignore
+
+ # ---- Grid + hide tick labels ----
+ ax.grid(True)
+ ax.set_xticklabels([])
+ ax.set_yticklabels([])
+ ax.set_zticklabels([]) # type: ignore
+
+ # ---- Box-edge axis labels (2D overlay — never occluded) ----
+ _label_kw = dict(fontsize=14, fontweight="bold", labelpad=2)
+ ax.set_xlabel("X", color="green", **_label_kw)
+ ax.set_ylabel("Y", color="red", **_label_kw)
+ ax.set_zlabel("Z", color="blue", **_label_kw) # type: ignore
+
+ # ---- DUT orientation arrows ----
+ # Short arrows from -lim toward the origin, matching the
+ # physical X/Y/Z marker placed on the DUT in the chamber.
+ # Placed on the negative side so they sit in clear air behind
+ # the pattern (antenna patterns have positive radii only).
+ arrow_len = 0.35 * lim # short — just an orientation hint
+ arrow_start = -lim # start at the box edge
+ arrow_end = arrow_start + arrow_len # end partway toward origin
+
+ _arrow_spec = [
+ # (dx, dy, dz, colour)
+ (1, 0, 0, "green"), # X
+ (0, 1, 0, "red"), # Y
+ (0, 0, 1, "blue"), # Z
+ ]
+ for dx, dy, dz, colour in _arrow_spec:
+ # Shaft from -lim toward origin
+ ax.plot(
+ [dx * arrow_start, dx * arrow_end],
+ [dy * arrow_start, dy * arrow_end],
+ [dz * arrow_start, dz * arrow_end],
+ color=colour, linewidth=3.0, alpha=0.8, solid_capstyle="round",
+ )
+ # Arrow head pointing toward +direction
+ ah = 0.25 * arrow_len
+ ax.quiver(
+ dx * (arrow_end - ah),
+ dy * (arrow_end - ah),
+ dz * (arrow_end - ah),
+ dx * ah, dy * ah, dz * ah,
+ color=colour, arrow_length_ratio=0.6, linewidth=2.5,
+ )
+
+
def plot_active_3d_data(
theta_angles_deg,
phi_angles_deg,
@@ -489,8 +584,10 @@ def plot_active_3d_data(
# Now plot (use data_interp which is defined in both branches)
plt.style.use("default")
- fig = plt.figure(figsize=(12, 8))
+ fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(1, 1, 1, projection="3d")
+ # Expand the 3D axes to fill more of the figure
+ fig.subplots_adjust(left=0.0, right=0.88, bottom=0.0, top=0.92)
# Color‐mapping alone obeys manual limits
if axis_mode == "manual":
@@ -515,161 +612,8 @@ def plot_active_3d_data(
# Set the view angle
ax.view_init(elev=20, azim=-30)
- # Make panes transparent so axes show through
- ax.xaxis.pane.fill = False # type: ignore
- ax.yaxis.pane.fill = False # type: ignore
- ax.zaxis.pane.fill = False # type: ignore
- ax.xaxis.pane.set_edgecolor("gray") # type: ignore
- ax.yaxis.pane.set_edgecolor("gray") # type: ignore
- ax.zaxis.pane.set_edgecolor("gray") # type: ignore
- ax.xaxis.pane.set_alpha(0.2) # type: ignore
- ax.yaxis.pane.set_alpha(0.2) # type: ignore
- ax.zaxis.pane.set_alpha(0.2) # type: ignore
-
- # Remove axis tick labels but retain grid
- ax.grid(True)
-
- # Get current ticks and create custom labels (fix matplotlib warning by using set_ticks first)
- from matplotlib.ticker import FixedLocator
-
- xticks = ax.get_xticks()
- yticks = ax.get_yticks()
- zticks = ax.get_zticks() # type: ignore
-
- # Set ticks with FixedLocator before setting labels (fixes matplotlib warning)
- ax.xaxis.set_major_locator(FixedLocator(xticks)) # type: ignore[arg-type]
- ax.yaxis.set_major_locator(FixedLocator(yticks)) # type: ignore[arg-type]
- ax.zaxis.set_major_locator(FixedLocator(zticks)) # type: ignore[attr-defined, arg-type]
-
- # Now set labels - hide labels near origin for cleaner look
- ax.set_xticklabels(["" if -1.2 < val < 1.2 else f"{val:.1f}" for val in xticks])
- ax.set_yticklabels(["" if -1.2 < val < 1.2 else f"{val:.1f}" for val in yticks])
- ax.set_zticklabels(["" if -1.2 < val < 1.2 else f"{val:.1f}" for val in zticks]) # type: ignore
- """
- # Add quiver arrows (axes)
- # Extract indices corresponding to X, Y, Z directions
- idx_theta_90 = np.argmin(np.abs(theta_interp - 90))
- idx_theta_0 = np.argmin(np.abs(theta_interp - 0))
- idx_phi_0 = np.argmin(np.abs(phi_interp - 0))
- idx_phi_90 = np.argmin(np.abs(phi_interp - 90))
-
- # Get starting points for quivers
- start_x = X[idx_theta_90, idx_phi_0]
- start_y = Y[idx_theta_90, idx_phi_90]
- start_z = Z[idx_theta_0, 0] # phi index can be 0 since theta is 0
-
- # Ensure starting points are not too close to zero
- min_offset = 0.05
- if np.abs(start_z) < min_offset:
- start_z = min_offset
-
- # Calculate the distances from each intersection point to the origin
- dist_x = np.abs(start_x)
- dist_y = np.abs(start_y)
- dist_z = np.abs(start_z)
-
- # Compute quiver lengths such that they don't exceed plot area
- quiver_length = 0.25 * max(dist_x, dist_y, dist_z) # Base length for X and Y axes
- min_quiver_length = 0.1 # Minimum length to ensure visibility
- quiver_length = max(quiver_length, min_quiver_length)
-
- # Make Z-axis quiver longer to enhance visibility
- quiver_length_z = quiver_length * 3.2 # Adjust the factor as needed
-
- # Plot adjusted quiver arrows
- ax.quiver(start_x, 0, 0, quiver_length*2.2, 0, 0, color='green', arrow_length_ratio=0.1, zorder=20) # X-axis
- ax.quiver(0, start_y, 0, 0, quiver_length*2.4, 0, color='red', arrow_length_ratio=0.1, zorder=20) # Y-axis
- ax.quiver(0, 0, start_z, 0, 0, quiver_length_z, color='blue', arrow_length_ratio=0.1, zorder=20) # Z-axis
- """
- # 1) Figure out how large data extends in X, Y, Z so we know how big to draw axes
- max_dim = max(np.nanmax(np.abs(X)), np.nanmax(np.abs(Y)), np.nanmax(np.abs(Z)))
- # Make axis arrows extend beyond the data for visibility
- axis_length = 1.3 * max_dim # 30% bigger than data
-
- # 2) Draw coordinate axis arrows using plot3D lines + cone heads for better 3D visibility
- # Note: zorder doesn't work reliably in 3D, so we use thick lines that extend beyond the pattern
- # and add text labels at the ends for clarity
-
- # Arrow head length (fraction of axis length)
- arrow_head = 0.1 * axis_length
-
- # X-axis (green) - extends in positive X direction
- ax.plot(
- [0, axis_length], [0, 0], [0, 0], color="green", linewidth=2.5, linestyle="-", alpha=0.9
- )
- ax.quiver(
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- 0,
- 0,
- color="green",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- _label_fx = [pe.withStroke(linewidth=3, foreground="white")]
- ax.text(
- axis_length * 1.08,
- 0,
- 0,
- "X",
- color="green",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
-
- # Y-axis (red) - extends in positive Y direction
- ax.plot([0, 0], [0, axis_length], [0, 0], color="red", linewidth=2.5, linestyle="-", alpha=0.9)
- ax.quiver(
- 0,
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- 0,
- color="red",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- ax.text(
- 0,
- axis_length * 1.08,
- 0,
- "Y",
- color="red",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
-
- # Z-axis (blue) - extends in positive Z direction
- ax.plot([0, 0], [0, 0], [0, axis_length], color="blue", linewidth=2.5, linestyle="-", alpha=0.9)
- ax.quiver(
- 0,
- 0,
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- color="blue",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- ax.text(
- 0,
- 0,
- axis_length * 1.08,
- "Z",
- color="blue",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
+ # Configure axes: equal aspect, panes, grid, arrows
+ _setup_3d_axes(ax, X, Y, Z)
# Set Title based on power_type with rounded TRP values
if power_type == "total":
@@ -684,17 +628,15 @@ def plot_active_3d_data(
plot_title = (
f"3D Radiation Pattern - {power_type} at {frequency} MHz, TRP = {TRP_dBm:.2f} dBm"
)
- ax.set_title(plot_title, fontsize=16)
+ fig.suptitle(plot_title, fontsize=14, y=0.97)
# Add a colorbar
- cbar = fig.colorbar(mappable, ax=ax, pad=0.1, shrink=0.75)
- cbar.set_label("Power (dBm)", rotation=270, labelpad=20, fontsize=14)
- cbar.ax.tick_params(labelsize=12)
+ cbar = fig.colorbar(mappable, ax=ax, pad=0.08, shrink=0.65)
+ cbar.set_label("Power (dBm)", rotation=270, labelpad=20, fontsize=12)
+ cbar.ax.tick_params(labelsize=10)
- # Add Max Power to top of Legend
- ax.text2D(
- 1.12, 0.90, f"{max_eirp_dBm:.2f} dBm", transform=ax.transAxes, fontsize=12, weight="bold"
- )
+ # Add Max Power to top of colorbar
+ cbar.ax.set_title(f"{max_eirp_dBm:.2f} dBm", fontsize=10, weight="bold", pad=4)
# If save path provided, save the plot
if save_path:
@@ -1391,18 +1333,11 @@ def plot_passive_3d_component(
# Plotting
plt.style.use("default")
- fig = plt.figure(figsize=(12, 8))
+ fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(1, 1, 1, projection="3d")
-
- # Remove axis tick labels but retain grid
- ax.grid(True)
- ax.set_xticklabels([]) # Empty labels are fine without FixedLocator
- ax.set_yticklabels([])
- ax.set_zticklabels([]) # type: ignore
+ fig.subplots_adjust(left=0.0, right=0.88, bottom=0.0, top=0.92)
# Color-scale respects Manual or Auto limits
- # Colorbar uses manual limits if selected, otherwise auto
- # Color‐mapping alone obeys your manual z-limits
if axis_mode == "manual":
norm = Normalize(zmin, zmax)
else:
@@ -1420,110 +1355,10 @@ def plot_passive_3d_component(
zorder=1,
)
- # Make panes transparent so axes show through
- ax.xaxis.pane.fill = False # type: ignore
- ax.yaxis.pane.fill = False # type: ignore
- ax.zaxis.pane.fill = False # type: ignore
- ax.xaxis.pane.set_edgecolor("gray") # type: ignore
- ax.yaxis.pane.set_edgecolor("gray") # type: ignore
- ax.zaxis.pane.set_edgecolor("gray") # type: ignore
- ax.xaxis.pane.set_alpha(0.2) # type: ignore
- ax.yaxis.pane.set_alpha(0.2) # type: ignore
- ax.zaxis.pane.set_alpha(0.2) # type: ignore
-
- # Calculate axis length based on data extent
- max_dim = max(np.nanmax(np.abs(X)), np.nanmax(np.abs(Y)), np.nanmax(np.abs(Z)))
- # Make axis arrows extend beyond the data for visibility
- axis_length = 1.3 * max_dim # 30% bigger than data
-
- # Draw coordinate axis arrows using plot3D lines + markers for better 3D visibility
- # Note: zorder doesn't work reliably in 3D, so we use thick lines that extend beyond the pattern
- # and add text labels at the ends for clarity
-
- # Arrow head length (fraction of axis length)
- arrow_head = 0.1 * axis_length
-
- # X-axis (green) - extends in positive X direction
- ax.plot(
- [0, axis_length], [0, 0], [0, 0], color="green", linewidth=2.5, linestyle="-", alpha=0.9
- )
- ax.quiver(
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- 0,
- 0,
- color="green",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- _label_fx = [pe.withStroke(linewidth=3, foreground="white")]
- ax.text(
- axis_length * 1.08,
- 0,
- 0,
- "X",
- color="green",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
-
- # Y-axis (red) - extends in positive Y direction
- ax.plot([0, 0], [0, axis_length], [0, 0], color="red", linewidth=2.5, linestyle="-", alpha=0.9)
- ax.quiver(
- 0,
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- 0,
- color="red",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- ax.text(
- 0,
- axis_length * 1.08,
- 0,
- "Y",
- color="red",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
-
- # Z-axis (blue) - extends in positive Z direction
- ax.plot([0, 0], [0, 0], [0, axis_length], color="blue", linewidth=2.5, linestyle="-", alpha=0.9)
- ax.quiver(
- 0,
- 0,
- axis_length - arrow_head,
- 0,
- 0,
- arrow_head,
- color="blue",
- arrow_length_ratio=0.5,
- linewidth=2,
- )
- ax.text(
- 0,
- 0,
- axis_length * 1.08,
- "Z",
- color="blue",
- fontsize=14,
- fontweight="bold",
- ha="center",
- path_effects=_label_fx,
- )
+ ax.view_init(elev=20, azim=-30)
- # Adjust the view angle for a top-down view
- # ax.view_init(elev=10, azim=-25)
- ax.view_init(elev=20, azim=-30) # Tweaking the view angle for a better perspective
+ # Configure axes: equal aspect, panes, grid, arrows
+ _setup_3d_axes(ax, X, Y, Z)
# Set Title
ax.set_title(plot_title, fontsize=16)
@@ -2681,8 +2516,9 @@ def plot_3d_pattern_masked(
gray = np.array([0.82, 0.82, 0.82, 1.0])
face_colors[~in_band] = mask_alpha * face_colors[~in_band] + (1 - mask_alpha) * gray
- fig = plt.figure(figsize=(12, 8))
+ fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection="3d")
+ fig.subplots_adjust(left=0.0, right=0.88, bottom=0.0, top=0.92)
ax.plot_surface(
X,
@@ -2708,20 +2544,10 @@ def plot_3d_pattern_masked(
ring_z = r_ring * np.cos(t_rad) * np.ones_like(ring_phi)
ax.plot(ring_x, ring_y, ring_z, color="yellow", linewidth=2, alpha=0.9)
- ax.set_xticklabels([])
- ax.set_yticklabels([])
- ax.set_zticklabels([])
- ax.grid(True)
-
- # Transparent panes
- ax.xaxis.pane.fill = False
- ax.yaxis.pane.fill = False
- ax.zaxis.pane.fill = False
- ax.xaxis.pane.set_alpha(0.2)
- ax.yaxis.pane.set_alpha(0.2)
- ax.zaxis.pane.set_alpha(0.2)
-
ax.view_init(elev=20, azim=-30)
+
+ # Configure axes: equal aspect, panes, grid, arrows
+ _setup_3d_axes(ax, X, Y, Z)
ax.set_title(
f"3D {data_label} Pattern — Horizon Band {theta_highlight_min}–{theta_highlight_max}° "
f"@ {frequency} MHz",
@@ -2969,25 +2795,28 @@ def plot_link_budget_summary(
ax_table = fig.add_subplot(fig_gs[0])
ax_table.axis("off")
- gt_label = "Peak EIRP" if is_active else "Tx Gain (Gt)"
+ gt_label = "Peak Horizon EIRP (θ=90°)" if is_active else "Peak Horizon Gain (θ=90°)"
gt_value = f"{peak_gain:.1f} dBm" if is_active else f"{peak_gain:.1f} dBi"
+ min_gt_label = "Min EIRP for target" if is_active else "Min Gt for target range"
+ min_gt_unit = "dBm" if is_active else "dBi"
+
table_data = []
if not is_active:
table_data.append(["Tx Power (Pt)", f"{pt_dbm:.1f} dBm"])
table_data.extend([
[gt_label, gt_value],
- [f"Path Loss @ {target_range_m:.1f}m", f"-{pl_target:.1f} dB"],
- ["Misc Losses", f"-{misc_loss_db:.1f} dB"],
+ [f"Path Loss @ {target_range_m:.1f}m", f"{-abs(pl_target):.1f} dB"],
+ ["Misc Losses", f"{-abs(misc_loss_db):.1f} dB"],
["Rx Gain (Gr)", f"{gr_dbi:.1f} dBi"],
["Rx Sensitivity", f"{pr_dbm:.1f} dBm"],
["", ""],
["Link Margin @ target", f"{margin:+.1f} dB"],
["Peak Range", f"{peak_range:.1f} m"],
["Worst-Case Range", f"{worst_range:.1f} m"],
- ["Min Gt for target range", f"{min_gt:.1f} dBi"],
- ["Frequency", f"{freq_mhz} MHz"],
- ["Path Loss Exponent (n)", f"{path_loss_exp}"],
+ [min_gt_label, f"{min_gt:.1f} {min_gt_unit}"],
+ ["Frequency", f"{freq_mhz:.1f} MHz"],
+ ["Path Loss Exponent (n)", f"{path_loss_exp:.1f}"],
])
table = ax_table.table(
@@ -3005,13 +2834,16 @@ def plot_link_budget_summary(
table[0, j].set_facecolor("#4A90E2")
table[0, j].set_text_props(color="white", fontweight="bold")
- # Highlight margin row (after the blank separator row)
- margin_row_idx = len(table_data) - 5 # "Link Margin" row
- if margin_row_idx > 0:
+ # Highlight margin row — find explicitly by content rather than fragile offset
+ margin_row_data_idx = next(
+ (i for i, row in enumerate(table_data) if "Link Margin" in row[0]), None
+ )
+ if margin_row_data_idx is not None:
+ margin_row_tbl = margin_row_data_idx + 1 # +1 for header row in table
color = "#4CAF50" if margin >= 0 else "#F44336"
for j in range(2):
- table[margin_row_idx, j].set_facecolor(color)
- table[margin_row_idx, j].set_text_props(color="white", fontweight="bold")
+ table[margin_row_tbl, j].set_facecolor(color)
+ table[margin_row_tbl, j].set_text_props(color="white", fontweight="bold")
ax_table.set_title(
f"Link Budget Summary — {freq_mhz} MHz",
@@ -3028,9 +2860,10 @@ def plot_link_budget_summary(
ax_polar.bar(phi_rad, range_m, width=bar_width,
color=colors, alpha=0.7, edgecolor="gray", linewidth=0.3)
- # Target range ring
- target_ring = np.full_like(phi_rad, target_range_m)
- ax_polar.plot(phi_rad, target_ring, "k--", linewidth=1.5,
+ # Target range ring — close the loop so the dashed circle is complete
+ phi_rad_closed = np.append(phi_rad, phi_rad[0])
+ target_ring = np.full_like(phi_rad_closed, target_range_m)
+ ax_polar.plot(phi_rad_closed, target_ring, "k--", linewidth=1.5,
label=f"Target: {target_range_m:.0f} m")
ax_polar.set_title(
@@ -3039,6 +2872,7 @@ def plot_link_budget_summary(
)
ax_polar.set_theta_zero_location("N")
ax_polar.set_theta_direction(-1)
+ ax_polar.set_rlabel_position(67.5) # move radial labels clear of data
ax_polar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=9)
plt.tight_layout()
@@ -3078,7 +2912,7 @@ def plot_indoor_coverage_map(
distances = np.linspace(0.5, max_distance_m, 60)
# Path loss models
- pl_free = np.array([free_space_path_loss(freq_mhz, d) for d in distances])
+ pl_free = free_space_path_loss(freq_mhz, distances)
pl_indoor = log_distance_path_loss(freq_mhz, distances, n=path_loss_exp)
wl = wall_penetration_loss(freq_mhz, wall_material)
pl_walls = pl_indoor + n_walls * wl
@@ -3088,16 +2922,18 @@ def plot_indoor_coverage_map(
theta_90_idx = np.argmin(np.abs(theta_deg - 90.0))
horizon_gain = gain_2d[theta_90_idx, :]
- # Received power heatmap: Pr(phi, d) = Pt + G(phi) - PL(d)
+ # Received power heatmap: Pr(phi, d) = Pt + G(phi) - PL(d) - shadow_margin
effective_pt = 0.0 if is_active else pt_dbm
- pr_map = effective_pt + horizon_gain[np.newaxis, :] - pl_walls[:, np.newaxis]
+ pl_total = pl_walls + shadow_fading_db # include shadow fading margin
+ pr_map = effective_pt + horizon_gain[np.newaxis, :] - pl_total[:, np.newaxis]
- # Coverage range per azimuth
+ # Coverage range per azimuth (with shadow fading margin applied)
coverage_range = np.zeros(len(phi_deg))
fspl_1m = free_space_path_loss(freq_mhz, 1.0)
+ total_wall_loss = n_walls * wl + shadow_fading_db
for i, g in enumerate(horizon_gain):
allowed_pl = effective_pt + g - pr_sensitivity_dbm
- net_pl = allowed_pl - n_walls * wl
+ net_pl = allowed_pl - total_wall_loss
if path_loss_exp > 0 and net_pl > fspl_1m:
coverage_range[i] = 10 ** ((net_pl - fspl_1m) / (10 * path_loss_exp))
else:
@@ -3113,7 +2949,7 @@ def plot_indoor_coverage_map(
ax_pl.plot(distances, pl_indoor, "g-", linewidth=1.5,
label=f"Indoor (n={path_loss_exp})")
ax_pl.plot(distances, pl_walls, "r-", linewidth=2,
- label=f"+ {n_walls}× {wall_material} ({wl:.1f} dB)")
+ label=f"+ {n_walls}× {wall_material} ({n_walls * wl:.1f} dB)")
ax_pl.plot(distances, pl_shadow, "r:", linewidth=1,
label=f"+ {shadow_fading_db:.0f} dB shadow margin")
ax_pl.set_xlabel("Distance (m)")
@@ -3125,7 +2961,9 @@ def plot_indoor_coverage_map(
# --- Center: Received Power Heatmap ---
ax_hm = fig.add_subplot(fig_gs[1])
- extent = [phi_deg[0], phi_deg[-1], distances[0], distances[-1]]
+ dphi = phi_deg[1] - phi_deg[0] if len(phi_deg) > 1 else 15.0
+ extent = [phi_deg[0] - dphi / 2, phi_deg[-1] + dphi / 2,
+ distances[0], distances[-1]]
vmin = pr_sensitivity_dbm - 20
vmax = float(np.max(pr_map))
im = ax_hm.imshow(
@@ -3147,11 +2985,13 @@ def plot_indoor_coverage_map(
# --- Right: Coverage Range Polar ---
ax_polar = fig.add_subplot(fig_gs[2], projection="polar")
- phi_rad = np.deg2rad(phi_deg)
- ax_polar.fill(phi_rad, coverage_range, alpha=0.3, color="#4CAF50")
- ax_polar.plot(phi_rad, coverage_range, "g-", linewidth=2, label="Coverage range")
+ phi_rad_closed = np.append(np.deg2rad(phi_deg), np.deg2rad(phi_deg[0]))
+ cr_closed = np.append(coverage_range, coverage_range[0])
+ ax_polar.fill(phi_rad_closed, cr_closed, alpha=0.3, color="#4CAF50")
+ ax_polar.plot(phi_rad_closed, cr_closed, "g-", linewidth=2, label="Coverage range")
ax_polar.set_title(
- f"Coverage Range @ {pr_sensitivity_dbm} dBm\n{n_walls}× {wall_material}",
+ f"Coverage Range @ {pr_sensitivity_dbm:.0f} dBm\n"
+ f"{n_walls}× {wall_material} + {shadow_fading_db:.0f} dB shadow margin",
fontsize=11, fontweight="bold", pad=15,
)
ax_polar.set_theta_zero_location("N")
@@ -3188,6 +3028,10 @@ def plot_fading_analysis(
fading_model="rayleigh",
fading_rician_k=10.0,
realizations=1000,
+ target_distance_m=5.0,
+ path_loss_exp=2.0,
+ misc_loss_db=0.0,
+ gr_dbi=0.0,
data_label="Gain",
data_unit="dBi",
save_path=None,
@@ -3195,6 +3039,9 @@ def plot_fading_analysis(
"""
Fading analysis: CDF curves, fade margin chart, pattern with fading envelope,
and outage probability bar chart.
+
+ The outage subplot computes received power at ``target_distance_m`` using
+ a log-distance path loss model so results are physically meaningful.
"""
is_active = data_label != "Gain"
model = str(fading_model).strip().lower()
@@ -3307,7 +3154,7 @@ def plot_fading_analysis(
mean_flat + std_flat,
alpha=0.2,
color="blue",
- label="+-1 sigma envelope",
+ label=r"$\pm 1\sigma$ envelope",
)
ax.plot(phi_deg, mean_flat, "b-", linewidth=2, label="Mean (faded)")
ax.plot(phi_deg, gain_2d[theta_90_idx, :], "k--", linewidth=1, label="Free-space")
@@ -3326,18 +3173,24 @@ def plot_fading_analysis(
ax = axes[1, 1]
horizon_vals = gain_2d[theta_90_idx, :]
effective_pt = 0.0 if is_active else pt_dbm
+ # Compute mean received power including path loss at target distance
+ fspl_d0 = free_space_path_loss(freq_mhz, 1.0)
+ pl_at_target = fspl_d0 + 10.0 * path_loss_exp * np.log10(
+ max(target_distance_m, 0.01)
+ )
+ mean_rx = effective_pt + horizon_vals + gr_dbi - pl_at_target - misc_loss_db
rx_threshold = np.full_like(horizon_vals, pr_sensitivity_dbm)
- mean_rx = effective_pt + horizon_vals
if model == "rician":
outage_prob = rician_cdf(rx_threshold, mean_rx, K_factor=max(k_factor, 0.1))
outage_title = f"Rician Outage per Azimuth (K={max(k_factor, 0.1):.1f})"
else:
outage_prob = rayleigh_cdf(rx_threshold, mean_rx)
outage_title = "Rayleigh Outage per Azimuth"
+ target_outage_pct = 100.0 - target_reliability
bar_width = np.mean(np.diff(phi_deg)) * 0.8 if len(phi_deg) > 1 else 3.0
colors_out = []
for op in outage_prob:
- if op < 0.01:
+ if op < target_outage_pct / 100.0:
colors_out.append("#4CAF50")
elif op < 0.1:
colors_out.append("#FFC107")
@@ -3351,15 +3204,21 @@ def plot_fading_analysis(
edgecolor="gray",
linewidth=0.3,
)
- ax.axhline(y=1.0, color="r", linestyle="--", linewidth=1, label="1% outage")
+ ax.axhline(
+ y=target_outage_pct, color="r", linestyle="--", linewidth=1,
+ label=f"{target_outage_pct:.0g}% outage",
+ )
ax.set_xlabel("Azimuth phi (deg)")
ax.set_ylabel("Outage Probability (%)")
- ax.set_title(f"{outage_title}\n(at Rx Sensitivity)", fontweight="bold")
+ ax.set_title(
+ f"{outage_title}\n(d={target_distance_m:.0f} m, Rx={pr_sensitivity_dbm:.0f} dBm)",
+ fontweight="bold",
+ )
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.suptitle(f"Multipath Fading Analysis - {freq_mhz} MHz", fontsize=14, fontweight="bold")
- plt.tight_layout()
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
if save_path:
fname = f"fading_analysis_{freq_mhz}MHz.png"
@@ -3746,6 +3605,10 @@ def generate_advanced_analysis_plots(
fading_model=fading_model,
fading_rician_k=fading_rician_k,
realizations=fading_realizations,
+ target_distance_m=lb_target_range_m,
+ path_loss_exp=lb_path_loss_exp,
+ misc_loss_db=lb_misc_loss_db,
+ gr_dbi=lb_gr_dbi,
data_label=data_label,
data_unit=data_unit,
save_path=save_path,
From 97a95d96b431e8947833a5a92d86ac386466f60b Mon Sep 17 00:00:00 2001
From: RFingAdam
Date: Tue, 24 Feb 2026 11:23:06 -0600
Subject: [PATCH 6/6] docs: Update v4.1.5 release notes with 3D plot and bug
fix details
Co-Authored-By: Claude Opus 4.6
---
RELEASE_NOTES.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 12b42ad..e8db644 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -20,10 +20,20 @@
### Improvements
+- **Professional 3D antenna pattern plots**: Shared `_setup_3d_axes()` helper across all 3D routines (active TRP, passive gain, masked horizon) with equal aspect ratio, symmetric limits, and transparent grid panes
+- **DUT orientation markers**: Short coloured arrows (X=green, Y=red, Z=blue) on the negative axis side match the physical orientation marker used in the anechoic chamber, enabling correlation between 3D plots and measured antenna position
+- **3D plot layout**: Tighter figure layout with suptitle, improved colorbar positioning, and max EIRP/gain annotation on the colorbar
- **Bulk processing failure reporting**: Per-job/per-file outcome tracking replaces blanket success-on-partial-failure messages
- **Non-blocking update checker**: Startup update check runs in a background thread — no more GUI freezes on slow networks
- **Matplotlib deprecation cleanup**: Replaced deprecated `cm.get_cmap` calls for forward compatibility
+### Bug Fixes
+
+- **Indoor propagation heatmap**: Vectorised `free_space_path_loss()` to accept distance arrays, fixing the indoor propagation heatmap calculation that previously failed on array inputs
+- **Multipath fading outage plot**: Outage probability calculation now correctly includes path loss; previously the fading-only CDF produced unrealistic outage values
+- **Shadow fading heatmap**: Shadow fading sigma is now applied to the indoor propagation heatmap colourmap; previously the shadow fading parameter was accepted but had no visible effect
+- **Link budget margin row**: Fixed hardcoded row index for the margin annotation in the link budget waterfall chart
+
### Tests
- 450 tests collected (302 passing, 148 skipped), up from 391 in v4.1.0