Skip to content

v2.1.1: Add powerSpectralDensity, fix Accelerate 4× scaling bug#5

Merged
jpurnell merged 2 commits intomainfrom
feature/psd-normalization
Apr 7, 2026
Merged

v2.1.1: Add powerSpectralDensity, fix Accelerate 4× scaling bug#5
jpurnell merged 2 commits intomainfrom
feature/psd-normalization

Conversation

@jpurnell
Copy link
Copy Markdown
Owner

@jpurnell jpurnell commented Apr 7, 2026

Summary

Adds normalized power spectral density (PSD) to FFTBackend and fixes a pre-existing 4× scaling bug in AccelerateFFTBackend.powerSpectrum that was uncovered while implementing PSD.

Driven by the BioFeedbackKit project, which needs physically meaningful spectral magnitudes (ms² for HRV LF/HF analysis) without reinventing FFT normalization in every downstream consumer.

Changes (2 commits)

  1. Fix AccelerateFFTBackend 4× power scaling bugvDSP_fft_zripD returns FFT outputs scaled by 2 vs the textbook DFT formula. Squaring magnitudes produced |X[k]|² that was 4× too large on Darwin. Fix: ×0.25 correction in the power computation. The existing cross-backend test only checked peak bin location, so the discrepancy was invisible.
  2. Add powerSpectralDensity(_:sampleRate:) to FFTBackend — new protocol method with default implementation in an extension. Returns one-sided PSD in units²/Hz; integral over frequency equals the time-domain variance per Parseval's theorem. Uses the unpadded signal length M for normalization, so PSD values are correct regardless of zero-padding. Also adds powerSpectralDensityBins(_:sampleRate:) convenience and the PSDBin value type.

Test plan

  • 12 new tests in PowerSpectralDensityTests.swift cover Parseval on multiple fixtures, the M-vs-N zero-padding edge case (M=50 padded to 64), DC and Nyquist edge factors, and cross-backend equivalence
  • Validation playground at Tests/Validation/PSD_Validation.swift verifies formulas independently against Parseval's theorem before any package code runs
  • Full BusinessMath test suite: 4720/4720 passing (4708 baseline + 12 new)
  • Zero compiler warnings
  • Cross-backend equivalence: PureSwift and Accelerate now produce identical PSDs to within 1e-9 relative tolerance; both satisfy Parseval to machine precision

Non-breaking

Purely additive at the public API level. The existing powerSpectrum(_:) signature is unchanged. Consumers of AccelerateFFTBackend that were comparing absolute spectrum magnitudes against external references were previously wrong by 4×; they should see corrected (smaller by 4×) values after this fix.

Findings surfaced for separate handling

  • Pre-existing flaky test PortfolioUtilitiesTests.Random returns are within reasonable range uses unseeded randomness, violating the mandatory TDD rule in 09_TEST_DRIVEN_DEVELOPMENT.md. Observed one failure during development runs. Worth its own PR with a seeded RNG.
  • Pre-existing accelerateMatchesPureSwift test only checks peak bin location. After this fix, it could be tightened to assert absolute equivalence. Worth its own PR.

🤖 Generated with Claude Code

jpurnell and others added 2 commits April 7, 2026 06:37
vDSP_fft_zripD returns FFT outputs scaled by 2 vs the textbook DFT
formula (vDSP convention for packed real-input FFT). Squaring magnitudes
therefore produced values 4× the textbook |X[k]|² on Darwin only,
breaking absolute-power analysis (Parseval's theorem, PSD integration,
band power, etc.).

The pre-existing cross-backend test only checked peak bin location, not
absolute magnitudes, so the discrepancy was invisible. The new
PowerSpectralDensityTests suite (added in the next commit) is the lever
that surfaced it.

Fix: apply ×0.25 correction to all DC, Nyquist, and typical bins in
AccelerateFFTBackend.powerSpectrum. Both backends now produce identical
absolute power values to within machine precision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a normalized one-sided power spectral density method to the
FFTBackend protocol so downstream consumers (BioFeedbackKit, etc.) get
physically meaningful spectral values directly without reinventing
normalization.

API additions:
- FFTBackend.powerSpectralDensity(_:sampleRate:) — one-sided PSD in
  units²/Hz. Default implementation in an extension; all backends
  inherit for free.
- FFTBackend.powerSpectralDensityBins(_:sampleRate:) — convenience that
  pairs each PSD value with its center frequency in Hz.
- PSDBin value type — Sendable, Equatable, (frequency: Double, power: Double).

Normalization correctness:
- One-sided spectrum: DC and Nyquist bins use edge factor 1/(M·fs);
  typical bins use 2/(M·fs).
- Uses the UNPADDED signal length M, not the internally zero-padded
  length N. This ensures the PSD integral equals the time-domain
  variance per Parseval's theorem regardless of input length. Previously
  every downstream consumer had to know about this gotcha.

Testing:
- 12 new tests in PowerSpectralDensityTests.swift covering Parseval on
  pure sines, two-tone signals, the M=50→64 zero-padding edge case, DC
  and Nyquist bin handling, cross-backend equivalence (PureSwift vs
  Accelerate, both now satisfy Parseval after the previous commit's
  scaling fix), empty/invalid input handling, and PSDBin convenience.
- Validation playground at Tests/Validation/PSD_Validation.swift —
  standalone hand-rolled implementation that verifies the formulas
  against Parseval's theorem independent of BusinessMath, producing the
  exact values the test suite asserts.
- Full BusinessMath suite: 4720/4720 passing, zero compiler warnings.

Purely additive — no breaking changes. Existing powerSpectrum(_:)
signature is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jpurnell jpurnell merged commit 5db92ff into main Apr 7, 2026
4 checks passed
@jpurnell jpurnell deleted the feature/psd-normalization branch April 7, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant