Skip to content

Commit 0ca7e98

Browse files
gabrielfrasantosclaudegithub-actions[bot]
authored
fix: resolve issues 251, 252, and 253 (#287)
* fix(analysis,control_analysis,math): resolve issues 251, 252, 253 and add coverage - #251: rename DiscreteConsineTransform to DiscreteCosineTransform; fix forward scale from 2/√N to √(2/N) and inverse scale from √N/2 to √N/√2 to produce the orthonormal DCT-II; update doc and two reference-value tests; add Parseval identity test; add OPTIMIZE_FOR_SPEED to Inverse - #252: multiply interior bins (1..N/2-1) by 2 in PowerSpectralDensity::Calculate to correct 3 dB under-read in one-sided PSD; add FftStubInteriorBin and ratio test verifying the doubling factor - #253: add missing D·charPoly[k] term in TransferFunctionStateSpace::ToTransferFunction so systems with nonzero feedthrough return correct numerator coefficients; add round-trip and DC-gain tests for improper transfer functions - Add TestTolerance.cpp with five TEST_F cases covering float, Q31, Q15 - Add seven TYPED_TEST cases to TestQNumber covering zero construction, sign rules, double negation, and compound-divide-by-zero death - Add six TEST_F cases to TestFrequencyResponse covering size, phase, and DC-gain properties across Biquad, Highpass, FirstOrderIir, PureDelay, and Lowpass fixtures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent c8b2636 commit 0ca7e98

13 files changed

Lines changed: 330 additions & 41 deletions

doc/analysis/DiscreteCosineTransform.md

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,25 @@ This library implements **DCT-II** (the most common variant) by leveraging the F
1010

1111
### DCT-II Definition
1212

13-
For a length-$N$ sequence $x[n]$, the DCT-II is defined as:
13+
This implementation uses the **orthonormal** DCT-II. For a length-$N$ sequence $x[n]$:
1414

15-
$$X[k] = \sum_{n=0}^{N-1} x[n] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right), \quad k = 0, 1, \ldots, N-1$$
15+
$$X[0] = \frac{1}{\sqrt{N}} \sum_{n=0}^{N-1} x[n]$$
1616

17-
The inverse (DCT-III, also called IDCT) recovers $x[n]$:
17+
$$X[k] = \sqrt{\frac{2}{N}} \sum_{n=0}^{N-1} x[n] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right), \quad k = 1, \ldots, N-1$$
1818

19-
$$x[n] = \frac{1}{N}\left[\frac{X[0]}{2} + \sum_{k=1}^{N-1} X[k] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right)\right]$$
19+
This normalisation makes the transform matrix unitary, so Parseval's theorem holds exactly: $\sum_k X[k]^2 = \sum_n x[n]^2$. The inverse (orthonormal DCT-III) recovers $x[n]$:
20+
21+
$$x[n] = \frac{X[0]}{\sqrt{N}} + \sqrt{\frac{2}{N}} \sum_{k=1}^{N-1} X[k] \cos\!\left(\frac{\pi}{N}\left(n + \tfrac{1}{2}\right) k\right)$$
2022

2123
### FFT-Based Computation
2224

2325
The DCT can be computed via the DFT of a reordered sequence:
2426

2527
1. Form a new sequence $y[n]$ by interleaving even-indexed and reverse odd-indexed samples of $x$.
2628
2. Compute the $N$-point FFT: $Y[k] = \text{FFT}\{y\}$.
27-
3. Apply twiddle factors: $X[k] = 2 \cdot \text{Re}\!\left(W[k] \cdot Y[k]\right)$, where $W[k] = e^{-j\pi k / 2N}$.
29+
3. Apply twiddle factors and orthonormal scale:
30+
- $k = 0$: $X[0] = \text{Re}(Y[0]) / \sqrt{N}$
31+
- $k \ge 1$: $X[k] = \sqrt{2/N} \cdot \text{Re}\!\left(W[k] \cdot Y[k]\right)$, where $W[k] = e^{-j\pi k / 2N}$.
2832

2933
### Why Cosine Basis?
3034

@@ -50,25 +54,25 @@ $$y = [x[0],\; x[2],\; x[3],\; x[1]] = [1, 3, 4, 2]$$
5054

5155
**Step 2 — Compute FFT**
5256

