Files
ovf/core/freqency.py
2025-09-20 12:02:24 -04:00

131 lines
4.6 KiB
Python

import numpy as np
def auto_select(H, freq,
n_baseline=64, # log-spaced backbone points
peak_prominence=0.05, # fraction of |H| dB dynamic range for peak detection
peak_window=5, # take ±peak_window samples around each peak
topgrad_q=0.98, # keep top 2% largest slope/phase-change points
max_points=25, # final cap on selected samples (None = no cap)
ensure_ends=True):
"""
Select several significant sample points for vector fitting.
Strategy:
1) Always keep endpoints (optional).
2) Add a log-spaced baseline over the band.
3) Detect resonance peaks in |H| (on a log scale) and keep small windows around them.
4) Add points with the largest magnitude slope and phase-change (w.r.t log-f).
5) De-duplicate, sort, and optionally thin to 'max_points' with priority
to endpoints and detected peaks.
Parameters
----------
H : (N,) complex array
Frequency response samples.
freq : (N,) float array
Frequency axis [Hz], strictly increasing.
n_baseline : int
Count of log-spaced baseline samples across the band.
peak_prominence : float
Peak prominence threshold as a fraction of the dynamic range in log|H|.
0.05 ≈ keep peaks ≥ 5% of the range.
peak_window : int
Number of neighbor indices to include on each side of every detected peak.
topgrad_q : float in (0,1)
Quantile for selecting strong slope/phase points.
0.98 ⇒ keep the top 2% largest derivatives.
max_points : int or None
If not None, cap the total number of selected indices to this value.
ensure_ends : bool
Always include the first and last samples.
Returns
-------
H_sel : (K,) complex array
freq_sel : (K,) float array
"""
H = np.asarray(H).reshape(-1)
f = np.asarray(freq).reshape(-1)
if H.size != f.size:
raise ValueError("H and freq must have the same length.")
N = f.size
if N < 4:
return H.copy(), f.copy()
eps = 1e-16
mag = np.abs(H)
logmag = np.log10(mag + eps)
phase = np.unwrap(np.angle(H))
# log-frequency axis (scale-invariant derivatives)
# keep it linear if any non-positive freq sneaks in
if np.all(f > 0):
lf = np.log(f)
else:
lf = f.copy()
dlf = np.gradient(lf)
d_logmag = np.gradient(logmag) / (dlf + 1e-16)
d_phase = np.gradient(phase) / (dlf + 1e-16)
idx = set()
if ensure_ends:
idx.update([0, N-1])
# 1) log-spaced baseline
if n_baseline > 0:
# map a log grid to nearest indices
grid = np.linspace(lf.min(), lf.max(), n_baseline)
base_idx = np.clip(np.searchsorted(lf, grid), 0, N-1)
idx.update(np.unique(base_idx).tolist())
# 2) peaks in |H|
try:
from scipy.signal import find_peaks
dyn = logmag.max() - logmag.min()
prom = peak_prominence * (dyn + 1e-12)
peaks, _ = find_peaks(logmag, prominence=prom)
except Exception:
# simple fallback: strict local maxima
peaks = np.where((mag[1:-1] > mag[:-2]) & (mag[1:-1] > mag[2:]))[0] + 1
for p in peaks:
lo = max(0, p - peak_window)
hi = min(N, p + peak_window + 1)
idx.update(range(lo, hi))
# 3) strongest slope / phase-change points
thr_slope = np.quantile(np.abs(d_logmag), topgrad_q)
thr_phase = np.quantile(np.abs(d_phase), topgrad_q)
idx.update(np.where(np.abs(d_logmag) >= thr_slope)[0].tolist())
idx.update(np.where(np.abs(d_phase) >= thr_phase)[0].tolist())
# 4) finalize set
sel = np.array(sorted(idx), dtype=int)
# 5) optional thinning with priority to endpoints and peaks
if max_points is not None and sel.size > max_points:
priority = np.zeros(sel.size, dtype=int)
if ensure_ends:
priority[(sel == 0) | (sel == N-1)] = 3
if peaks.size:
priority[np.isin(sel, peaks)] = np.maximum(priority[np.isin(sel, peaks)], 2)
keep = []
budget = max_points
# keep highest-priority first
for lev in (3, 2, 1, 0):
cand = sel[priority == lev]
if cand.size == 0:
continue
if cand.size <= budget:
keep.extend(cand.tolist())
budget -= cand.size
else:
step = max(1, int(np.ceil(cand.size / budget)))
keep.extend(cand[::step][:budget].tolist())
budget = 0
if budget == 0:
break
sel = np.array(sorted(set(keep)), dtype=int)
return H[sel], f[sel]