Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 4.1.3
current_version = 4.1.4
commit = True
tag = True
tag_name = v{new_version}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</p>

<p align="center">
<a href="https://github.com/RFingAdam/RFlect/releases"><img src="https://img.shields.io/badge/version-4.1.3-blue" alt="Version"></a>
<a href="https://github.com/RFingAdam/RFlect/releases"><img src="https://img.shields.io/badge/version-4.1.4-blue" alt="Version"></a>
<img src="https://img.shields.io/badge/python-3.11+-green" alt="Python">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-orange" alt="License"></a>
<img src="https://img.shields.io/badge/tests-391%20passing-brightgreen" alt="Tests">
Expand Down
17 changes: 17 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# RFlect - Release Notes

## Version 4.1.4 (02/11/2026)

**Feature release — horizon band TRP, efficiency calculations, and enhanced maritime statistics.**

### New Features
- **Horizon TRP**: Integrated radiated power over the horizon band (the "donut" between theta min/max), computed using sin(θ)-weighted numerical integration
- **Full Sphere TRP**: Total radiated power integrated over the entire measurement sphere for reference
- **Horizon Efficiency**: Percentage of total radiated power concentrated in the horizon band — the key figure of merit for maritime antennas
- **3D pattern statistics**: The 3D masked horizon plot now includes an annotation box with max/min/avg, horizon TRP, full TRP, and efficiency

### Improvements
- **Multi-cut polar plot**: Horizon statistics page now shows 3–5 polar cuts spanning the full horizon band instead of a single θ=90° cut
- **Enhanced statistics table**: Added Horizon TRP, Full Sphere TRP, and Horizon Efficiency rows with appropriate labels (Gain/Directivity for passive, Power/TRP for active)
- **Batch processing**: Disabled interactive matplotlib during batch jobs so figure windows no longer briefly pop up and interfere with queuing additional work

---

## Version 4.1.3 (02/10/2026)

**Patch release — coverage threshold and horizon statistics fixes.**
Expand Down
2 changes: 1 addition & 1 deletion plot_antenna/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- Professional report generation
"""

__version__ = "4.1.3"
__version__ = "4.1.4"
__author__ = "Adam"
__license__ = "GPL-3.0"

Expand Down
20 changes: 20 additions & 0 deletions plot_antenna/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1044,8 +1044,14 @@ def batch_process_passive_scans(
``save_base`` if provided.
"""
import os

import matplotlib.pyplot as plt

from .plotting import plot_2d_passive_data, plot_passive_3d_component

