Skip to content

Commit 14dcb48

Browse files
committed
erge branch 'Echo-Detection-RealValues'
2 parents 865b0f5 + 92f7a3f commit 14dcb48

12 files changed

Lines changed: 586 additions & 21 deletions

File tree

docs/api/fast-api/multi/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Use these guides when you need periodic or scheduled captures (for example, hour
2626
| Module | Purpose |
2727
|--------|---------|
2828
| [Multi-RxMER min/avg/max](analysis/multi-rxmer-min-avg-max.md) | Roll up RxMER across captures. |
29+
| [Multi-RxMER echo reflection](multi-capture-rxmer.md#5-analysis) | Detect reflection/echo candidates from trend-removed averaged RxMER via IFFT. |
2930
| [Multi-ChanEst min/avg/max](analysis/multi-chanest-min-avg-max.md) | Summaries for channel estimation data. |
3031
| [Group delay calculator](analysis/group-delay-calculator.md) | Compute group delay variations. |
3132
| [OFDM performance 1:1](analysis/multi-rxmer-ofdm-performance-part-1.md) | Compare per-subcarrier capacity vs profile. |

docs/api/fast-api/multi/multi-capture-rxmer.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ When `pnm_parameters.capture.channel_ids` is omitted or empty, the capture inclu
9696

9797
| Measure Mode | Suited Analyses | Processes |
9898
| ------------------- | -------------------------------------------------------------- | ---------------------------------------- |
99-
| `0` | `min-avg-max`, `rxmer-heat-map` | RxMER |
100-
| `1` | `ofdm-profile-performance-1`, `min-avg-max`, `rxmer-heat-map` | RxMER + Modulation Profile + FEC Summary |
99+
| `0` | `min-avg-max`, `rxmer-heat-map`, `echo-reflection-1` | RxMER |
100+
| `1` | `ofdm-profile-performance-1`, `min-avg-max`, `rxmer-heat-map`, `echo-reflection-1` | RxMER + Modulation Profile + FEC Summary |
101101

102102
> Use `mode=1` when you specifically want OFDM performance context; otherwise `mode=0` is recommended for continuous monitoring.
103103
@@ -223,6 +223,7 @@ aabbccddeeff_lpet3_1763007737_160_rxmer_heat_map.png
223223
| `min-avg-max` | Min/Avg/Max RxMER across samples | `0` or `1` |
224224
| `rxmer-heat-map` | Time × Frequency heatmap grid | `0` or `1` |
225225
| `ofdm-profile-performance-1` | Per‑subcarrier performance metrics | `1` |
226+
| `echo-reflection-1` | IFFT echo/reflection detection from averaged RxMER trend-removed input | `0` or `1` |
226227

227228
**Output Types** (`analysis.output.type`)
228229

@@ -327,3 +328,20 @@ These keys appear under the `data` object of `MultiRxMerAnalysisResponse`. Per
327328
| `profiles[].fec_summary.summary[].summary.total_codewords` | int | Total FEC codewords counted. |
328329
| `profiles[].fec_summary.summary[].summary.corrected` | int | FEC corrected codewords. |
329330
| `profiles[].fec_summary.summary[].summary.uncorrectable` | int | Uncorrectable codewords. |
331+
| `rxmer.frequency` | array[int] (Hz) | Per-subcarrier center frequencies used for `echo-reflection-1` input. |
332+
| `rxmer.avg` | array[float] (dB) | Average RxMER across captures before preprocessing. |
333+
| `rxmer.avg_preprocessed` | array[float] (dB, detrended) | Average RxMER after trend removal, used by the echo detector. |
334+
| `echo_report.dataset_info.subcarriers` | int | Number of frequency bins in the detection input. |
335+
| `echo_report.dataset_info.captures` | int | Number of captures used to build the per-channel average. |
336+
| `echo_report.direct_path.*` | object | Direct-path peak (bin/time/amplitude and 0-distance reference). |
337+
| `echo_report.echoes[]` | array[object] | Detected echoes with bin/time/amplitude/distance. |
338+
| `echo_report.cable_type` / `echo_report.velocity_factor` | string / float | Cable model and VF used for delay-to-distance conversion. |
339+
| `echo_report.time_response` | object or `null` | Optional IFFT time-domain block used for troubleshooting/visualization. |
340+
341+
#### Echo Reflection Detection Notes
342+
343+
For `echo-reflection-1`, echo candidates are normalized for physical interpretation:
344+
345+
1. Only forward-delay lags are kept (`lag <= n_fft/2`).
346+
2. Mirror FFT pairs (`k` and `n_fft-k`) are deduplicated.
347+
3. Output echoes are sorted by increasing delay/distance (not by amplitude).

src/pypnm/api/routes/advance/analysis/signal_analysis/detection/echo/ifft.py

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@
88

99
import numpy as np
1010
from 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

1320
from pypnm.lib.constants import FEET_PER_METER, SPEED_OF_LIGHT, CableTypes
21+
from pypnm.lib.signal_processing.window import SignalWindow, window_values
1422
from 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+
5572
class 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

184201
class 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

Comments
 (0)