Wideband autonomous SDR analysis engine forked from sdr-visual-suite
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

774 lines
26KB

  1. package main
  2. import (
  3. "fmt"
  4. "log"
  5. "math"
  6. "os"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "sdr-wideband-suite/internal/config"
  12. "sdr-wideband-suite/internal/demod/gpudemod"
  13. "sdr-wideband-suite/internal/detector"
  14. "sdr-wideband-suite/internal/dsp"
  15. "sdr-wideband-suite/internal/logging"
  16. "sdr-wideband-suite/internal/telemetry"
  17. )
  18. func mustParseDuration(raw string, fallback time.Duration) time.Duration {
  19. if raw == "" {
  20. return fallback
  21. }
  22. if d, err := time.ParseDuration(raw); err == nil {
  23. return d
  24. }
  25. return fallback
  26. }
  27. func buildDecoderMap(cfg config.Config) map[string]string {
  28. out := map[string]string{}
  29. if cfg.Decoder.FT8Cmd != "" {
  30. out["FT8"] = cfg.Decoder.FT8Cmd
  31. }
  32. if cfg.Decoder.WSPRCmd != "" {
  33. out["WSPR"] = cfg.Decoder.WSPRCmd
  34. }
  35. if cfg.Decoder.DMRCmd != "" {
  36. out["DMR"] = cfg.Decoder.DMRCmd
  37. }
  38. if cfg.Decoder.DStarCmd != "" {
  39. out["D-STAR"] = cfg.Decoder.DStarCmd
  40. }
  41. if cfg.Decoder.FSKCmd != "" {
  42. out["FSK"] = cfg.Decoder.FSKCmd
  43. }
  44. if cfg.Decoder.PSKCmd != "" {
  45. out["PSK"] = cfg.Decoder.PSKCmd
  46. }
  47. return out
  48. }
  49. func decoderKeys(cfg config.Config) []string {
  50. m := buildDecoderMap(cfg)
  51. keys := make([]string, 0, len(m))
  52. for k := range m {
  53. keys = append(keys, k)
  54. }
  55. sort.Strings(keys)
  56. return keys
  57. }
  58. func (m *extractionManager) reset() {
  59. if m == nil {
  60. return
  61. }
  62. m.mu.Lock()
  63. defer m.mu.Unlock()
  64. if m.runner != nil {
  65. m.runner.Close()
  66. m.runner = nil
  67. }
  68. }
  69. func (m *extractionManager) get(sampleCount int, sampleRate int) *gpudemod.BatchRunner {
  70. if m == nil || sampleCount <= 0 || sampleRate <= 0 || !gpudemod.Available() {
  71. return nil
  72. }
  73. m.mu.Lock()
  74. defer m.mu.Unlock()
  75. if m.runner != nil && sampleCount > m.maxSamples {
  76. m.runner.Close()
  77. m.runner = nil
  78. }
  79. if m.runner == nil {
  80. // Allocate generously: enough for full allIQ (sampleRate/10 ≈ 100ms)
  81. // so the runner never needs re-allocation when used for both
  82. // classification (FFT-block ~65k) and streaming (allIQ ~273k+).
  83. allocSize := sampleCount
  84. generous := sampleRate/10 + 1024 // ~400k at 4MHz — covers any scenario
  85. if generous > allocSize {
  86. allocSize = generous
  87. }
  88. if r, err := gpudemod.NewBatchRunner(allocSize, sampleRate); err == nil {
  89. m.runner = r
  90. m.maxSamples = allocSize
  91. } else {
  92. log.Printf("gpudemod: batch runner init failed: %v", err)
  93. }
  94. return m.runner
  95. }
  96. return m.runner
  97. }
  98. func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 {
  99. if len(iq) == 0 || sampleRate <= 0 {
  100. return nil
  101. }
  102. results, _ := extractSignalIQBatch(nil, iq, sampleRate, centerHz, []detector.Signal{{CenterHz: sigHz, BWHz: bwHz}})
  103. if len(results) == 0 {
  104. return nil
  105. }
  106. return results[0]
  107. }
  108. func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleRate int, centerHz float64, signals []detector.Signal) ([][]complex64, []int) {
  109. out := make([][]complex64, len(signals))
  110. rates := make([]int, len(signals))
  111. if len(iq) == 0 || sampleRate <= 0 || len(signals) == 0 {
  112. return out, rates
  113. }
  114. decimTarget := 200000
  115. if decimTarget <= 0 {
  116. decimTarget = sampleRate
  117. }
  118. runner := extractMgr.get(len(iq), sampleRate)
  119. if runner != nil {
  120. jobs := make([]gpudemod.ExtractJob, len(signals))
  121. for i, sig := range signals {
  122. bw := sig.BWHz
  123. sigMHz := sig.CenterHz / 1e6
  124. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  125. jobOutRate := decimTarget
  126. if isWFM {
  127. jobOutRate = wfmStreamOutRate
  128. }
  129. // Minimum extraction BW: ensure enough bandwidth for demod features
  130. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo pilot + RDS at 57kHz
  131. // Also widen for any signal classified as WFM (in case of re-extraction)
  132. if isWFM {
  133. if bw < wfmStreamMinBW {
  134. bw = wfmStreamMinBW
  135. }
  136. } else if bw < 20000 {
  137. bw = 20000
  138. }
  139. jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: jobOutRate}
  140. }
  141. if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
  142. // batch extraction OK (silent)
  143. for i := range gpuOuts {
  144. out[i] = gpuOuts[i]
  145. if i < len(gpuRates) {
  146. rates[i] = gpuRates[i]
  147. }
  148. }
  149. return out, rates
  150. } else if err != nil {
  151. log.Printf("gpudemod: batch extraction failed for %d signals: %v", len(signals), err)
  152. }
  153. }
  154. // CPU extraction fallback (silent — see batch extraction failed above if applicable)
  155. for i, sig := range signals {
  156. offset := sig.CenterHz - centerHz
  157. shifted := dsp.FreqShift(iq, sampleRate, offset)
  158. bw := sig.BWHz
  159. // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo + RDS
  160. sigMHz := sig.CenterHz / 1e6
  161. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  162. if isWFM {
  163. if bw < wfmStreamMinBW {
  164. bw = wfmStreamMinBW
  165. }
  166. } else if bw < 20000 {
  167. bw = 20000
  168. }
  169. cutoff := bw / 2
  170. if cutoff < 200 {
  171. cutoff = 200
  172. }
  173. if cutoff > float64(sampleRate)/2-1 {
  174. cutoff = float64(sampleRate)/2 - 1
  175. }
  176. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  177. filtered := dsp.ApplyFIR(shifted, taps)
  178. decim := sampleRate / decimTarget
  179. if decim < 1 {
  180. decim = 1
  181. }
  182. out[i] = dsp.Decimate(filtered, decim)
  183. rates[i] = sampleRate / decim
  184. }
  185. return out, rates
  186. }
  187. func parseSince(raw string) (time.Time, error) {
  188. if raw == "" {
  189. return time.Time{}, nil
  190. }
  191. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  192. if ms > 1e12 {
  193. return time.UnixMilli(ms), nil
  194. }
  195. return time.Unix(ms, 0), nil
  196. }
  197. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  198. return t, nil
  199. }
  200. return time.Parse(time.RFC3339, raw)
  201. }
  202. // streamExtractState holds per-signal persistent state for phase-continuous
  203. // GPU extraction. Stored in the DSP loop, keyed by signal ID.
  204. type streamExtractState struct {
  205. phase float64 // FreqShift phase accumulator
  206. }
  207. // streamIQOverlap holds the tail of the previous allIQ for FIR halo prepend.
  208. type streamIQOverlap struct {
  209. tail []complex64
  210. }
  211. // extractionConfig holds audio quality settings for signal extraction.
  212. type extractionConfig struct {
  213. firTaps int // AQ-3: FIR tap count (default 101)
  214. bwMult float64 // AQ-5: BW multiplier (default 1.2)
  215. }
  216. const streamOverlapLen = 512 // must be >= FIR tap count with margin
  217. const (
  218. wfmStreamOutRate = 500000
  219. wfmStreamMinBW = 250000
  220. )
  221. var forceCPUStreamExtract = func() bool {
  222. raw := strings.TrimSpace(os.Getenv("SDR_FORCE_CPU_STREAM_EXTRACT"))
  223. if raw == "" {
  224. return false
  225. }
  226. v, err := strconv.ParseBool(raw)
  227. if err != nil {
  228. return false
  229. }
  230. return v
  231. }()
  232. // extractForStreaming performs GPU-accelerated extraction with:
  233. // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
  234. // - IQ overlap prepended to allIQ so FIR kernel has real data in halo
  235. //
  236. // Returns extracted snippets with overlap trimmed, and updates phase state.
  237. func extractForStreaming(
  238. extractMgr *extractionManager,
  239. allIQ []complex64,
  240. sampleRate int,
  241. centerHz float64,
  242. signals []detector.Signal,
  243. phaseState map[int64]*streamExtractState,
  244. overlap *streamIQOverlap,
  245. aqCfg extractionConfig,
  246. coll *telemetry.Collector,
  247. ) ([][]complex64, []int) {
  248. out := make([][]complex64, len(signals))
  249. rates := make([]int, len(signals))
  250. if len(allIQ) == 0 || sampleRate <= 0 || len(signals) == 0 {
  251. return out, rates
  252. }
  253. // AQ-3: Use configured overlap length (must cover FIR taps)
  254. overlapNeeded := streamOverlapLen
  255. if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded {
  256. overlapNeeded = aqCfg.firTaps + 64
  257. }
  258. // Prepend overlap from previous frame so FIR kernel has real halo data
  259. var gpuIQ []complex64
  260. overlapLen := len(overlap.tail)
  261. logging.Debug("extract", "overlap", "len", overlapLen, "needed", overlapNeeded, "allIQ", len(allIQ))
  262. if overlapLen > 0 {
  263. gpuIQ = make([]complex64, overlapLen+len(allIQ))
  264. copy(gpuIQ, overlap.tail)
  265. copy(gpuIQ[overlapLen:], allIQ)
  266. } else {
  267. gpuIQ = allIQ
  268. overlapLen = 0
  269. }
  270. // Save tail for next frame (sized to cover configured FIR taps)
  271. if len(allIQ) > overlapNeeded {
  272. overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...)
  273. } else {
  274. overlap.tail = append(overlap.tail[:0], allIQ...)
  275. }
  276. decimTarget := 200000
  277. // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals)
  278. bwMult := aqCfg.bwMult
  279. if bwMult <= 0 {
  280. bwMult = 1.0
  281. }
  282. if coll != nil {
  283. coll.SetGauge("iq.extract.input.length", float64(len(allIQ)), nil)
  284. coll.SetGauge("iq.extract.input.overlap_length", float64(overlapLen), nil)
  285. headMean, tailMean, boundaryScore, _ := boundaryMetrics(overlap.tail, allIQ, 32)
  286. coll.SetGauge("iq.extract.input.head_mean_mag", headMean, nil)
  287. coll.SetGauge("iq.extract.input.prev_tail_mean_mag", tailMean, nil)
  288. coll.Observe("iq.extract.input.discontinuity_score", boundaryScore, nil)
  289. }
  290. rawBoundary := make(map[int64]boundaryProbeState, len(signals))
  291. trimmedBoundary := make(map[int64]boundaryProbeState, len(signals))
  292. // Build jobs with per-signal phase
  293. jobs := make([]gpudemod.ExtractJob, len(signals))
  294. for i, sig := range signals {
  295. bw := sig.BWHz * bwMult // AQ-5: widen extraction BW
  296. sigMHz := sig.CenterHz / 1e6
  297. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
  298. (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  299. jobOutRate := decimTarget
  300. if isWFM {
  301. jobOutRate = wfmStreamOutRate
  302. if bw < wfmStreamMinBW {
  303. bw = wfmStreamMinBW
  304. }
  305. } else if bw < 20000 {
  306. bw = 20000
  307. }
  308. ps := phaseState[sig.ID]
  309. if ps == nil {
  310. ps = &streamExtractState{}
  311. phaseState[sig.ID] = ps
  312. }
  313. // PhaseStart is where the NEW data begins. But gpuIQ has overlap
  314. // prepended, so the GPU kernel starts processing at the overlap.
  315. // We need to rewind the phase by overlapLen samples so that the
  316. // overlap region gets the correct phase, and the new data region
  317. // starts at ps.phase exactly.
  318. phaseInc := -2.0 * math.Pi * (sig.CenterHz - centerHz) / float64(sampleRate)
  319. gpuPhaseStart := ps.phase - phaseInc*float64(overlapLen)
  320. jobs[i] = gpudemod.ExtractJob{
  321. OffsetHz: sig.CenterHz - centerHz,
  322. BW: bw,
  323. OutRate: jobOutRate,
  324. PhaseStart: gpuPhaseStart,
  325. }
  326. }
  327. // Try GPU BatchRunner with phase unless CPU-only debug is forced.
  328. var runner *gpudemod.BatchRunner
  329. if forceCPUStreamExtract {
  330. logging.Warn("boundary", "force_cpu_stream_extract", "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "signals", len(signals))
  331. } else {
  332. runner = extractMgr.get(len(gpuIQ), sampleRate)
  333. }
  334. if runner != nil {
  335. results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
  336. if err == nil && len(results) == len(signals) {
  337. for i, res := range results {
  338. outRate := res.Rate
  339. if outRate <= 0 {
  340. outRate = decimTarget
  341. }
  342. sigMHz := signals[i].CenterHz / 1e6
  343. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO"))
  344. if isWFM {
  345. outRate = wfmStreamOutRate
  346. }
  347. decim := sampleRate / outRate
  348. if decim < 1 {
  349. decim = 1
  350. }
  351. trimSamples := (overlapLen + decim - 1) / decim
  352. if i == 0 {
  353. logging.Debug("extract", "gpu_result", "rate", res.Rate, "outRate", outRate, "decim", decim, "trim", trimSamples)
  354. }
  355. // Update phase state — advance only by NEW data length, not overlap
  356. phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
  357. phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
  358. // Normalize to [-π, π) to prevent float64 drift over long runs
  359. phaseState[signals[i].ID].phase = math.Remainder(phaseState[signals[i].ID].phase, 2*math.Pi)
  360. // Trim overlap from output
  361. iq := res.IQ
  362. rawLen := len(iq)
  363. if trimSamples > 0 && trimSamples < len(iq) {
  364. iq = iq[trimSamples:]
  365. }
  366. if i == 0 {
  367. logging.Debug("boundary", "extract_trim", "path", "gpu", "raw_len", rawLen, "trim", trimSamples, "out_len", len(iq), "overlap_len", overlapLen, "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "outRate", outRate, "signal", signals[i].ID)
  368. logExtractorHeadComparison(signals[i].ID, "gpu", overlapLen, res.IQ, trimSamples, iq)
  369. }
  370. if coll != nil {
  371. tags := telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", signals[i].ID), "path", "gpu")
  372. stats := computeIQHeadStats(iq, 64)
  373. coll.SetGauge("iq.extract.output.length", float64(len(iq)), tags)
  374. coll.Observe("iq.extract.output.head_mean_mag", stats.meanMag, tags)
  375. coll.Observe("iq.extract.output.head_min_mag", stats.minMag, tags)
  376. coll.Observe("iq.extract.output.head_max_step", stats.maxStep, tags)
  377. coll.Observe("iq.extract.output.head_p95_step", stats.p95Step, tags)
  378. coll.Observe("iq.extract.output.head_tail_ratio", stats.headTail, tags)
  379. coll.SetGauge("iq.extract.output.head_low_magnitude_count", float64(stats.lowMag), tags)
  380. coll.SetGauge("iq.extract.raw.length", float64(rawLen), tags)
  381. coll.SetGauge("iq.extract.trim.trim_samples", float64(trimSamples), tags)
  382. if rawLen > 0 {
  383. coll.SetGauge("iq.extract.raw.head_mag", math.Hypot(float64(real(res.IQ[0])), float64(imag(res.IQ[0]))), tags)
  384. coll.SetGauge("iq.extract.raw.tail_mag", math.Hypot(float64(real(res.IQ[rawLen-1])), float64(imag(res.IQ[rawLen-1]))), tags)
  385. rawHead := probeHead(res.IQ, 16, 1e-6)
  386. coll.SetGauge("iq.extract.raw.head_zero_count", float64(rawHead.zeroCount), tags)
  387. coll.SetGauge("iq.extract.raw.first_nonzero_index", float64(rawHead.firstNonZeroIndex), tags)
  388. coll.SetGauge("iq.extract.raw.head_max_step", rawHead.maxStep, tags)
  389. coll.Event("extract_raw_head_probe", "info", "raw extractor head probe", tags, map[string]any{
  390. "mags": rawHead.mags,
  391. "zero_count": rawHead.zeroCount,
  392. "first_nonzero_index": rawHead.firstNonZeroIndex,
  393. "head_max_step": rawHead.maxStep,
  394. "trim_samples": trimSamples,
  395. })
  396. }
  397. if len(iq) > 0 {
  398. coll.SetGauge("iq.extract.trimmed.head_mag", math.Hypot(float64(real(iq[0])), float64(imag(iq[0]))), tags)
  399. coll.SetGauge("iq.extract.trimmed.tail_mag", math.Hypot(float64(real(iq[len(iq)-1])), float64(imag(iq[len(iq)-1]))), tags)
  400. trimmedHead := probeHead(iq, 16, 1e-6)
  401. coll.SetGauge("iq.extract.trimmed.head_zero_count", float64(trimmedHead.zeroCount), tags)
  402. coll.SetGauge("iq.extract.trimmed.first_nonzero_index", float64(trimmedHead.firstNonZeroIndex), tags)
  403. coll.SetGauge("iq.extract.trimmed.head_max_step", trimmedHead.maxStep, tags)
  404. coll.Event("extract_trimmed_head_probe", "info", "trimmed extractor head probe", tags, map[string]any{
  405. "mags": trimmedHead.mags,
  406. "zero_count": trimmedHead.zeroCount,
  407. "first_nonzero_index": trimmedHead.firstNonZeroIndex,
  408. "head_max_step": trimmedHead.maxStep,
  409. "trim_samples": trimSamples,
  410. })
  411. }
  412. if rb := rawBoundary[signals[i].ID]; rb.set && rawLen > 0 {
  413. prevMag := math.Hypot(float64(real(rb.last)), float64(imag(rb.last)))
  414. currMag := math.Hypot(float64(real(res.IQ[0])), float64(imag(res.IQ[0])))
  415. coll.SetGauge("iq.extract.raw.boundary.prev_tail_mag", prevMag, tags)
  416. coll.SetGauge("iq.extract.raw.boundary.curr_head_mag", currMag, tags)
  417. coll.Event("extract_raw_boundary", "info", "raw extractor boundary", tags, map[string]any{
  418. "delta_mag": math.Abs(currMag - prevMag),
  419. "trim_samples": trimSamples,
  420. "raw_len": rawLen,
  421. })
  422. }
  423. if tb := trimmedBoundary[signals[i].ID]; tb.set && len(iq) > 0 {
  424. prevMag := math.Hypot(float64(real(tb.last)), float64(imag(tb.last)))
  425. currMag := math.Hypot(float64(real(iq[0])), float64(imag(iq[0])))
  426. coll.SetGauge("iq.extract.trimmed.boundary.prev_tail_mag", prevMag, tags)
  427. coll.SetGauge("iq.extract.trimmed.boundary.curr_head_mag", currMag, tags)
  428. coll.Event("extract_trimmed_boundary", "info", "trimmed extractor boundary", tags, map[string]any{
  429. "delta_mag": math.Abs(currMag - prevMag),
  430. "trim_samples": trimSamples,
  431. "out_len": len(iq),
  432. })
  433. }
  434. }
  435. if rawLen > 0 {
  436. rawBoundary[signals[i].ID] = boundaryProbeState{last: res.IQ[rawLen-1], set: true}
  437. }
  438. if len(iq) > 0 {
  439. trimmedBoundary[signals[i].ID] = boundaryProbeState{last: iq[len(iq)-1], set: true}
  440. }
  441. out[i] = iq
  442. rates[i] = res.Rate
  443. }
  444. return out, rates
  445. } else if err != nil {
  446. log.Printf("gpudemod: stream batch extraction failed: %v", err)
  447. }
  448. }
  449. // CPU fallback (with phase tracking)
  450. for i, sig := range signals {
  451. offset := sig.CenterHz - centerHz
  452. bw := jobs[i].BW
  453. ps := phaseState[sig.ID]
  454. // Phase-continuous FreqShift — rewind by overlap so new data starts at ps.phase
  455. shifted := make([]complex64, len(gpuIQ))
  456. inc := -2.0 * math.Pi * offset / float64(sampleRate)
  457. phase := ps.phase - inc*float64(overlapLen)
  458. for k, v := range gpuIQ {
  459. phase += inc
  460. re := math.Cos(phase)
  461. im := math.Sin(phase)
  462. shifted[k] = complex(
  463. float32(float64(real(v))*re-float64(imag(v))*im),
  464. float32(float64(real(v))*im+float64(imag(v))*re),
  465. )
  466. }
  467. // Advance phase by NEW data length only
  468. ps.phase += inc * float64(len(allIQ))
  469. ps.phase = math.Remainder(ps.phase, 2*math.Pi)
  470. cutoff := bw / 2
  471. if cutoff < 200 {
  472. cutoff = 200
  473. }
  474. if cutoff > float64(sampleRate)/2-1 {
  475. cutoff = float64(sampleRate)/2 - 1
  476. }
  477. firTaps := 101
  478. if aqCfg.firTaps > 0 {
  479. firTaps = aqCfg.firTaps
  480. }
  481. taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
  482. filtered := dsp.ApplyFIR(shifted, taps)
  483. sigMHz := sig.CenterHz / 1e6
  484. isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
  485. outRate := decimTarget
  486. if isWFM {
  487. outRate = wfmStreamOutRate
  488. }
  489. decim := sampleRate / outRate
  490. if decim < 1 {
  491. decim = 1
  492. }
  493. decimated := dsp.Decimate(filtered, decim)
  494. rates[i] = sampleRate / decim
  495. // Trim overlap — use ceil to ensure ALL overlap samples are removed.
  496. // Floor trim (overlapLen/decim) leaves a remainder for non-divisible
  497. // factors (e.g. 512/20=25 trims only 500 of 512 samples → 12 leak).
  498. trimSamples := (overlapLen + decim - 1) / decim
  499. if i == 0 {
  500. logging.Debug("extract", "cpu_result", "outRate", outRate, "decim", decim, "trim", trimSamples)
  501. }
  502. rawIQ := decimated
  503. rawLen := len(rawIQ)
  504. if trimSamples > 0 && trimSamples < len(decimated) {
  505. decimated = decimated[trimSamples:]
  506. }
  507. if i == 0 {
  508. logging.Debug("boundary", "extract_trim", "path", "cpu", "raw_len", rawLen, "trim", trimSamples, "out_len", len(decimated), "overlap_len", overlapLen, "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "outRate", outRate, "signal", signals[i].ID)
  509. logExtractorHeadComparison(signals[i].ID, "cpu", overlapLen, decimated, trimSamples, decimated)
  510. }
  511. if coll != nil {
  512. tags := telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", signals[i].ID), "path", "cpu")
  513. stats := computeIQHeadStats(decimated, 64)
  514. coll.SetGauge("iq.extract.output.length", float64(len(decimated)), tags)
  515. coll.Observe("iq.extract.output.head_mean_mag", stats.meanMag, tags)
  516. coll.Observe("iq.extract.output.head_min_mag", stats.minMag, tags)
  517. coll.Observe("iq.extract.output.head_max_step", stats.maxStep, tags)
  518. coll.Observe("iq.extract.output.head_p95_step", stats.p95Step, tags)
  519. coll.Observe("iq.extract.output.head_tail_ratio", stats.headTail, tags)
  520. coll.SetGauge("iq.extract.output.head_low_magnitude_count", float64(stats.lowMag), tags)
  521. coll.SetGauge("iq.extract.raw.length", float64(rawLen), tags)
  522. coll.SetGauge("iq.extract.trim.trim_samples", float64(trimSamples), tags)
  523. if rb := rawBoundary[signals[i].ID]; rb.set && rawLen > 0 {
  524. observeBoundarySample(coll, "iq.extract.raw.boundary", tags, rb.last, rawIQ[0])
  525. }
  526. if tb := trimmedBoundary[signals[i].ID]; tb.set && len(decimated) > 0 {
  527. observeBoundarySample(coll, "iq.extract.trimmed.boundary", tags, tb.last, decimated[0])
  528. }
  529. }
  530. if rawLen > 0 {
  531. rawBoundary[signals[i].ID] = boundaryProbeState{last: rawIQ[rawLen-1], set: true}
  532. }
  533. if len(decimated) > 0 {
  534. trimmedBoundary[signals[i].ID] = boundaryProbeState{last: decimated[len(decimated)-1], set: true}
  535. }
  536. out[i] = decimated
  537. }
  538. return out, rates
  539. }
  540. type iqHeadStats struct {
  541. length int
  542. minMag float64
  543. maxMag float64
  544. meanMag float64
  545. lowMag int
  546. maxStep float64
  547. maxStepIdx int
  548. p95Step float64
  549. headTail float64
  550. headMinIdx int
  551. stepSamples []float64
  552. }
  553. type boundaryProbeState struct {
  554. last complex64
  555. set bool
  556. }
  557. type headProbe struct {
  558. zeroCount int
  559. firstNonZeroIndex int
  560. maxStep float64
  561. mags []float64
  562. }
  563. func probeHead(samples []complex64, n int, zeroThreshold float64) headProbe {
  564. if n <= 0 || len(samples) == 0 {
  565. return headProbe{firstNonZeroIndex: -1}
  566. }
  567. if len(samples) < n {
  568. n = len(samples)
  569. }
  570. if zeroThreshold <= 0 {
  571. zeroThreshold = 1e-6
  572. }
  573. out := headProbe{firstNonZeroIndex: -1, mags: make([]float64, 0, n)}
  574. for i := 0; i < n; i++ {
  575. v := samples[i]
  576. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  577. out.mags = append(out.mags, mag)
  578. if mag <= zeroThreshold {
  579. out.zeroCount++
  580. } else if out.firstNonZeroIndex < 0 {
  581. out.firstNonZeroIndex = i
  582. }
  583. if i > 0 {
  584. p := samples[i-1]
  585. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  586. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  587. step := math.Abs(math.Atan2(num, den))
  588. if step > out.maxStep {
  589. out.maxStep = step
  590. }
  591. }
  592. }
  593. return out
  594. }
  595. func observeBoundarySample(coll *telemetry.Collector, metricPrefix string, tags map[string]string, prev complex64, curr complex64) {
  596. prevMag := math.Hypot(float64(real(prev)), float64(imag(prev)))
  597. currMag := math.Hypot(float64(real(curr)), float64(imag(curr)))
  598. deltaMag := math.Abs(currMag - prevMag)
  599. num := float64(real(prev))*float64(imag(curr)) - float64(imag(prev))*float64(real(curr))
  600. den := float64(real(prev))*float64(real(curr)) + float64(imag(prev))*float64(imag(curr))
  601. deltaPhase := math.Abs(math.Atan2(num, den))
  602. d2 := float64(real(curr-prev))*float64(real(curr-prev)) + float64(imag(curr-prev))*float64(imag(curr-prev))
  603. coll.Observe(metricPrefix+".delta_mag", deltaMag, tags)
  604. coll.Observe(metricPrefix+".delta_phase", deltaPhase, tags)
  605. coll.Observe(metricPrefix+".d2", d2, tags)
  606. coll.Observe(metricPrefix+".discontinuity_score", deltaMag+deltaPhase, tags)
  607. }
  608. func computeIQHeadStats(iq []complex64, headLen int) iqHeadStats {
  609. stats := iqHeadStats{minMag: math.MaxFloat64, headMinIdx: -1, maxStepIdx: -1}
  610. if len(iq) == 0 {
  611. stats.minMag = 0
  612. return stats
  613. }
  614. n := len(iq)
  615. if headLen > 0 && headLen < n {
  616. n = headLen
  617. }
  618. stats.length = n
  619. stats.stepSamples = make([]float64, 0, max(0, n-1))
  620. sumMag := 0.0
  621. headSum := 0.0
  622. tailSum := 0.0
  623. tailCount := 0
  624. for i := 0; i < n; i++ {
  625. v := iq[i]
  626. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  627. if mag < stats.minMag {
  628. stats.minMag = mag
  629. stats.headMinIdx = i
  630. }
  631. if mag > stats.maxMag {
  632. stats.maxMag = mag
  633. }
  634. sumMag += mag
  635. if mag < 0.05 {
  636. stats.lowMag++
  637. }
  638. if i < min(16, n) {
  639. headSum += mag
  640. }
  641. if i >= max(0, n-16) {
  642. tailSum += mag
  643. tailCount++
  644. }
  645. if i > 0 {
  646. p := iq[i-1]
  647. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  648. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  649. step := math.Abs(math.Atan2(num, den))
  650. if step > stats.maxStep {
  651. stats.maxStep = step
  652. stats.maxStepIdx = i - 1
  653. }
  654. stats.stepSamples = append(stats.stepSamples, step)
  655. }
  656. }
  657. stats.meanMag = sumMag / float64(n)
  658. if len(stats.stepSamples) > 0 {
  659. sorted := append([]float64(nil), stats.stepSamples...)
  660. sort.Float64s(sorted)
  661. idx := int(float64(len(sorted)-1) * 0.95)
  662. stats.p95Step = sorted[idx]
  663. } else {
  664. stats.p95Step = stats.maxStep
  665. }
  666. if headSum > 0 && tailCount > 0 {
  667. headMean := headSum / float64(min(16, n))
  668. tailMean := tailSum / float64(tailCount)
  669. if tailMean > 0 {
  670. stats.headTail = headMean / tailMean
  671. }
  672. }
  673. return stats
  674. }
  675. func observeIQStats(coll *telemetry.Collector, stage string, iq []complex64, tags telemetry.Tags) {
  676. if coll == nil || len(iq) == 0 {
  677. return
  678. }
  679. stats := computeIQHeadStats(iq, len(iq))
  680. stageTags := telemetry.TagsWith(tags, "stage", stage)
  681. coll.Observe("iq.magnitude.min", stats.minMag, stageTags)
  682. coll.Observe("iq.magnitude.max", stats.maxMag, stageTags)
  683. coll.Observe("iq.magnitude.mean", stats.meanMag, stageTags)
  684. coll.Observe("iq.phase_step.max", stats.maxStep, stageTags)
  685. coll.Observe("iq.phase_step.p95", stats.p95Step, stageTags)
  686. coll.Observe("iq.low_magnitude.count", float64(stats.lowMag), stageTags)
  687. coll.SetGauge("iq.length", float64(stats.length), stageTags)
  688. }
  689. func logExtractorHeadComparison(signalID int64, path string, overlapLen int, raw []complex64, trimSamples int, out []complex64) {
  690. rawStats := computeIQHeadStats(raw, 96)
  691. trimmedStats := computeIQHeadStats(out, 96)
  692. logging.Debug("boundary", "extract_head_compare",
  693. "signal", signalID,
  694. "path", path,
  695. "raw_len", len(raw),
  696. "trim", trimSamples,
  697. "out_len", len(out),
  698. "overlap_len", overlapLen,
  699. "raw_min_mag", rawStats.minMag,
  700. "raw_min_idx", rawStats.headMinIdx,
  701. "raw_max_step", rawStats.maxStep,
  702. "raw_max_step_idx", rawStats.maxStepIdx,
  703. "raw_head_tail", rawStats.headTail,
  704. "trimmed_min_mag", trimmedStats.minMag,
  705. "trimmed_min_idx", trimmedStats.headMinIdx,
  706. "trimmed_max_step", trimmedStats.maxStep,
  707. "trimmed_max_step_idx", trimmedStats.maxStepIdx,
  708. "trimmed_head_tail", trimmedStats.headTail,
  709. )
  710. for _, off := range []int{2, 4, 8, 16} {
  711. if len(out) <= off+8 {
  712. continue
  713. }
  714. offStats := computeIQHeadStats(out[off:], 96)
  715. logging.Debug("boundary", "extract_head_offset_compare",
  716. "signal", signalID,
  717. "path", path,
  718. "offset", off,
  719. "base_min_mag", trimmedStats.minMag,
  720. "base_min_idx", trimmedStats.headMinIdx,
  721. "base_max_step", trimmedStats.maxStep,
  722. "base_max_step_idx", trimmedStats.maxStepIdx,
  723. "offset_min_mag", offStats.minMag,
  724. "offset_min_idx", offStats.headMinIdx,
  725. "offset_max_step", offStats.maxStep,
  726. "offset_max_step_idx", offStats.maxStepIdx,
  727. "offset_head_tail", offStats.headTail,
  728. )
  729. }
  730. }