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 @@

- Version + Version Python License Tests 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 @@ Version Python License - Tests + Tests

--- @@ -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