53-
$$Y = \text{FFT}([1, 3, 4, 2]) = [10,\; -3+j,\; -2,\; -3-j]$$
57+
$$Y = \text{FFT}([1, 3, 4, 2]) = [10,\; -3-j,\; 0,\; -3+j]$$
5458

55-
**Step 3 — Apply twiddle factors** $W[k] = e^{-j\pi k/8}$
59+
**Step 3 — Apply twiddle factors and orthonormal scale** ($N=4$, $W[k] = e^{-j\pi k/8}$)
5660

57-
| $k$ | $W[k]$ | $W[k] \cdot Y[k]$ | $X[k] = 2 \cdot \text{Re}(\cdot)$ |
58-
|-----|----------------|-------------------|-----------------------------------|
59-
| 0 | 1 | 10 | 20 |
60-
| 1 | $e^{-j\pi/8}$ | ≈ −2.22 − 1.90j | ≈ −4.44 |
61-
| 2 | $e^{-j\pi/4}$ | ≈ −1.41 + 1.41j | ≈ −2.83 |
62-
| 3 | $e^{-j3\pi/8}$ | ≈ −0.24 + 3.07j | ≈ −0.47 |
61+
| $k$ | $W[k]$ | $W[k] \cdot Y[k]$ | $X[k]$ |
62+
|-----|----------------|---------------------------|-------------------------------------------|
63+
| 0 | | | $10 / \sqrt{4} = 5$ |
64+
| 1 | $e^{-j\pi/8}$ | $\approx -3.154 + 0.224j$ | $\sqrt{0.5} \cdot (-3.154) \approx -2.23$ |
65+
| 2 | $e^{-j\pi/4}$ | $0$ | $0$ |
66+
| 3 | $e^{-j3\pi/8}$ | $\approx -0.224 + 3.154j$ | $\sqrt{0.5} \cdot (-0.224) \approx -0.16$ |
6367

64-
**Output:** $X \approx [20, -4.44, -2.83, -0.47]$
68+
**Output:** $X \approx [5,\; -2.23,\; 0,\; -0.16]$
6569

66-
Notice how most of the energy is in $X[0]$ (the DC component) — energy compaction in action.
70+
Notice how most of the energy is in $X[0]$ (the DC component) — energy compaction in action. The orthonormal scale ensures $\sum_k X[k]^2 = 1^2+2^2+3^2+4^2 = 30$.
6771

6872
## Pitfalls & Edge Cases
6973

7074
- **Power-of-2 length required** — inherited from the underlying FFT constraint.
71-
- **Normalization convention.** Different references use different scaling (some include $\sqrt{2/N}$). Verify which convention the consumer expects.
75+
- **Normalization convention.** This library uses the **orthonormal** convention ($1/\sqrt{N}$ for $k=0$, $\sqrt{2/N}$ for $k \ge 1$), which satisfies Parseval's theorem and makes the transform matrix unitary. Other references may use the unnormalized form; scale accordingly when interfacing.
7276
- **Fixed-point overflow.** The reordering and FFT steps must preserve range; apply the 0.9999 scaling factor used throughout this library.
7377
- **Inverse accuracy.** Rounding errors accumulate in the forward-then-inverse round-trip, especially for Q15 types.
7478
- **Real input only.** Complex inputs are not supported by the reordering trick.

numerical/analysis/DiscreteCosineTransform.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
namespace analysis
55
{
6-
template class DiscreteConsineTransform<float, 8>;
7-
template class DiscreteConsineTransform<math::Q15, 8>;
8-
template class DiscreteConsineTransform<math::Q31, 8>;
6+
template class DiscreteCosineTransform<float, 8>;
7+
template class DiscreteCosineTransform<math::Q15, 8>;
8+
template class DiscreteCosineTransform<math::Q31, 8>;
99
}

