88
99import numpy as np
1010from numpy .typing import NDArray
11- from pydantic import BaseModel , ConfigDict , Field , ValidationInfo , field_validator
11+ from pydantic import (
12+ AliasChoices ,
13+ BaseModel ,
14+ ConfigDict ,
15+ Field ,
16+ ValidationInfo ,
17+ field_validator ,
18+ )
1219
1320from pypnm .lib .constants import FEET_PER_METER , SPEED_OF_LIGHT , CableTypes
21+ from pypnm .lib .signal_processing .window import SignalWindow , window_values
1422from pypnm .lib .types import ChannelId , ComplexArray , FloatSeries
1523
1624# ──────────────────────────────────────────────────────────────
@@ -37,21 +45,30 @@ class IfftEchoDetectorDatasetInfo(BaseModel):
3745 ----------
3846 subcarriers : int
3947 Number of frequency bins (N).
40- snapshots : int
41- Number of snapshots (M).
48+ captures : int
49+ Number of captures/ snapshots (M).
4250 """
4351 model_config = ConfigDict (str_strip_whitespace = True )
4452
4553 subcarriers : int = Field (..., description = "Number of frequency bins (N)" )
46- snapshots : int = Field (..., description = "Number of snapshots (M)" )
54+ captures : int = Field (
55+ ...,
56+ validation_alias = AliasChoices ("captures" , "snapshots" ),
57+ description = "Number of captures (M)" ,
58+ )
4759
48- @field_validator ("subcarriers" , "snapshots " )
60+ @field_validator ("subcarriers" , "captures " )
4961 @classmethod
5062 def _positive (cls , v : int ) -> int :
5163 if v < 1 :
5264 raise ValueError ("Values must be >= 1." )
5365 return v
5466
67+ @property
68+ def snapshots (self ) -> int :
69+ """Backward-compatible alias for captures."""
70+ return self .captures
71+
5572class IfftEchoReflectionModel (BaseModel ):
5673 """Direct-path and first-echo metrics derived from |h(t)|.
5774
@@ -174,11 +191,11 @@ def _coerce_snap(cls, v: list[ComplexArray], info: ValidationInfo) -> list[Compl
174191 row_out .append ((float (item [0 ]), float (item [1 ])))
175192 out .append (row_out )
176193 di = info .data .get ("dataset_info" )
177- if di is not None and hasattr (di , "subcarriers" ) and hasattr (di , "snapshots " ):
194+ if di is not None and hasattr (di , "subcarriers" ) and hasattr (di , "captures " ):
178195 if di .subcarriers != n :
179196 raise ValueError (f"H_snap N={ n } must match dataset_info.subcarriers={ di .subcarriers } ." )
180- if di .snapshots != len (out ):
181- raise ValueError (f"H_snap M={ len (out )} must match dataset_info.snapshots ={ di .snapshots } ." )
197+ if di .captures != len (out ):
198+ raise ValueError (f"H_snap M={ len (out )} must match dataset_info.captures ={ di .captures } ." )
182199 return out
183200
184201class IfftEchoPathModel (BaseModel ):
@@ -470,6 +487,13 @@ def detect_multiple_reflections(
470487 IfftMultiEchoDetectionModel
471488 Direct path and list of echoes with distances in m/ft.
472489 NOTE: `channel_id` must be attached by the caller/orchestrator.
490+
491+ Notes
492+ -----
493+ Echo post-selection enforces:
494+ - forward-lag candidates only (delay bins <= n_fft/2),
495+ - mirror-pair deduplication (k and n_fft-k represent the same delay),
496+ - final output sorted by increasing time/distance.
473497 """
474498 # ensure time response exists (optionally zero-pad)
475499 if n_fft is not None or self ._time_response is None or self ._time_axis is None :
@@ -500,18 +524,38 @@ def detect_multiple_reflections(
500524 local_idxs = _local_maxima_indices (cand_region )
501525 cand_idxs = [start + i for i in local_idxs if cand_region [i ] >= thresh ]
502526
503- # sort by amplitude descending
527+ # sort by amplitude descending (selection priority), then post-sort by time
504528 cand_idxs .sort (key = lambda i : mag [i ], reverse = True )
505529
506530 # enforce minimum separation in bins
507531 min_sep_bins = int (np .ceil (max (0.0 , min_separation_s ) * self .sample_rate ))
508532 selected : list [int ] = []
533+ selected_lags : list [int ] = []
534+ used_canonical_lags : set [int ] = set ()
509535 for i in cand_idxs :
510- if not selected or all (abs (i - j ) >= min_sep_bins for j in selected ):
511- selected .append (i )
536+ lag = int ((i - i0 ) % n )
537+ # Keep only forward delays and drop wrap-around half.
538+ if lag <= 0 or lag > (n // 2 ):
539+ continue
540+
541+ # Mirror dedupe: lag and n-lag represent the same delay.
542+ canonical_lag = int (min (lag , n - lag ))
543+ if canonical_lag in used_canonical_lags :
544+ continue
545+
546+ # Enforce spacing in delay domain.
547+ if selected_lags and any (abs (lag - prev_lag ) < min_sep_bins for prev_lag in selected_lags ):
548+ continue
549+
550+ selected .append (i )
551+ selected_lags .append (lag )
552+ used_canonical_lags .add (canonical_lag )
512553 if len (selected ) >= max_peaks :
513554 break
514555
556+ # Output in physical order (increasing delay/time), not amplitude order.
557+ selected .sort ()
558+
515559 # propagation speed from cable type / override
516560 vf = float (velocity_factor ) if velocity_factor is not None else float (_CABLE_VF [cable_type ])
517561 prop_speed = float (C0 * vf )
@@ -553,7 +597,7 @@ def detect_multiple_reflections(
553597 # NOTE: channel_id is not known to the detector; the caller should stamp it
554598 return IfftMultiEchoDetectionModel (
555599 channel_id = ChannelId (- 1 ), # placeholder; orchestrator must update
556- dataset_info = IfftEchoDetectorDatasetInfo (subcarriers = self .N , snapshots = self .M ),
600+ dataset_info = IfftEchoDetectorDatasetInfo (subcarriers = self .N , captures = self .M ),
557601 sample_rate_hz = float (self .sample_rate ),
558602 complex = COMPLEX_LITERAL , # alias
559603 cable_type = cable_type ,
@@ -590,7 +634,7 @@ def to_model(
590634 if n_fft is not None or self ._time_response is None or self ._time_axis is None :
591635 self .compute_time_response (n_fft = n_fft )
592636
593- dataset = IfftEchoDetectorDatasetInfo (subcarriers = self .N , snapshots = self .M )
637+ dataset = IfftEchoDetectorDatasetInfo (subcarriers = self .N , captures = self .M )
594638
595639 H_snap_pairs : list [ComplexArray ] = self ._mat_to_pairs (self .H_snap )
596640 H_avg_pairs : ComplexArray = self ._vec_to_pairs (self .H_avg )
@@ -616,3 +660,56 @@ def to_model(
616660 H_avg = H_avg_pairs ,
617661 reflection = reflection ,
618662 time_response = tr_block ,)
663+
664+
665+ class WindowedIfftEchoDetector (IfftEchoDetector ):
666+ """
667+ Extension of IfftEchoDetector that preprocesses frequency data before iFFT:
668+ - applies a window (Hann by default) to the averaged frequency vector
669+ - optionally zero-pads to next power-of-two when n_fft is not provided
670+
671+ This class reuses the parent detector's input normalization and reflection logic.
672+ """
673+
674+ def __init__ (
675+ self ,
676+ freq_data : Sequence [complex ] | Sequence [Sequence [complex ]] | Sequence [Sequence [float ]],
677+ sample_rate : float ,
678+ prop_speed_frac : float = 0.87 ,
679+ * ,
680+ window : SignalWindow | str = SignalWindow .HANN ,
681+ auto_pad_to_pow2 : bool = True ,
682+ ) -> None :
683+ super ().__init__ (freq_data = freq_data , sample_rate = sample_rate , prop_speed_frac = prop_speed_frac )
684+ self .window = SignalWindow .coerce (window )
685+ self .auto_pad_to_pow2 = bool (auto_pad_to_pow2 )
686+
687+ def compute_time_response (self , n_fft : int | None = None ) -> tuple [NDArray [np .float64 ], NDArray [np .complex128 ]]:
688+ """
689+ Compute h(t) = IFFT{W(f) * H(f)} with optional zero-padding.
690+
691+ If n_fft is None and auto_pad_to_pow2=True, n_fft defaults to next power-of-two >= N.
692+ """
693+ n_use = int (n_fft ) if n_fft is not None else self .N
694+ if n_fft is None and self .auto_pad_to_pow2 :
695+ n_use = self ._next_power_of_two (self .N )
696+ if n_use < self .N :
697+ raise ValueError (f"n_fft ({ n_use } ) must be >= N ({ self .N } )." )
698+
699+ win = self ._window_values (self .N )
700+ h = np .fft .ifft (self .freq_data * win , n = n_use )
701+ t = np .arange (n_use , dtype = np .float64 ) / self .sample_rate
702+
703+ self ._time_axis = t
704+ self ._time_response = h .astype (np .complex128 , copy = False )
705+ self ._n_fft = n_use
706+ return t , self ._time_response
707+
708+ def _window_values (self , n : int ) -> NDArray [np .float64 ]:
709+ return window_values (n , self .window )
710+
711+ @staticmethod
712+ def _next_power_of_two (n : int ) -> int :
713+ if n < 1 :
714+ raise ValueError ("n must be >= 1" )
715+ return 1 << (n - 1 ).bit_length ()
0 commit comments