Source code for simpleLOMs.analysis

"""
analysis.py
-----------
Pure numerical analysis functions for extracting resonance frequencies
and linewidths from S-parameter networks.

None of these functions build networks or do optimization — they only
inspect existing rf.Network objects.  This makes them easy to test
independently and reuse across models.
"""
from __future__ import annotations
import logging

import numpy as np
import skrf as rf
from scipy.signal import savgol_filter, find_peaks

logger = logging.getLogger(__name__)




def _fit_circle_kasa(z: np.ndarray) -> tuple[complex, float]:
    """
    Algebraic Kasa circle fit to complex points z = x + i y.

    Returns
    -------
    center : complex
        Fitted circle center.
    radius : float
        Fitted circle radius.
    """
    x = np.real(z)
    y = np.imag(z)

    A = np.column_stack([x, y, np.ones_like(x)])
    b = -(x**2 + y**2)

    coeffs, *_ = np.linalg.lstsq(A, b, rcond=None)
    a, b_, c = coeffs

    xc = -a / 2.0
    yc = -b_ / 2.0
    r = np.sqrt(max(xc**2 + yc**2 - c, 0.0))

    return xc + 1j * yc, r


[docs] def circle_fit_f0_kappa( ntwk: rf.Network, m: int = 0, n: int = 0, smooth_window: int | None = 51, smooth_polyorder: int = 3, ) -> tuple[float, float]: """ Extract resonance frequency (f0) and linewidth (kappa) from a circle fit to S[m,n] in the complex plane (reflection S11/S22). Returns ------- f0 : float Resonance frequency in Hz. kappa : float Linewidth (FWHM of the angular velocity peak) in Hz. """ f = ntwk.frequency.f s = ntwk.s[:, m, n] if len(f) < 5: raise ValueError("Need ≥5 frequency points for circle-fit.") center, radius = _fit_circle_kasa(s) if not np.isfinite(radius) or radius <= 0: raise ValueError("Circle fit failed: non-physical radius.") theta = np.unwrap(np.angle(s - center)) sw = smooth_window if sw is not None: if sw >= len(theta): sw = len(theta) - 1 if len(theta) % 2 == 0 else len(theta) if sw < 5: sw = None elif sw % 2 == 0: sw += 1 if sw is not None: theta = savgol_filter(theta, window_length=sw, polyorder=smooth_polyorder) dtheta_df = np.abs(np.gradient(theta, f)) idx_peak = int(np.argmax(dtheta_df)) f0 = float(f[idx_peak]) half_max = dtheta_df[idx_peak] / 2.0 above = dtheta_df > half_max crossings = np.where(np.diff(above.astype(int)))[0] left_cross = crossings[crossings < idx_peak] right_cross = crossings[crossings > idx_peak] if len(left_cross) == 0 or len(right_cross) == 0: if idx_peak > 0 and idx_peak < len(f) - 1: df = f[1] - f[0] kappa = 2.0 * df else: kappa = float("nan") else: iL = left_cross[-1] iR = right_cross[0] def _interp(i: int) -> float: f0_, f1_ = f[i], f[i + 1] y0_, y1_ = dtheta_df[i], dtheta_df[i + 1] if y1_ == y0_: return 0.5 * (f0_ + f1_) return f0_ + (half_max - y0_) * (f1_ - f0_) / (y1_ - y0_) kappa = float(_interp(iR) - _interp(iL)) return f0, kappa
[docs] def circle_fit_modes( ntwk: rf.Network, m: int = 0, n: int = 0, n_modes: int = 3, prominence: float = None, min_spacing_hz: float = None, smooth_window: int | None = 51, smooth_polyorder: int = 3, ) -> tuple[np.ndarray, np.ndarray]: """ Find multiple resonance modes in S[m,n] using the reflection circle fit. Returns ------- f0s : np.ndarray Mode frequencies in Hz, length n_modes (NaN-padded). kappas : np.ndarray Mode linewidths in Hz, length n_modes (NaN-padded). """ f = ntwk.frequency.f s = ntwk.s[:, m, n] df = f[1] - f[0] if min_spacing_hz is None: min_spacing_hz = (f[-1] - f[0]) / (10 * n_modes) min_distance_pts = max(1, int(min_spacing_hz / df)) center, _ = _fit_circle_kasa(s) theta = np.unwrap(np.angle(s - center)) sw = smooth_window if sw is not None: if sw >= len(theta): sw = len(theta) - 1 if len(theta) % 2 == 0 else len(theta) if sw < 5: sw = None elif sw % 2 == 0: sw += 1 if sw is not None: theta = savgol_filter(theta, window_length=sw, polyorder=smooth_polyorder) trace = np.abs(np.gradient(theta, f)) if prominence is None: prominence = 0.0005 * (trace.max() - trace.min()) peak_indices, _ = find_peaks( trace, prominence=prominence, distance=min_distance_pts, ) if len(peak_indices) > n_modes: heights = trace[peak_indices] top = np.argsort(heights)[::-1][:n_modes] peak_indices = np.sort(peak_indices[top]) f0s = np.full(n_modes, np.nan) kappas = np.full(n_modes, np.nan) for k, pidx in enumerate(peak_indices): if k >= n_modes: break half_w_pts = max(20, int(0.5 * min_distance_pts)) i_lo = max(0, pidx - 5 * half_w_pts) i_hi = min(len(f) - 1, pidx + 5 * half_w_pts) local_freq = rf.Frequency.from_f(f[i_lo:i_hi + 1], unit="Hz") local_s = s[i_lo:i_hi + 1] if len(local_s) < 5: f0s[k] = float(f[pidx]) kappas[k] = float("nan") continue local_ntwk = rf.Network( frequency=local_freq, s=local_s[:, np.newaxis, np.newaxis], ) try: f0_loc, kappa_loc = circle_fit_f0_kappa( local_ntwk, m=0, n=0, smooth_window=smooth_window, smooth_polyorder=smooth_polyorder, ) f0s[k] = f0_loc kappas[k] = kappa_loc except Exception: f0s[k] = float(f[pidx]) kappas[k] = float("nan") return f0s, kappas
[docs] def stitch_shifted_freqs( *arrays: np.ndarray, dedup_tol_ghz: float = 0.05, return_sources: bool = False, ): """Merge S11/S22 mode frequency lists, deduplicating nearby modes.""" tagged = [] for src_idx, arr in enumerate(arrays): for freq in arr[~np.isnan(arr)]: tagged.append((freq, src_idx)) if not tagged: empty = np.array([]) return (empty, np.array([], dtype=object)) if return_sources else empty tagged = sorted(tagged, key=lambda x: x[0]) merged_freqs = [] merged_sources = [] cluster_freqs = [tagged[0][0]] cluster_srcs = [tagged[0][1]] for freq, src in tagged[1:]: if freq - cluster_freqs[0] < dedup_tol_ghz: cluster_freqs.append(freq) cluster_srcs.append(src) else: merged_freqs.append(float(np.mean(cluster_freqs))) merged_sources.append(max(set(cluster_srcs), key=cluster_srcs.count)) cluster_freqs = [freq] cluster_srcs = [src] merged_freqs.append(float(np.mean(cluster_freqs))) merged_sources.append(max(set(cluster_srcs), key=cluster_srcs.count)) freqs = np.array(merged_freqs) sources = np.array(["s11" if s == 0 else "s22" for s in merged_sources], dtype=object) return (freqs, sources) if return_sources else freqs
[docs] def resonance_circle_fit( ntwk: rf.Network, m: int = 0, n: int = 0, smooth_window: int | None = 51, smooth_polyorder: int = 3, ) -> float: """ Estimate resonance frequency from a circle fit to S[m,n] in the complex plane. Strategy -------- 1. Fit a circle to the complex trace S[m,n](f). 2. Compute the angle of each point about the fitted center. 3. Take the frequency where |d(theta)/df| is largest. This is usually a good reflection-based estimator for S11/S22. Parameters ---------- ntwk : rf.Network m, n : int S-parameter indices. smooth_window : int or None Optional Savitzky-Golay smoothing window for theta before differentiation. Must be odd if provided. smooth_polyorder : int Polynomial order for Savitzky-Golay smoothing. Returns ------- float Resonance frequency in Hz. """ f0, _ = circle_fit_f0_kappa( ntwk, m=m, n=n, smooth_window=smooth_window, smooth_polyorder=smooth_polyorder, ) return f0
[docs] def resonance( ntwk: rf.Network, m: int = 0, n: int = 0, use_max: bool = False, method: str = "min_re", ) -> float: """ Estimate resonance frequency from S[m,n]. Parameters ---------- ntwk : rf.Network m, n : int S-parameter indices (0-based). use_max : bool Only used for method='min_re'. If True, find frequency of maximum Re(S) instead of minimum. method : str Resonance estimator. Supported: - 'min_re' : min/max of Re(S) - 'circle_fit' : circle fit in complex plane Returns ------- float Resonance frequency in Hz. """ if method == "circle_fit": return resonance_circle_fit(ntwk, m=m, n=n) if method == "min_re": f = ntwk.frequency.f re_s = np.real(ntwk.s[:, m, n]) idx = np.argmax(re_s) if use_max else np.argmin(re_s) return float(f[idx]) raise ValueError(f"Unknown resonance method: {method}")
# def resonance(ntwk: rf.Network, m: int = 0, n: int = 0, use_max: bool = False) -> float: # """ # Estimate resonance frequency from the real part of S[m,n]. # By default returns the frequency of the minimum of Re(S), which # corresponds to the resonance dip in S11/S22. Set use_max=True # for transmission peaks. # Parameters # ---------- # ntwk : rf.Network # m, n : int # S-parameter indices (0-based). # use_max : bool # If True, find frequency of maximum Re(S) instead of minimum. # Returns # ------- # float # Resonance frequency in Hz. # """ # f = ntwk.frequency.f # re_s = np.real(ntwk.s[:, m, n]) # idx = np.argmax(re_s) if use_max else np.argmin(re_s) # return float(f[idx])
[docs] def resonances( ntwk: rf.Network, m: int = 0, n: int = 0, n_modes: int = 3, method: str = "min_re", prominence: float = None, min_spacing_hz: float = None, ) -> np.ndarray: """ Estimate resonance frequencies for multiple modes from S[m,n]. Parameters ---------- ntwk : rf.Network m, n : int S-parameter indices (0-based). n_modes : int Number of resonant modes to find. method : str Resonance estimator. Supported: - 'min_re' : finds n_modes minima of Re(S) - 'circle_fit' : finds n_modes peaks of |dθ/df| from circle fit prominence : float, optional Minimum peak prominence passed to scipy.signal.find_peaks. If None, defaults to 0.1 * (max - min) of the trace being searched. min_spacing_hz : float, optional Minimum frequency separation between modes in Hz. If None, defaults to (f_max - f_min) / (10 * n_modes). Returns ------- np.ndarray Resonance frequencies in Hz, sorted ascending, length n_modes. If fewer than n_modes peaks are found, the array is padded with NaN. """ from scipy.signal import find_peaks f = ntwk.frequency.f df = f[1] - f[0] # assumes uniform spacing if min_spacing_hz is None: min_spacing_hz = (f[-1] - f[0]) / (10 * n_modes) min_distance_pts = max(1, int(min_spacing_hz / df)) if method == "min_re": # Search for minima of Re(S) by inverting the trace trace = -np.real(ntwk.s[:, m, n]) elif method == "circle_fit": # Build the angular velocity |dθ/df| trace from the circle fit s = ntwk.s[:, m, n] x, y = s.real, s.imag # Kasa algebraic circle fit A = np.column_stack([x, y, np.ones(len(x))]) b = -(x**2 + y**2) coeffs, _, _, _ = np.linalg.lstsq(A, b, rcond=None) cx, cy = -coeffs[0] / 2.0, -coeffs[1] / 2.0 theta = np.unwrap(np.angle((x - cx) + 1j * (y - cy))) trace = np.abs(np.gradient(theta, f)) else: raise ValueError(f"Unknown resonance method: {method!r}") if prominence is None: prominence = 0.1 * (trace.max() - trace.min()) peak_indices, _ = find_peaks( trace, prominence=prominence, distance=min_distance_pts, ) # Sort by peak height descending, take top n_modes, then re-sort by frequency if len(peak_indices) > n_modes: heights = trace[peak_indices] top = np.argsort(heights)[::-1][:n_modes] peak_indices = np.sort(peak_indices[top]) freqs = f[peak_indices].astype(float) # Pad with NaN if not enough peaks found if len(freqs) < n_modes: freqs = np.concatenate([freqs, np.full(n_modes - len(freqs), np.nan)]) return freqs
[docs] def resonance_from_s_max(network: rf.Network, m: int = 0, n: int = 0) -> float: """ Resonance frequency from the dominant peak in |S[m,n]|. Finds all peaks in the magnitude and returns the frequency of the largest one. Useful for transmission parameters (S12/S21) where the resonance appears as a peak rather than a dip. Parameters ---------- network : rf.Network m, n : int S-parameter indices (0-based). Returns ------- float Dominant resonance frequency in GHz. Raises ------ ValueError If no peaks are found in |S[m,n]|. """ f = network.frequency.f / 1e9 s_mn = network.s[:, m, n] peaks, _ = find_peaks(np.abs(s_mn)) if len(peaks) == 0: raise ValueError("No peaks found in |S[{},{}]|.".format(m, n)) peak_idx = peaks[np.argmax(np.abs(s_mn)[peaks])] return float(f[peak_idx])
[docs] def resonances_from_s_max(network: rf.Network, m: int = 0, n: int = 0) -> float: """ Resonance frequency from the dominant peak in |S[m,n]|. Finds all peaks in the magnitude and returns the frequency of the largest one. Useful for transmission parameters (S12/S21) where the resonance appears as a peak rather than a dip. Parameters ---------- network : rf.Network m, n : int S-parameter indices (0-based). Returns ------- float Dominant resonance frequency in GHz. Raises ------ ValueError If no peaks are found in |S[m,n]|. """ f = network.frequency.f / 1e9 s_mn = network.s[:, m, n] peaks, _ = find_peaks(np.abs(s_mn)) freqs = f[peaks] logger.debug("Resonance frequencies (GHz): %s", freqs) if len(peaks) == 0: raise ValueError("No peaks found in |S[{},{}]|.".format(m, n)) return freqs
[docs] def resonances_from_s(network: rf.Network, m: int = 0, n: int = 0) -> np.ndarray: """ Find all resonance frequencies from peaks in Re(S[m,n]). Parameters ---------- network : rf.Network m, n : int S-parameter indices (0-based). Returns ------- np.ndarray Array of resonance frequencies in GHz. """ f = network.frequency.f / 1e9 s_mn = network.s[:, m, n] peaks, _ = find_peaks(np.real(s_mn)) freqs = f[peaks] logger.debug("Resonance frequencies (GHz): %s", freqs) return freqs
[docs] def fwhm_from_trace_db( ntwk: rf.Network, m: int = 0, n: int = 0, kind: str = "dip", smooth: int = None, ) -> float: """ Full-Width at Half Maximum (FWHM) from S-parameter magnitude in dB. Uses crossing-interpolation to find the two frequencies where the magnitude crosses the half-depth level, giving a more accurate result than a pure index-based approach. Parameters ---------- ntwk : rf.Network m, n : int S-parameter indices (0-based). kind : {"dip", "peak"} "dip" — resonance appears as a downward dip (typical for S11/S22). "peak" — resonance appears as an upward peak (typical for S12/S21). smooth : int or None Optional moving-average window length for noisy traces. Use an odd integer; None disables smoothing. Returns ------- float FWHM linewidth in Hz. Raises ------ ValueError If two crossings at the half-depth level cannot be found. """ f = np.asarray(ntwk.frequency.f, dtype=float) s = ntwk.s[:, m, n] mag_db = 20 * np.log10(np.maximum(np.abs(s), 1e-300)) if smooth is not None and smooth > 1: kernel = np.ones(int(smooth)) / int(smooth) mag_db = np.convolve(mag_db, kernel, mode="same") if kind == "dip": i0 = np.argmin(mag_db) y0 = mag_db[i0] baseline = np.median(np.r_[ mag_db[:max(3, i0 // 8)], mag_db[min(len(mag_db) - 3, i0 + len(mag_db) // 8):], ]) level = 0.5 * (baseline + y0) above = mag_db > level elif kind == "peak": i0 = np.argmax(mag_db) y0 = mag_db[i0] baseline = np.median(np.r_[ mag_db[:max(3, i0 // 8)], mag_db[min(len(mag_db) - 3, i0 + len(mag_db) // 8):], ]) level = 0.5 * (baseline + y0) above = mag_db < level else: raise ValueError("kind must be 'dip' or 'peak', got '{}'.".format(kind)) flips = np.where(above[:-1] != above[1:])[0] if flips.size < 2: raise ValueError("Could not find two crossings for FWHM.") left_flips = flips[flips < i0] right_flips = flips[flips >= i0] if left_flips.size == 0 or right_flips.size == 0: raise ValueError("Could not bracket resonance with crossings.") iL = left_flips[-1] iR = right_flips[0] def _interp_crossing(i: int) -> float: x0, x1 = f[i], f[i + 1] y0_, y1_ = mag_db[i], mag_db[i + 1] if y1_ == y0_: return 0.5 * (x0 + x1) return x0 + (level - y0_) * (x1 - x0) / (y1_ - y0_) return float(_interp_crossing(iR) - _interp_crossing(iL))
[docs] def fwhm_from_res11(ntwk: rf.Network) -> float: """ Linewidth from zero crossings of Re(S11). Re(S11) crosses zero at the two half-power frequencies of the resonance. Uses linear interpolation between samples for accuracy. Parameters ---------- ntwk : rf.Network Returns ------- float Linewidth (distance between the two zero crossings) in Hz. Raises ------ ValueError If fewer than two zero crossings are found. """ f = ntwk.frequency.f re_s11 = np.real(ntwk.s[:, 0, 0]) sign_changes = np.where(np.diff(np.sign(re_s11)))[0] if len(sign_changes) < 2: raise ValueError("Could not determine linewidth: fewer than 2 zero crossings found.") zeros = [] for idx in sign_changes[:2]: f1, f2 = f[idx], f[idx + 1] y1, y2 = re_s11[idx], re_s11[idx + 1] f_zero = f1 - y1 * (f2 - f1) / (y2 - y1) zeros.append(f_zero) return float(zeros[1] - zeros[0])
[docs] def resonance_from_res11(ntwk: rf.Network) -> float: """ Resonance frequency from the minimum of Re(S11). Thin convenience wrapper around `resonance` for the common single-port S11 case. Parameters ---------- ntwk : rf.Network Returns ------- float Resonance frequency in Hz. """ return resonance(ntwk, m=0, n=0, use_max=False)
def find_resonant_frequency(network: rf.Network) -> complex: freqs = network.f S21 = network.s[:, 1, 0] mag = np.abs(S21) peak_idx = np.argmax(mag) omega_0 = 2 * np.pi * freqs[peak_idx] half_pow = mag[peak_idx] / np.sqrt(2) left = np.where(mag[:peak_idx] < half_pow)[0] right = np.where(mag[peak_idx:] < half_pow)[0] alpha = (2 * np.pi * freqs[peak_idx + right[0]] - 2 * np.pi * freqs[left[-1]]) / 2 pole = complex(-alpha, omega_0) f0 = pole.imag / (2 * np.pi) return f0
[docs] def find_resonant_frequency(network: rf.Network) -> complex: freqs = network.f S21 = network.s[:, 1, 0] mag = np.abs(S21) peak_idx = np.argmax(mag) omega_0 = 2 * np.pi * freqs[peak_idx] half_pow = mag[peak_idx] / np.sqrt(2) left = np.where(mag[:peak_idx] < half_pow)[0] right = np.where(mag[peak_idx:] < half_pow)[0] alpha = (2 * np.pi * freqs[peak_idx + right[0]] - 2 * np.pi * freqs[left[-1]]) / 2 pole = complex(-alpha, omega_0) f0 = pole.imag / (2 * np.pi) return f0