numerical/analysis/DiscreteCosineTransform.hpp

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@
1414
namespace analysis
1515
{
1616
template<typename QNumberType, std::size_t Length>
17-
class DiscreteConsineTransform
17+
class DiscreteCosineTransform
1818
{
19-
static_assert((Length & (Length - 1)) == 0, "DiscreteConsineTransform size must be a power of 2");
19+
static_assert((Length & (Length - 1)) == 0, "DiscreteCosineTransform size must be a power of 2");
2020
static_assert(math::is_qnumber<QNumberType>::value || std::is_floating_point_v<QNumberType>,
21-
"DiscreteConsineTransform can only be instantiated with math::QNumber types or floating point.");
21+
"DiscreteCosineTransform can only be instantiated with math::QNumber types or floating point.");
2222

2323
public:
2424
using VectorReal = typename FastFourierTransform<QNumberType>::VectorReal;
2525
using VectorComplex = typename FastFourierTransform<QNumberType>::VectorComplex;
2626

27-
explicit DiscreteConsineTransform(FastFourierTransform<QNumberType>& fft);
27+
explicit DiscreteCosineTransform(FastFourierTransform<QNumberType>& fft);
2828
VectorReal& Forward(VectorReal& input);
2929
VectorReal& Inverse(VectorReal& input);
3030

@@ -38,7 +38,7 @@ namespace analysis
3838
// Implementation //
3939

4040
template<typename QNumberType, std::size_t Length>
41-
DiscreteConsineTransform<QNumberType, Length>::DiscreteConsineTransform(FastFourierTransform<QNumberType>& fft)
41+
DiscreteCosineTransform<QNumberType, Length>::DiscreteCosineTransform(FastFourierTransform<QNumberType>& fft)
4242
: fft(fft)
4343
{
4444
output.resize(Length);
@@ -48,8 +48,8 @@ namespace analysis
4848

4949
template<typename QNumberType, std::size_t Length>
5050
OPTIMIZE_FOR_SPEED
51-
typename DiscreteConsineTransform<QNumberType, Length>::VectorReal&
52-
DiscreteConsineTransform<QNumberType, Length>::Forward(VectorReal& input)
51+
typename DiscreteCosineTransform<QNumberType, Length>::VectorReal&
52+
DiscreteCosineTransform<QNumberType, Length>::Forward(VectorReal& input)
5353
{
5454
for (std::size_t n = 0; n < Length / 2; ++n)
5555
{
@@ -64,7 +64,7 @@ namespace analysis
6464
for (std::size_t k = 1; k < Length; ++k)
6565
{
6666
float angle = -static_cast<float>(k) * std::numbers::pi_v<float> / (2.0f * Length);
67-
float scale = 2.0f / math::Sqrt(static_cast<float>(Length));
67+
float scale = math::Sqrt(2.0f / static_cast<float>(Length));
6868

6969
float real = math::ToFloat(fftResult[k].Real());
7070
float imag = math::ToFloat(fftResult[k].Imaginary());
@@ -76,16 +76,18 @@ namespace analysis
7676
}
7777

7878
template<typename QNumberType, std::size_t Length>
79-
typename DiscreteConsineTransform<QNumberType, Length>::VectorReal& DiscreteConsineTransform<QNumberType, Length>::Inverse(VectorReal& input)
79+
OPTIMIZE_FOR_SPEED
80+
typename DiscreteCosineTransform<QNumberType, Length>::VectorReal&
81+
DiscreteCosineTransform<QNumberType, Length>::Inverse(VectorReal& input)
8082
{
8183
float sqrtN = math::Sqrt(static_cast<float>(Length));
8284

8385
complexBuffer[0] = math::Complex<QNumberType>{ QNumberType(math::ToFloat(input[0]) * sqrtN), QNumberType(0.0f) };
8486

8587
for (std::size_t k = 1; k < Length; ++k)
8688
{
87-
float real = math::ToFloat(input[k]) * sqrtN / 2.0f;
88-
float imag = -math::ToFloat(input[Length - k]) * sqrtN / 2.0f;
89+
float real = math::ToFloat(input[k]) * sqrtN / math::Sqrt(2.0f);
90+
float imag = -math::ToFloat(input[Length - k]) * sqrtN / math::Sqrt(2.0f);
8991

9092
float angle = static_cast<float>(k) * std::numbers::pi_v<float> / (2.0f * static_cast<float>(Length));
9193
float cosine = math::Cos(angle);
@@ -106,8 +108,8 @@ namespace analysis
106108
}
107109

108110
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
109-
extern template class DiscreteConsineTransform<float, 8>;
110-
extern template class DiscreteConsineTransform<math::Q15, 8>;
111-
extern template class DiscreteConsineTransform<math::Q31, 8>;
111+
extern template class DiscreteCosineTransform<float, 8>;
112+
extern template class DiscreteCosineTransform<math::Q15, 8>;
113+
extern template class DiscreteCosineTransform<math::Q31, 8>;
112114
#endif
113115
}

numerical/analysis/PowerDensitySpectrum.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ namespace analysis
8585
auto& spectrum = fft.Forward(segment);
8686

8787
for (std::size_t k = 0; k <= SegmentSize / 2; ++k)
88-
y[k] += QNumberType(math::ToFloat(MagnitudeSquared(spectrum[k])) / static_cast<float>(SegmentSize));
88+
{
89+
float factor = (k == 0 || k == SegmentSize / 2) ? 1.0f : 2.0f;
90+
y[k] += QNumberType(math::ToFloat(MagnitudeSquared(spectrum[k])) * factor / static_cast<float>(SegmentSize));
91+
}
8992

9093
++segmentCount;
9194
}

numerical/analysis/test/PowerDensitySpectrumTestSupport.hpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,42 @@ namespace analysis::test
7070
typename VectorComplex::template WithMaxSize<Length> result;
7171
typename VectorReal::template WithMaxSize<Length> timeResult;
7272
};
73+
74+
template<typename QNumberType, std::size_t Length>
75+
class FftStubInteriorBin
76+
: public analysis::FastFourierTransform<QNumberType>
77+
{
78+
public:
79+
using VectorComplex = typename analysis::FastFourierTransform<QNumberType>::VectorComplex;
80+
using VectorReal = typename analysis::FastFourierTransform<QNumberType>::VectorReal;
81+
82+
explicit FftStubInteriorBin(analysis::TwiddleFactors<QNumberType, Length / 2>&)
83+
{}
84+
85+
VectorComplex& Forward(VectorReal& input) override
86+
{
87+
result.clear();
88+
89+
for (std::size_t i = 0; i < Length; ++i)
90+
{
91+
if (i == 1)
92+
result.push_back(math::Complex<QNumberType>(QNumberType(0.5f), QNumberType(0.0f)));
93+
else
94+
result.push_back(math::Complex<QNumberType>(QNumberType(0.0f), QNumberType(0.0f)));
95+
}
96+
return result;
97+
}
98+
99+
VectorReal& Inverse(VectorComplex& input) override
100+
{
101+
timeResult.clear();
102+
for (std::size_t i = 0; i < Length; ++i)
103+
timeResult.push_back(QNumberType(0.0f));
104+
return timeResult;
105+
}
106+
107+
private:
108+
typename VectorComplex::template WithMaxSize<Length> result;
109+
typename VectorReal::template WithMaxSize<Length> timeResult;
110+
};
73111
}

numerical/analysis/test/TestDiscreteCosineTransform.cpp

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ namespace
4949

5050
ConcreteTwiddleFactors<float, Length / 2> twiddle;
5151
analysis::FastFourierTransformRadix2Impl<float, Length> fft{ twiddle };
52-
analysis::DiscreteConsineTransform<float, Length> dct{ fft };
52+
analysis::DiscreteCosineTransform<float, Length> dct{ fft };
5353

5454
RealBuf signal;
5555

@@ -71,7 +71,7 @@ namespace
7171
using ComplexBuf = VectorComplex::WithMaxSize<Length>;
7272

7373
::testing::StrictMock<MockFft> mockFft;
74-
analysis::DiscreteConsineTransform<float, Length> dct{ mockFft };
74+
analysis::DiscreteCosineTransform<float, Length> dct{ mockFft };
7575

7676
RealBuf signal;
7777
ComplexBuf fftOut;
@@ -112,7 +112,7 @@ TEST_F(TestDiscreteCosineTransform, forward_impulse_at_origin_matches_closed_for
112112
EXPECT_NEAR(result[0], 1.0f / sqrtN, math::Tolerance<float>());
113113
for (std::size_t k = 1; k < Length; ++k)
114114
{
115-
float ref{ 2.0f * std::cos(static_cast<float>(k) * std::numbers::pi_v<float> / (2.0f * static_cast<float>(Length))) / sqrtN };
115+
float ref{ std::sqrt(2.0f / static_cast<float>(Length)) * std::cos(static_cast<float>(k) * std::numbers::pi_v<float> / (2.0f * static_cast<float>(Length))) };
116116
EXPECT_NEAR(result[k], ref, math::Tolerance<float>());
117117
}
118118
}
@@ -136,7 +136,7 @@ TEST_F(TestDiscreteCosineTransform, forward_matches_direct_dct_ii_definition)
136136
for (std::size_t n = 0; n < Length; ++n)
137137
acc += x[n] * std::cos(std::numbers::pi_v<float> * (2.0f * static_cast<float>(n) + 1.0f) * static_cast<float>(k) / (2.0f * static_cast<float>(Length)));
138138

139-
float ref{ 2.0f / std::sqrt(static_cast<float>(Length)) * acc };
139+
float ref{ std::sqrt(2.0f / static_cast<float>(Length)) * acc };
140140
EXPECT_NEAR(result[k], ref, math::Tolerance<float>());
141141
}
142142
}
@@ -224,6 +224,25 @@ TEST_F(TestDiscreteCosineTransform, inverse_pure_dc_spectrum_gives_constant_sign
224224
EXPECT_NEAR(result[n], ref, math::Tolerance<float>());
225225
}
226226

