Skip to content

Commit bb43d57

Browse files
feat: add cascade integrator filter (#177)
* add cascade integrator filter * Update doc/filters/passive/CicFilter.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 852d0f7 commit bb43d57

11 files changed

Lines changed: 335 additions & 147 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2121
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF, Gain-Scheduled Controller |
2222
| [Dynamics](doc/dynamics/README.md) | Euler-Lagrange, Newton-Euler, Recursive Newton-Euler, ABA |
2323
| [Estimators](doc/estimators/README.md) | Linear Regression, Polynomial Fitting, Yule-Walker (offline), Recursive Least Squares (online) |
24-
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter |
24+
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter, CIC (Cascaded Integrator-Comb) |
2525
| [Kinematics](doc/kinematics/README.md) | Forward Kinematics |
2626
| [Neural Network](doc/neural_network/README.md) | Layers, activations, losses, model |
2727
| [Optimization](doc/optimization/README.md) | Gradient Descent |

ROADMAP.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ Difficulty legend:
2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
3030
| 13 | Goertzel algorithm | `analysis` | ★★☆☆☆ |
31-
| 14 | CIC (Cascaded Integrator-Comb) filter | `filters/passive` | ★★☆☆☆ |
3231
| 15 | Biquad / Second-Order-Section cascade | `filters/passive` | ★★★☆☆ |
3332
| 16 | Notch / comb filter | `filters/passive` | ★★★☆☆ |
3433
| 17 | Lead-lag compensator | `controllers` | ★★★☆☆ |

doc/filters/passive/CicFilter.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# CIC (Cascaded Integrator-Comb) Filter
2+
3+
## Overview & Motivation
4+
5+
Decimation and interpolation in digital signal processing typically require a lowpass anti-aliasing filter before the rate change. Standard FIR filters require multiplications proportional to their order. The CIC filter achieves a highly efficient lowpass response using only additions and subtractions, making it ideal for resource-constrained embedded systems where multipliers are expensive or unavailable.
6+
7+
CIC filters are used in sigma-delta ADC interfaces, software-defined radio front-ends, and any application that must drastically reduce the sample rate of a high-frequency signal stream before further processing.
8+
9+
## Mathematical Theory
10+
11+
### Core Definitions
12+
13+
A CIC decimator of order $N$ with decimation ratio $R$ and differential delay $M$ is defined by its $z$-domain transfer function:
14+
15+
$$H(z) = \left( \frac{1 - z^{-RM}}{1 - z^{-1}} \right)^N$$
16+
17+
The numerator factor $1 - z^{-RM}$ is the $z$-transform of the comb (differencing) stage. The denominator $\frac{1}{1 - z^{-1}}$ is the accumulator (integrator) stage.
18+
19+
### Structure
20+
21+
The filter consists of two cascaded sections:
22+
23+
**Integrator section** (running at the high input rate $f_s$): $N$ stages of first-order IIR accumulators,
24+
25+
$$y_k[n] = y_k[n-1] + y_{k-1}[n], \quad k = 1, \ldots, N$$
26+
27+
**Comb section** (running at the low output rate $f_s / R$): $N$ stages of differencing with delay $M$,
28+
29+
$$y_k[m] = y_{k-1}[m] - y_{k-1}[m - M], \quad k = 1, \ldots, N$$
30+
31+
### DC Gain
32+
33+
The unnormalized DC gain of the filter is:
34+
35+
$$G = (R \cdot M)^N$$
36+
37+
All outputs are divided by $G$ to normalize the DC gain to unity for a constant input.
38+
39+
### Frequency Response
40+
41+
The magnitude response in the baseband is approximately:
42+
43+
$$|H(f)| = \left| \frac{\sin(\pi f M R / f_s)}{R \sin(\pi f / f_s)} \right|^N$$
44+
45+
This is a sinc-like response that suppresses high-frequency content before the rate change.
46+
47+
## Complexity Analysis
48+
49+
| Case | Time per input sample | Space | Notes |
50+
|---------|-----------------------|----------------|----------------------------------------------------|
51+
| Best | $O(N)$ | $O(N \cdot M)$ | $N$ integrator ops; comb only at decimation points |
52+
| Average | $O(N)$ | $O(N \cdot M)$ | Same |
53+
| Worst | $O(N)$ | $O(N \cdot M)$ | Comb adds $N$ differencing ops at rate $f_s/R$ |
54+
55+
The integrator section executes $N$ additions per input sample. The comb section executes $N$ subtractions once every $R$ input samples. There are no multiplications in the signal path.
56+
57+
## Step-by-Step Walkthrough
58+
59+
Consider $N=2$, $R=4$, $M=1$, input impulse $x[0]=1$, $x[n]=0$ for $n>0$.
60+
61+
**Integrator section at $n=0,1,2,3$:**
62+
63+
| $n$ | Input | Integrator 1 | Integrator 2 |
64+
|-----|-------|--------------|--------------|
65+
| 0 | 1 | 1 | 1 |
66+
| 1 | 0 | 1 | 2 |
67+
| 2 | 0 | 1 | 3 |
68+
| 3 | 0 | 1 | 4 |
69+
70+
**Comb section at decimated sample $m=0$ (triggered at $n=3$):**
71+
72+
- Comb 1 input: 4; delay buffer held 0; output: $4 - 0 = 4$; buffer updated to 4
73+
- Comb 2 input: 4; delay buffer held 0; output: $4 - 0 = 4$; buffer updated to 4
74+
- Normalized output: $4 / 16 = 0.25$
75+
76+
**At $m=1$ (triggered at $n=7$):**
77+
78+
- Integrator 2 output: 8 (accumulated four more 1s from integrator 1)
79+
- Comb 1: $8 - 4 = 4$; Comb 2: $4 - 4 = 0$; Normalized output: $0.0$
80+
81+
## Pitfalls & Edge Cases
82+
83+
**Integer overflow in fixed-point implementations**: in fixed-point arithmetic, the integrators accumulate without bound between comb operations. Registers must be wide enough to hold $(R \cdot M)^N$ times the maximum input value. This implementation uses floating-point, which avoids this issue.
84+
85+
**Initial transient**: the filter takes several R-length blocks to settle to steady-state behavior for a constant input. DC normalization is exact only after the delay pipeline is fully flushed.
86+
87+
**Passband droop**: the sinc-shaped response causes attenuation even near DC as the input frequency increases. Compensation filters or a larger $R$ reduce in-band droop.
88+
89+
**Aliasing from high-order terms**: if the input signal has energy above $f_s / (2R)$, aliased components will appear at the output. A simple prefilter can reduce this.
90+
91+
## Variants & Generalizations
92+
93+
**Interpolating CIC**: the comb section runs at the low rate and the integrators at the high rate, acting as an upsampler. The architecture mirrors the decimator with sections swapped.
94+
95+
**Pruned CIC**: removes multiplier-free adder stages that contribute negligibly to the response, reducing hardware at the cost of response shape.
96+
97+
**Compensation filter**: a short linear-phase FIR appended at the low rate corrects passband droop without reintroducing multipliers in the high-rate path.
98+
99+
**Variable-rate CIC**: by making $R$ a runtime parameter, one filter structure supports multiple decimation ratios, useful in SDR front-ends.
100+
101+
## Applications
102+
103+
- Sigma-delta ADC decimation: the high oversampling rate (e.g., 256x) is reduced to Nyquist rate by a CIC stage before a compensation FIR.
104+
- Software-defined radio: narrowband channels are extracted from a wideband stream by decimating with a CIC before channelization.
105+
- Audio sample-rate conversion: high-to-low rate conversion with anti-aliasing, followed by a polyphase FIR for droop correction.
106+
- Sensor interfaces: smoothing and downsampling of high-rate MEMS sensor data with minimal compute budget.
107+
108+
## Connections to Other Algorithms
109+
110+
A CIC filter of order $N=1$, $M=1$ is equivalent to a boxcar (rectangular window) FIR of length $R$, identical to a Moving Average filter operating on non-overlapping blocks. The Moving Average filter in this library is the continuous-output counterpart.
111+
112+
Higher-order CICs approximate a Gaussian response as $N \to \infty$, connecting them to the Gaussian filter family in theory.
113+
114+
The CIC is a special case of the more general Hogenauer filter structure, which can be pruned to reduce word widths at each stage.
115+
116+
## References & Further Reading
117+
118+
- E. B. Hogenauer, "An economical class of digital filters for decimation and interpolation," IEEE Transactions on Acoustics, Speech, and Signal Processing, vol. 29, no. 2, pp. 155-162, April 1981.
119+
- R. E. Crochiere and L. R. Rabiner, "Multirate Digital Signal Processing," Prentice-Hall, 1983. Chapter 3.
120+
- F. J. Harris, "Multirate Signal Processing for Communication Systems," Prentice-Hall, 2004.

numerical/filters/passive/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ target_link_libraries(numerical.filters.passive ${NUMERICAL_VISIBILITY}
1111
)
1212

1313
target_sources(numerical.filters.passive PRIVATE
14+
CicFilter.hpp
1415
ExponentialMovingAverage.hpp
1516
Fir.hpp
1617
Iir.hpp
@@ -19,6 +20,7 @@ target_sources(numerical.filters.passive PRIVATE
1920
)
2021

2122
numerical_add_coverage_sources(numerical.filters.passive
23+
CicFilter.cpp
2224
ExponentialMovingAverage.cpp
2325
Fir.cpp
2426
Iir.cpp
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "numerical/filters/passive/CicFilter.hpp"
2+
3+
namespace filters::passive
4+
{
5+
template class CicDecimator<float, 2, 4, 1>;
6+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#pragma once
2+
3+
#if defined(__GNUC__) || defined(__clang__)
4+
#pragma GCC optimize("O3", "fast-math")
5+
#endif
6+
7+
#include "numerical/math/CompilerOptimizations.hpp"
8+
#include <array>
9+
#include <cstddef>
10+
#include <type_traits>
11+
12+
namespace filters::passive
13+
{
14+
template<typename T>
15+
struct CicSample
16+
{
17+
T value{};
18+
bool valid{ false };
19+
};
20+
21+
template<typename T, std::size_t M>
22+
class Comb
23+
{
24+
static_assert(std::is_floating_point_v<T>, "Comb supports floating-point types");
25+
26+
public:
27+
OPTIMIZE_FOR_SPEED T PushPop(T input) noexcept;
28+
void Reset() noexcept;
29+
30+
private:
31+
std::array<T, M> delay{};
32+
std::size_t head{ 0 };
33+
};
34+
35+
template<typename T, std::size_t Stages, std::size_t R, std::size_t M>
36+
class CicDecimator
37+
{
38+
static_assert(std::is_floating_point_v<T>, "CicDecimator supports floating-point types");
39+
static_assert(Stages > 0, "Stages must be > 0");
40+
static_assert(R > 0, "R must be > 0");
41+
static_assert(M > 0, "M must be > 0");
42+
43+
public:
44+
CicDecimator() noexcept = default;
45+
46+
OPTIMIZE_FOR_SPEED CicSample<T> Filter(T input) noexcept;
47+
void Reset() noexcept;
48+
static constexpr T Gain() noexcept;
49+
50+
private:
51+
std::array<T, Stages> integrator{};
52+
std::array<Comb<T, M>, Stages> comb{};
53+
std::size_t phase{ 0 };
54+
};
55+
56+
// --- Comb implementation ---
57+
58+
template<typename T, std::size_t M>
59+
OPTIMIZE_FOR_SPEED T Comb<T, M>::PushPop(T input) noexcept
60+
{
61+
T oldest = delay[head];
62+
delay[head] = input;
63+
head = (head + 1) % M;
64+
return oldest;
65+
}
66+
67+
template<typename T, std::size_t M>
68+
void Comb<T, M>::Reset() noexcept
69+
{
70+
delay.fill(T{});
71+
head = 0;
72+
}
73+
74+
// --- CicDecimator implementation ---
75+
76+
template<typename T, std::size_t Stages, std::size_t R, std::size_t M>
77+
OPTIMIZE_FOR_SPEED CicSample<T> CicDecimator<T, Stages, R, M>::Filter(T input) noexcept
78+
{
79+
T acc{ input };
80+
for (std::size_t i = 0; i < Stages; ++i)
81+
{
82+
integrator[i] += acc;
83+
acc = integrator[i];
84+
}
85+
86+
++phase;
87+
if (phase < R)
88+
return CicSample<T>{};
89+
phase = 0;
90+
91+
for (std::size_t i = 0; i < Stages; ++i)
92+
{
93+
T delayed = comb[i].PushPop(acc);
94+
acc = acc - delayed;
95+
}
96+
97+
return CicSample<T>{ acc / Gain(), true };
98+
}
99+
100+
template<typename T, std::size_t Stages, std::size_t R, std::size_t M>
101+
void CicDecimator<T, Stages, R, M>::Reset() noexcept
102+
{
103+
integrator.fill(T{});
104+
for (std::size_t i = 0; i < Stages; ++i)
105+
comb[i].Reset();
106+
phase = 0;
107+
}
108+
109+
template<typename T, std::size_t Stages, std::size_t R, std::size_t M>
110+
constexpr T CicDecimator<T, Stages, R, M>::Gain() noexcept
111+
{
112+
T g{ static_cast<T>(R * M) };
113+
T result{ T{ 1 } };
114+
for (std::size_t i = 0; i < Stages; ++i)
115+
result *= g;
116+
return result;
117+
}
118+
119+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
120+
extern template class CicDecimator<float, 2, 4, 1>;
121+
#endif
122+
}

numerical/filters/passive/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ target_link_libraries(numerical.filters.passive_test PUBLIC
1010
)
1111

1212
target_sources(numerical.filters.passive_test PRIVATE
13+
TestCicFilter.cpp
1314
TestExponentialMovingAverage.cpp
1415
TestFir.cpp
1516
TestIir.cpp
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#include "numerical/filters/passive/CicFilter.hpp"
2+
#include "numerical/math/Tolerance.hpp"
3+
#include "gtest/gtest.h"
4+
5+
namespace
6+
{
7+
class TestCicDecimator
8+
: public ::testing::Test
9+
{
10+
public:
11+
filters::passive::CicDecimator<float, 2, 4, 1> cic{};
12+
};
13+
}
14+
15+
TEST_F(TestCicDecimator, emits_one_per_R)
16+
{
17+
int count{ 0 };
18+
for (int i = 0; i < 8; ++i)
19+
{
20+
auto result = cic.Filter(1.0f);
21+
if (result.valid)
22+
++count;
23+
}
24+
EXPECT_EQ(count, 2);
25+
}
26+
27+
TEST_F(TestCicDecimator, dc_gain_normalized)
28+
{
29+
constexpr float c{ 0.5f };
30+
constexpr float tol{ 1e-5f };
31+
filters::passive::CicSample<float> result{};
32+
for (int i = 0; i < 16; ++i)
33+
result = cic.Filter(c);
34+
EXPECT_NEAR(result.value, c, tol);
35+
}
36+
37+
TEST_F(TestCicDecimator, impulse_response_is_triangular)
38+
{
39+
constexpr float tol{ 1e-5f };
40+
filters::passive::CicSample<float> first{};
41+
filters::passive::CicSample<float> second{};
42+
for (int i = 0; i < 8; ++i)
43+
{
44+
float inp = (i == 0) ? 1.0f : 0.0f;
45+
auto r = cic.Filter(inp);
46+
if (r.valid)
47+
{
48+
if (!first.valid)
49+
first = r;
50+
else if (!second.valid)
51+
second = r;
52+
}
53+
}
54+
EXPECT_NEAR(first.value, 0.25f, tol);
55+
EXPECT_NEAR(second.value, 0.0f, tol);
56+
}
57+
58+
TEST_F(TestCicDecimator, silence_gives_zero)
59+
{
60+
constexpr float tol{ 1e-5f };
61+
for (int i = 0; i < 8; ++i)
62+
{
63+
auto result = cic.Filter(0.0f);
64+
if (result.valid)
65+
EXPECT_NEAR(result.value, 0.0f, tol);
66+
}
67+
}
68+
69+
TEST_F(TestCicDecimator, reset_clears_all_state)
70+
{
71+
constexpr float tol{ 1e-5f };
72+
for (int i = 0; i < 4; ++i)
73+
cic.Filter(1.0f);
74+
cic.Reset();
75+
filters::passive::CicSample<float> first{};
76+
for (int i = 0; i < 4; ++i)
77+
{
78+
auto r = cic.Filter(1.0f);
79+
if (r.valid)
80+
first = r;
81+
}
82+
EXPECT_NEAR(first.value, 0.625f, tol);
83+
}

0 commit comments

Comments
 (0)