# 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")]
Expand All @@ -1061,6 +1067,7 @@ def batch_process_passive_scans(

if not pairs:
print(f"No HPOL/VPOL pairs found in {folder_path}.")
plt.ion()
return

for h_path, v_path in pairs:
Expand Down Expand Up @@ -1174,6 +1181,9 @@ def batch_process_passive_scans(
save_path=maritime_sub,
)

# Re-enable interactive mode after batch processing
plt.ion()


def batch_process_active_scans(
folder_path,
Expand Down Expand Up @@ -1207,15 +1217,22 @@ def batch_process_active_scans(
4. Saves results to per‑file subfolders in ``save_base`` if provided.
"""
import os

import matplotlib.pyplot as plt

from .plotting import plot_active_2d_data, plot_active_3d_data
from .calculations import calculate_active_variables

# 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()]

if not trp_files:
print(f"No TRP files found in {folder_path}.")
plt.ion()
return

for trp_file in trp_files:
Expand Down Expand Up @@ -1341,3 +1358,6 @@ def batch_process_active_scans(

except Exception as e:
print(f" ✗ Error processing {trp_file}: {e}")

# Re-enable interactive mode after batch processing
plt.ion()
151 changes: 132 additions & 19 deletions plot_antenna/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2203,7 +2203,7 @@ def plot_conical_cuts(
else:
ax = fig.add_subplot(111)

colors = plt.cm.viridis(np.linspace(0, 1, len(theta_cuts)))
colors = cm.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))
Expand Down Expand Up @@ -2289,7 +2289,7 @@ def plot_gain_over_azimuth(
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)

colors = plt.cm.viridis(np.linspace(0, 1, len(theta_cuts)))
colors = cm.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))
Expand Down Expand Up @@ -2338,6 +2338,44 @@ def plot_gain_over_azimuth(
plt.show()


def _compute_partial_trp(theta_deg, phi_deg, gain_2d, theta_min=None, theta_max=None):
"""
Compute TRP (Total Radiated Power) over a partial or full sphere.

Uses numerical integration with sin(theta) weighting:
TRP = ΔΘ·ΔΦ / (4π) · ΣΣ P_linear(θ,φ) · sin(θ)

Parameters:
theta_deg: 1D array of theta angles in degrees
phi_deg: 1D array of phi angles in degrees
gain_2d: 2D array (n_theta, n_phi) of gain/power in dB scale
theta_min: Optional lower bound (degrees). None = use full range.
theta_max: Optional upper bound (degrees). None = use full range.

Returns:
TRP in dB (10*log10 of linear TRP), or -inf if no data.
"""
if theta_min is not None and theta_max is not None:
mask = (theta_deg >= theta_min) & (theta_deg <= theta_max)
sel_theta = theta_deg[mask]
sel_gain = gain_2d[mask, :]
else:
sel_theta = theta_deg
sel_gain = gain_2d

if sel_gain.size == 0:
return float("-inf")

d_theta = np.deg2rad(np.mean(np.diff(sel_theta))) if len(sel_theta) > 1 else np.deg2rad(5.0)
d_phi = np.deg2rad(np.mean(np.diff(phi_deg))) if len(phi_deg) > 1 else np.deg2rad(5.0)

lin = 10 ** (sel_gain / 10)
sin_w = np.sin(np.deg2rad(sel_theta))
# Integrate: ΔΘ·ΔΦ/(4π) · Σ_θ Σ_φ P(θ,φ)·sin(θ)
integrated = d_theta * d_phi / (4 * np.pi) * np.sum(lin * sin_w[:, np.newaxis])
return 10 * np.log10(integrated) if integrated > 0 else float("-inf")


def plot_horizon_statistics(
theta_deg,
phi_deg,
Expand Down Expand Up @@ -2374,7 +2412,7 @@ def plot_horizon_statistics(
print(f"[Maritime] No data in theta range {theta_min}-{theta_max} deg")
return

# Statistics
# ----- Basic statistics -----
max_gain = np.max(horizon_gain)
min_gain = np.min(horizon_gain)
lin = 10 ** (horizon_gain / 10)
Expand All @@ -2384,47 +2422,62 @@ def plot_horizon_statistics(
coverage_limit = max_gain + gain_threshold # threshold is negative
coverage_pct = 100.0 * np.sum(horizon_gain >= coverage_limit) / horizon_gain.size

# MEG: Mean Effective Gain with sin(theta) weighting (passive only)
# For active power data, sin-weighted average EIRP is shown instead.
# Sin-weighted average (MEG for passive, avg EIRP for active)
theta_rad = np.deg2rad(horizon_theta)
sin_weights = np.sin(theta_rad)
n_phi = horizon_gain.shape[1]
weighted_lin = lin * sin_weights[:, np.newaxis]
meg_lin = np.sum(weighted_lin) / (np.sum(sin_weights) * n_phi)
meg_dB = 10 * np.log10(meg_lin) if meg_lin > 0 else float("-inf")

# Null detection: find the minimum point
# Null detection
null_flat_idx = np.argmin(horizon_gain)
null_theta_idx, null_phi_idx = np.unravel_index(null_flat_idx, horizon_gain.shape)
null_depth = min_gain - max_gain
null_location = f"θ={horizon_theta[null_theta_idx]:.0f}°, φ={phi_deg[null_phi_idx]:.0f}°"

# Create figure with table and mini polar plot
fig = plt.figure(figsize=(14, 6))
gs = fig.add_gridspec(1, 2, width_ratios=[1.5, 1])
# ----- TRP / efficiency -----
trp_horizon_dB = _compute_partial_trp(
theta_deg, phi_deg, gain_2d, theta_min=theta_min, theta_max=theta_max
)
trp_full_dB = _compute_partial_trp(theta_deg, phi_deg, gain_2d)
if trp_full_dB > -100 and trp_horizon_dB > -100:
horizon_eff = 10 ** ((trp_horizon_dB - trp_full_dB) / 10) * 100.0
else:
horizon_eff = 0.0

# Left: statistics table
# ----- Figure: table (left) + multi-cut polar (right) -----
fig = plt.figure(figsize=(16, 7))
gs = fig.add_gridspec(1, 2, width_ratios=[1.4, 1])

# --- Left: statistics table ---
ax_table = fig.add_subplot(gs[0])
ax_table.axis("off")

# Label the sin-weighted metric appropriately for gain vs power
if data_label == "Gain":
meg_label = "MEG (sin-θ weighted)"
trp_label = "Partial Directivity (horizon)"
trp_full_label = "Total Directivity (full sphere)"
else:
meg_label = "Avg EIRP (sin-θ weighted)"
trp_label = "Horizon TRP"
trp_full_label = "Full Sphere TRP"

table_data = [
["Max " + data_label, f"{max_gain:.1f} {data_unit}"],
["Min " + data_label, f"{min_gain:.1f} {data_unit}"],
["Avg " + data_label + " (linear)", f"{avg_gain:.1f} {data_unit}"],
[meg_label, f"{meg_dB:.1f} {data_unit}"],
[trp_label, f"{trp_horizon_dB:.1f} {data_unit}"],
[trp_full_label, f"{trp_full_dB:.1f} {data_unit}"],
["Horizon Efficiency", f"{horizon_eff:.1f}%"],
[
f"Coverage (>{coverage_limit:.1f} {data_unit})",
f"{coverage_pct:.1f}%",
],
["Null Depth", f"{null_depth:.1f} dB"],
["Null Location", null_location],
["Theta Range", f"{theta_min}° - {theta_max}°"],
["Theta Range", f"{theta_min}° {theta_max}°"],
["Frequency", f"{frequency} MHz"],
]

Expand All @@ -2437,7 +2490,7 @@ def plot_horizon_statistics(
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 1.6)
table.scale(1, 1.5)

# Style header row
for j in range(2):
Expand All @@ -2451,17 +2504,38 @@ def plot_horizon_statistics(
pad=20,
)

# Right: mini polar plot at theta=90
# --- Right: multi-cut polar plot across horizon band ---
ax_polar = fig.add_subplot(gs[1], projection="polar")
theta_90_idx = np.argmin(np.abs(theta_deg - 90))
cut_gain = gain_2d[theta_90_idx, :]
# Pick 3-5 representative cuts spanning the horizon band
band_thetas = np.array(
[
t
for t in [theta_min, (theta_min + 90) / 2, 90, (90 + theta_max) / 2, theta_max]
if theta_min <= t <= theta_max
]
)
# Remove near-duplicates
band_thetas = np.unique(np.round(band_thetas, 1))
cmap = cm.get_cmap("viridis")
colors = cmap(np.linspace(0, 1, len(band_thetas)))

phi_rad = np.deg2rad(phi_deg)
phi_plot = np.append(phi_rad, phi_rad[0])
gain_plot = np.append(cut_gain, cut_gain[0])
ax_polar.plot(phi_plot, gain_plot, "b-", linewidth=2)

for i, tc in enumerate(band_thetas):
t_idx = np.argmin(np.abs(theta_deg - tc))
cut = gain_2d[t_idx, :]
cut_plot = np.append(cut, cut[0])
ax_polar.plot(phi_plot, cut_plot, color=colors[i], linewidth=1.5, label=f"θ={tc:.0f}°")

ax_polar.set_theta_zero_location("N")
ax_polar.set_theta_direction(-1)
ax_polar.set_title(f"θ=90° Cut", pad=20, fontsize=11)
ax_polar.set_title(
f"Horizon Cuts ({data_unit})\n{theta_min}°–{theta_max}°",
pad=20,
fontsize=11,
)
ax_polar.legend(loc="upper right", bbox_to_anchor=(1.35, 1.0), fontsize=8)
ax_polar.grid(True, alpha=0.3)

plt.tight_layout()
Expand Down Expand Up @@ -2634,6 +2708,45 @@ def plot_3d_pattern_masked(
cbar = fig.colorbar(mappable, ax=ax, pad=0.1, shrink=0.75)
cbar.set_label(f"{data_label} ({data_unit})")

# ----- Horizon band statistics annotation -----
band_mask = (theta_deg >= theta_highlight_min) & (theta_deg <= theta_highlight_max)
band_data = gain_2d[band_mask, :]
if band_data.size > 0:
band_max = np.max(band_data)
band_min = np.min(band_data)
band_lin = 10 ** (band_data / 10)
band_avg = 10 * np.log10(np.mean(band_lin))

trp_horizon = _compute_partial_trp(
theta_deg,
phi_deg,
gain_2d,
theta_min=theta_highlight_min,
theta_max=theta_highlight_max,
)
trp_full = _compute_partial_trp(theta_deg, phi_deg, gain_2d)
if trp_full > -100 and trp_horizon > -100:
eff = 10 ** ((trp_horizon - trp_full) / 10) * 100.0
else:
eff = 0.0

stats_text = (
f"Horizon Band ({theta_highlight_min}–{theta_highlight_max}°)\n"
f"Max: {band_max:.1f} Min: {band_min:.1f} Avg: {band_avg:.1f} {data_unit}\n"
f"Horizon TRP: {trp_horizon:.1f} {data_unit} "
f"Full TRP: {trp_full:.1f} {data_unit}\n"
f"Horizon Efficiency: {eff:.1f}%"
)
ax.text2D(
0.02,
0.02,
stats_text,
transform=ax.transAxes,
fontsize=9,
verticalalignment="bottom",
bbox=dict(facecolor="white", edgecolor="gray", alpha=0.85, boxstyle="round,pad=0.4"),
)

plt.tight_layout()

if save_path:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "rflect"
version = "4.1.3"
version = "4.1.4"
description = "Antenna measurement visualization and analysis tool"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"CURRENT_VERSION": "v4.1.3"
"CURRENT_VERSION": "v4.1.4"
}