227+
TEST_F(TestDiscreteCosineTransform, parseval_identity_holds)
228+
{
229+
constexpr std::array<float, Length> x{ 3.0f, -1.0f, 4.0f, 1.0f, -5.0f, 9.0f, -2.0f, 6.0f };
230+
for (std::size_t n = 0; n < Length; ++n)
231+
signal[n] = x[n];
232+
233+
auto& X = dct.Forward(signal);
234+
235+
float energyTime{ 0.0f };
236+
for (std::size_t n = 0; n < Length; ++n)
237+
energyTime += x[n] * x[n];
238+
239+
float energyFreq{ 0.0f };
240+
for (std::size_t k = 0; k < Length; ++k)
241+
energyFreq += X[k] * X[k];
242+
243+
EXPECT_NEAR(energyFreq, energyTime, 1.0f);
244+
}
245+
227246
TEST_F(TestDiscreteCosineTransformMockFft, forward_passes_full_length_buffer_to_fft)
228247
{
229248
for (std::size_t i = 0; i < Length; ++i)

numerical/analysis/test/TestPowerDensitySpectrum.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,34 @@ TYPED_TEST(TestPowerSpectralDensityZeroOverlap, output_size_equals_half_segment_
176176
EXPECT_EQ(result.size(), TestFixture::length / 2 + 1);
177177
}
178178

179+
TYPED_TEST(TestPowerSpectralDensity, interior_bin_is_doubled_relative_to_dc_for_equal_magnitude)
180+
{
181+
if constexpr (!std::is_floating_point_v<TypeParam>)
182+
GTEST_SKIP();
183+
184+
using FftInterior = analysis::test::FftStubInteriorBin<TypeParam, TestFixture::length>;
185+
using Twiddle = analysis::test::TwiddleFactorsStub<TypeParam, TestFixture::length / 2>;
186+
using PsdInterior = analysis::PowerSpectralDensity<TypeParam, TestFixture::length, FftInterior, Twiddle, TestFixture::overlap>;
187+
188+
analysis::test::WindowStub<TypeParam> win;
189+
TypeParam samplingTime = TypeParam(1.0f / 48000.0f);
190+
191+
PsdInterior psdInterior(win, samplingTime);
192+
193+
typename TestFixture::PowerDensitySpectrum::VectorReal::template WithMaxSize<TestFixture::length> input;
194+
for (std::size_t i = 0; i < this->length; ++i)
195+
input.push_back(TypeParam(0.5f));
196+
197+
auto& dcResult = this->powerDensitySpectrum->Calculate(input);
198+
float dcBin = math::ToFloat(dcResult[0]);
199+
200+
auto& interiorResult = psdInterior.Calculate(input);
201+
float interiorBin1 = math::ToFloat(interiorResult[1]);
202+
203+
ASSERT_GT(dcBin, 0.0f);
204+
EXPECT_NEAR(interiorBin1 / dcBin, 2.0f, 1e-3f);
205+
}
206+
179207
TYPED_TEST(TestPowerSpectralDensityZeroOverlap, zero_overlap_step_equals_segment_size)
180208
{
181209
if constexpr (!std::is_floating_point_v<TypeParam>)

numerical/control_analysis/TransferFunctionStateSpace.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ namespace control_analysis
150150
for (std::size_t k{ 1 }; k <= n; ++k)
151151
{
152152
auto CB{ sys.C * N * sys.B };
153-
numCoeffs[k] = CB.at(0, 0);
153+
numCoeffs[k] = CB.at(0, 0) + sys.D.at(0, 0) * charPoly[k];
154154
N = sys.A * N;
155155
for (std::size_t i{ 0 }; i < n; ++i)
156156
N.at(i, i) += charPoly[k];

0 commit comments

Comments
 (0)