"""
pyserep.selection.band_selector
===================================
FrequencyBand and FrequencyBandSet — frequency band management for
selective SEREP analysis.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass
from typing import List, Optional, Sequence
import numpy as np
[docs]
@dataclass(frozen=True)
class FrequencyBand:
"""
A single contiguous analysis band [f_min, f_max] Hz.
Parameters
----------
f_min : float
Lower bound (Hz). Use 0.0 for DC.
f_max : float
Upper bound (Hz).
label : str, optional
Human-readable label. Auto-generated if not supplied.
n_points : int, optional
Number of frequency evaluation points in this band.
Overrides the global ``n_points_per_band`` when set.
Examples
--------
>>> band = FrequencyBand(0, 100, label="LowBand")
>>> band.contains(50)
True
>>> band.span
100.0
"""
f_min: float
f_max: float
label: Optional[str] = None
n_points: Optional[int] = None
def __post_init__(self) -> None:
if self.f_max <= self.f_min:
raise ValueError(
f"FrequencyBand requires f_max > f_min; got [{self.f_min}, {self.f_max}]"
)
if self.f_min < 0:
raise ValueError("f_min must be >= 0")
if self.label is None:
object.__setattr__(
self, "label",
f"Band_{self.f_min:.0f}-{self.f_max:.0f}Hz",
)
@property
def span(self) -> float:
"""Width of the band in Hz."""
return self.f_max - self.f_min
@property
def centre(self) -> float:
"""Centre frequency in Hz."""
return (self.f_min + self.f_max) / 2.0
[docs]
def contains(self, freq: float) -> bool:
"""Return True if *freq* lies within this band (inclusive)."""
return self.f_min <= freq <= self.f_max
[docs]
def expanded(self, alpha: float) -> "FrequencyBand":
"""Return a new band with f_max scaled by *alpha* (MS1 safety factor)."""
return FrequencyBand(
self.f_min, self.f_max * alpha,
label=f"{self.label}_x{alpha:.2f}",
)
def __repr__(self) -> str:
return f"FrequencyBand({self.f_min:.1f}–{self.f_max:.1f} Hz, '{self.label}')"
[docs]
class FrequencyBandSet:
"""
Container for a collection of FrequencyBand objects.
Manages the multi-band frequency grid, mode relevance checks,
and band-weighted Modal Participation Factor computation.
Parameters
----------
bands : sequence of FrequencyBand
n_points_per_band : int
Default frequency evaluation points per band.
Examples
--------
>>> bset = FrequencyBandSet([FrequencyBand(0, 100), FrequencyBand(400, 500)])
>>> bset.n_bands
2
>>> len(bset.frequency_grid())
4000
"""
def __init__(
self,
bands: Sequence[FrequencyBand],
n_points_per_band: int = 2000,
) -> None:
if not bands:
raise ValueError("At least one FrequencyBand is required.")
self._bands: List[FrequencyBand] = sorted(bands, key=lambda b: b.f_min)
self._n_default = n_points_per_band
self._validate()
def _validate(self) -> None:
for i in range(len(self._bands) - 1):
a, b = self._bands[i], self._bands[i + 1]
if a.f_max > b.f_min:
warnings.warn(
f"Bands '{a.label}' and '{b.label}' overlap "
f"({a.f_max:.1f} Hz > {b.f_min:.1f} Hz).",
UserWarning, stacklevel=3,
)
# ── Properties ────────────────────────────────────────────────────────────
@property
def bands(self) -> List[FrequencyBand]:
"""Return a copy of the band list, sorted by f_min."""
return list(self._bands)
@property
def n_bands(self) -> int:
"""Number of frequency bands in the set."""
return len(self._bands)
@property
def global_f_min(self) -> float:
"""Lower bound of the lowest band (Hz)."""
return self._bands[0].f_min
@property
def global_f_max(self) -> float:
"""Upper bound of the highest band (Hz)."""
return self._bands[-1].f_max
@property
def is_selective(self) -> bool:
"""True when there are gaps between bands."""
return self.n_bands > 1
# ── Frequency grid ────────────────────────────────────────────────────────
[docs]
def frequency_grid(self) -> np.ndarray:
"""
Build the evaluation frequency array (Hz) as the union of all bands.
Returns
-------
np.ndarray, sorted and deduplicated.
"""
grids = []
for band in self._bands:
n = band.n_points if band.n_points is not None else self._n_default
grids.append(np.linspace(band.f_min, band.f_max, n))
return np.sort(np.unique(np.concatenate(grids)))
[docs]
def frequency_mask(self, freqs_hz: np.ndarray) -> np.ndarray:
"""Boolean mask: True where *freqs_hz* falls inside any band."""
mask = np.zeros(freqs_hz.shape, dtype=bool)
for band in self._bands:
mask |= (freqs_hz >= band.f_min) & (freqs_hz <= band.f_max)
return mask
# ── Mode relevance ────────────────────────────────────────────────────────
[docs]
def mode_passes_ms1(
self,
freq_hz: float,
rb_hz: float = 1.0,
alpha: float = 1.5,
) -> bool:
"""True if a mode passes the MS1 frequency-range criterion."""
if freq_hz <= rb_hz:
return False
return any(freq_hz <= alpha * b.f_max for b in self._bands)
[docs]
def band_weighted_mpf(
self,
phi_f: np.ndarray,
phi_o: np.ndarray,
omega_n: np.ndarray,
band: FrequencyBand,
) -> np.ndarray:
"""
Band-weighted Modal Participation Factor for all modes.
C_i = |phi_f_i * phi_o_i| * max_ω_in_band(1 / |ωᵢ² − ω²|)
Parameters
----------
phi_f, phi_o : (n_modes,)
omega_n : (n_modes,)
band : FrequencyBand
Returns
-------
(n_modes,) band-weighted MPF
"""
n = band.n_points if band.n_points is not None else self._n_default
omega_eval = 2.0 * np.pi * np.linspace(band.f_min, band.f_max, n)
numerator = np.abs(phi_f * phi_o)
denom = np.abs(omega_n[:, None] ** 2 - omega_eval[None, :] ** 2)
eps = max(1e-6 * (omega_n.mean() ** 2), 1e-6)
denom = np.maximum(denom, eps)
return numerator * (1.0 / denom).max(axis=1)
# ── Summary ───────────────────────────────────────────────────────────────
[docs]
def summary(self) -> str:
"""Return a human-readable multi-line summary of all bands and gaps."""
lines = [f"FrequencyBandSet ({self.n_bands} band(s))"]
for b in self._bands:
n = b.n_points if b.n_points is not None else self._n_default
lines.append(
f" {b.label:25s} [{b.f_min:8.2f}, {b.f_max:8.2f}] Hz {n} pts"
)
if self.is_selective:
for i in range(len(self._bands) - 1):
lo = self._bands[i].f_max
hi = self._bands[i + 1].f_min
if hi > lo:
lines.append(f" GAP: [{lo:.1f}, {hi:.1f}] Hz (ignored)")
return "\n".join(lines)
def __repr__(self) -> str:
parts = ", ".join(f"[{b.f_min:.0f}–{b.f_max:.0f}Hz]" for b in self._bands)
return f"FrequencyBandSet({parts})"