Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c7cb5ae
Add FixedPoint scalar type for freestanding fixed-point filter support
MitchellThompkins Jul 7, 2026
b250ad5
Add FixedFilter and fixed-point converting constructor
MitchellThompkins Jul 7, 2026
e4ac5b0
Add fixed_point tests
MitchellThompkins Jul 7, 2026
7978150
Add fixed_filter integration tests
MitchellThompkins Jul 8, 2026
d886073
Add fixed_point and fixed_filter to umbrella include
MitchellThompkins Jul 8, 2026
5f96f48
Fix T{} zero-init and add saturation to FixedPoint double constructor
MitchellThompkins Jul 8, 2026
4c5dce3
Add FixedButterworth, FixedElliptic, and common Q-format aliases
MitchellThompkins Jul 8, 2026
e9f737b
Update fixed_filter tests to use FixedButterworth and built-in Q aliases
MitchellThompkins Jul 8, 2026
9c8fde4
Address PR review: {} init syntax, operator/ precondition docs
MitchellThompkins Jul 8, 2026
f5902ad
Add fixed point filter documentation
MitchellThompkins Jul 8, 2026
6c8e91e
Fix signed overflow UB in FixedPoint add/sub/negate
MitchellThompkins Jul 13, 2026
a051f31
Round to nearest in FixedPoint multiply
MitchellThompkins Jul 13, 2026
8546f77
Fix Q-format naming in FixedPoint comments
MitchellThompkins Jul 13, 2026
aee5fd4
Document a[0]=1 saturation and coeffs_b() range check
MitchellThompkins Jul 13, 2026
ef5a5c5
Document ambiguous FixedPoint integer construction
MitchellThompkins Jul 13, 2026
84d20a9
Brace all loop bodies in fixed_filter.test.cpp
MitchellThompkins Jul 14, 2026
9136cbd
Add UBSan CI job
MitchellThompkins Jul 16, 2026
67b0b14
Use range-based for loops to fill test arrays
MitchellThompkins Jul 16, 2026
5c6d868
Fix misleading FixedPoint disambiguation example in docs
MitchellThompkins Jul 16, 2026
3c3dd54
Drop operator/ from custom scalar requirements
MitchellThompkins Jul 16, 2026
c4a2445
Document the fixed-point test layer in verification.md
MitchellThompkins Jul 16, 2026
99e6572
Saturate FixedPoint arithmetic on overflow instead of wrapping
MitchellThompkins Aug 7, 2026
debab9d
Finish fixed-point docs and add long tier support
MitchellThompkins Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ All at compile time, constfilt supports:
default, `TustinNW` standard) of arbitrary continuous-time transfer functions.
- Direct Form II Transposed filter implementation, with both a real-time
sample-by-sample interface and a `constexpr`-capable batch interface.
- Fixed-point coefficient storage via `FixedButterworth` and `FixedElliptic`,
for targets without floating-point hardware. Coefficients are computed in
`double` at compile time and converted once to the chosen Q format.

Full documentation can be found at
[documentation](https://mitchellthompkins.github.io/constfilt/).
Expand All @@ -41,6 +44,10 @@ Simply `#include <constfilt/constfilt.hpp>` (optionally consume via CMake with

static constexpr constfilt::Butterworth<double, 4> bw(100.0, 1000.0);
static constexpr constfilt::Elliptic<double, 4> el(100.0, 0.5, 60.0, 1000.0);

// Fixed-point: coefficients computed in double, stored as Q0.15 (16-bit).
static constexpr constfilt::FixedButterworth<constfilt::Q0_15, 1, constfilt::ZOH>
fbw(100.0, 1000.0);
```

For a quick reference on getting started, including examples, see
Expand Down
172 changes: 172 additions & 0 deletions docs/fixed-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Fixed-Point Filters

constfilt can compute filter coefficients in `double` at compile time and then
convert them to a fixed-point type for deployment on targets that lack
floating-point hardware.

## Quick start

```cpp
#include <constfilt/constfilt.hpp>

// 1st-order Butterworth lowpass, coefficients stored as Q0.15 (16-bit).
static constexpr constfilt::FixedButterworth<constfilt::Q0_15, 1, constfilt::ZOH>
filt(100.0, 1000.0);

// Batch filtering.
constfilt::Q0_15 input[8]{};
constfilt::Q0_15 output[8]{};
filt(input, output);

// Real-time sample-by-sample filtering.
constfilt::FixedButterworth<constfilt::Q0_15, 1, constfilt::ZOH> rt(100.0, 1000.0);
constfilt::Q0_15 y = rt(constfilt::Q0_15{0.5});
```

All coefficient math runs at compile time in `double`. The conversion to
fixed-point happens once, also at compile time. At runtime only the
Direct Form II Transposed arithmetic executes in fixed-point.

## Choosing a Q format

A Q format defines how many fractional bits a fixed-point value carries and
therefore its representable range. For a signed N-bit type with F fractional bits
the range is `[-2^(N-1-F), 2^(N-1-F))` and the resolution is `2^(-F)`.

Filter coefficients can exceed `[-1, 1)` for higher-order designs, and `b[k]`
coefficients can exceed `[-1, 1)` too for highpass or elliptic designs with
gain. The Q format must be wide enough to hold the largest coefficient in
either array. Check both `coeffs_a()` and `coeffs_b()` on the `double`
prototype if you are unsure.

By convention `a[0] = 1.0`, but in a `Q0` format (range `[-1, 1)`) `1.0`
itself is not representable, so the converting constructor saturates it to
the format maximum instead (about `0.99997` for `Q0_15`). This is harmless
for filtering, since the Direct Form II Transposed recurrence never reads
`a[0]`, but `coeffs_a()[0]` will not read back exactly `1.0` in a `Q0`
format.

### Built-in aliases

| Alias | Storage | FracBits | Range | Resolution |
|---------|---------------|----------|------------|-------------|
| `Q0_7` | `signed char` | 7 | `[-1, 1)` | ~7.8e-3 |
| `Q1_6` | `signed char` | 6 | `[-2, 2)` | ~1.6e-2 |
| `Q0_15` | `short` | 15 | `[-1, 1)` | ~3.1e-5 |
| `Q1_14` | `short` | 14 | `[-2, 2)` | ~6.1e-5 |
| `Q0_31` | `int` | 31 | `[-1, 1)` | ~4.7e-10 |
| `Q1_30` | `int` | 30 | `[-2, 2)` | ~9.3e-10 |

All aliases are in the `constfilt` namespace and are defined in
`include/constfilt/fixed_point_aliases.hpp`, which is included by the umbrella
header.

### Rule of thumb

Use a `Q0` variant (range `[-1, 1)`) for 1st-order filters and verify the
coefficients fit. Use a `Q1` variant (range `[-2, 2)`) when denominator
coefficients exceed `[-1, 1)`, which is common for 2nd-order and higher designs.
Higher precision (`Q0_31` / `Q1_30`) reduces quantization error at the cost of
wider arithmetic.

## FixedButterworth and FixedElliptic

These are the primary user-facing types. They mirror the constructor signatures
of `Butterworth` and `Elliptic` exactly, with the floating-point scalar type
replaced by the fixed-point type.

```cpp
// FixedButterworth<TFixed, N, Method, FilterType>
static constexpr constfilt::FixedButterworth<constfilt::Q1_30, 2, constfilt::ZOH>
bw(100.0, 1000.0);

// FixedElliptic<TFixed, N, Method, FilterType>
static constexpr constfilt::FixedElliptic<constfilt::Q1_30, 2, constfilt::ZOH>
el(100.0, 0.5, 60.0, 1000.0);
```

`Method` and `FilterType` default to `TustinPW` and `LowPass` respectively,
matching the floating-point counterparts.

## FixedFilter (advanced)

`FixedFilter` is the lower-level building block. It converts any
`Filter<double, NB, NA>` to a fixed-point filter by taking the double filter as
a constructor argument. Use it when you need to convert an `AnalogFilter` or a
hand-constructed `Filter`, or when you prefer to manage the double prototype
explicitly.

```cpp
static constexpr constfilt::Butterworth<double, 1, constfilt::ZOH> proto(
100.0, 1000.0);
static constexpr constfilt::FixedFilter<constfilt::Q0_15, 2, 2> filt(proto);
```

The `NB` and `NA` template arguments must match the source filter. For a
Butterworth or Elliptic of order N both are `N + 1`.

## Custom fixed-point types

constfilt supplies `FixedPoint<TInt, TWider, FracBits>` as a ready-to-use
scalar, but `FixedFilter`, `FixedButterworth`, and `FixedElliptic` accept any
type `T` that provides the arithmetic operators used by `Filter`

```
T operator+(T) const
T operator-(T) const
T operator-() const
T operator*(T) const
T operator/(T) const // precondition: divisor is non-zero
T() // default constructor produces zero
explicit T(double) // conversion from double coefficient
```

`T` must also support value-initialization (`T{}` produces zero) and
`static_cast<T>(double_value)` must be valid.

## FixedPoint reference

```cpp
template <typename TInt, typename TWider, unsigned FracBits>
class constfilt::FixedPoint;
```

**Template parameters**

| Parameter | Description |
|------------|----------------------------------------------------------------|
| `TInt` | Signed integer storage type (`signed char`, `short`, `int`, `long long`) |
| `TWider` | Signed integer strictly wider than `TInt`, used as multiply intermediate |
| `FracBits` | Number of fractional bits; must satisfy `0 < FracBits < 8*sizeof(TInt)` |

**Construction**

| Expression | Result |
|-------------------------|--------------------------------------------------|
| `FixedPoint{}` | Zero |
| `FixedPoint{double v}` | Nearest representable value; saturates at limits |
| `FixedPoint{TInt n}` | Integer `n` scaled by `2^FracBits` |
| `FixedPoint::from_raw(TInt r)` | Raw bit pattern `r` without scaling |

An integer literal such as `Q0_15{1}` fails to compile: it converts equally
well to `TInt` (the integer constructor) and to `double` (the double
constructor), so the call is ambiguous. Cast the literal to the storage type
or to `double` to disambiguate, for example `Q0_15{static_cast<short>(1)}` or
`Q0_15{1.0}`.

**Members**

| Member | Description |
|-------------------|---------------------------------------------|
| `raw_value()` | Returns the raw integer representation |
| `to_double()` | Converts back to `double` |
| `from_raw(TInt)` | Constructs from raw bits (static) |

Arithmetic follows Q-format rules. Multiplication widens to `TWider`, adds a
half-LSB bias, and shifts right by `FracBits` to renormalize with round to
nearest rather than truncation. Division widens and shifts left before
dividing. Addition and subtraction operate directly on the raw bits.
Overflow wraps in two's complement. Division by zero is undefined behavior.

`fixed_limits<TInt, TWider, FracBits>` provides `min()`, `max()`, `lowest()`,
and `epsilon()` without requiring `<limits>`.
22 changes: 22 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ constexpr double b0 = bw.coeffs_b()[0];
constexpr double a1 = bw.coeffs_a()[1];
```

## Fixed-point filters

For targets without floating-point hardware, use `FixedButterworth` or
`FixedElliptic`. Coefficients are computed in `double` at compile time and
converted once to the chosen Q format.

```cpp
// Q0_15 is short storage with 15 fractional bits, range [-1, 1).
// Q1_30 is int storage with 30 fractional bits, range [-2, 2).
// Use Q1_30 when denominator coefficients exceed [-1, 1), which occurs
// for 2nd-order and higher designs at many cutoff/sample-rate combinations.

static constexpr constfilt::FixedButterworth<constfilt::Q0_15, 1, constfilt::ZOH>
bw1(100.0, 1000.0);

static constexpr constfilt::FixedButterworth<constfilt::Q1_30, 2, constfilt::ZOH>
bw2(100.0, 1000.0);
```

See [Fixed-Point Filters](fixed-point.md) for the full guide including Q format
selection, the built-in aliases, and the `FixedPoint` scalar type reference.

## Stability checking

`AnalogFilter` (the base of `Butterworth` and `Elliptic`) exposes a `CheckStab`
Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ nav:
- Butterworth: butterworth.md
- Elliptic: elliptic.md
- Discretization Methods: discretization.md
- Fixed-Point Filters: fixed-point.md
- Development:
- Building & Testing: building.md
- Verification: verification.md
Expand Down
5 changes: 5 additions & 0 deletions include/constfilt/constfilt.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
#include "discretize.hpp"
#include "elliptic.hpp"
#include "filter.hpp"
#include "fixed_butterworth.hpp"
#include "fixed_elliptic.hpp"
#include "fixed_filter.hpp"
#include "fixed_point.hpp"
#include "fixed_point_aliases.hpp"
#include "stability.hpp"

#endif // CONSTFILT_HPP
19 changes: 16 additions & 3 deletions include/constfilt/filter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,27 @@ template <typename T, consteig::Size NB, consteig::Size NA> class Filter
}
}

template <typename U>
constexpr explicit Filter(const Filter<U, NB, NA> &other)
{
for (consteig::Size i = 0; i < NB; ++i)
{
_b[i] = static_cast<T>(other.coeffs_b()[i]);
}
for (consteig::Size i = 0; i < NA; ++i)
{
_a[i] = static_cast<T>(other.coeffs_a()[i]);
}
}

private:
constexpr T b_coeff(consteig::Size i) const
{
return i < NB ? _b[i] : T(0);
return i < NB ? _b[i] : T{};
}
constexpr T a_coeff(consteig::Size i) const
{
return i < NA ? _a[i] : T(0);
return i < NA ? _a[i] : T{};
}

public:
Expand Down Expand Up @@ -107,7 +120,7 @@ template <typename T, consteig::Size NB, consteig::Size NA> class Filter
{
for (consteig::Size k = 0; k < M; ++k)
{
_state[k] = static_cast<T>(0);
_state[k] = T{};
}
}
};
Expand Down
36 changes: 36 additions & 0 deletions include/constfilt/fixed_butterworth.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#ifndef CONSTFILT_FIXED_BUTTERWORTH_HPP
#define CONSTFILT_FIXED_BUTTERWORTH_HPP

#include "butterworth.hpp"
#include "fixed_filter.hpp"

namespace constfilt
{

// Butterworth filter with fixed-point coefficients. Computes coefficients in
// double at compile time, then converts to TFixed. Constructor arguments are
// identical to Butterworth<double, N, Method>.
template <typename TFixed, unsigned N, typename Method = TustinPW,
typename FilterType = LowPass>
class FixedButterworth : public FixedFilter<TFixed, N + 1u, N + 1u>
{
public:
constexpr FixedButterworth(double cutoff_hz, double sample_rate_hz)
: FixedFilter<TFixed, N + 1u, N + 1u>(
Butterworth<double, N, Method, FilterType>(cutoff_hz,
sample_rate_hz))
{
}

constexpr FixedButterworth(double cutoff_hz, double sample_rate_hz,
double zeta)
: FixedFilter<TFixed, N + 1u, N + 1u>(
Butterworth<double, N, Method, FilterType>(cutoff_hz,
sample_rate_hz, zeta))
{
}
};

} // namespace constfilt

#endif // CONSTFILT_FIXED_BUTTERWORTH_HPP
29 changes: 29 additions & 0 deletions include/constfilt/fixed_elliptic.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#ifndef CONSTFILT_FIXED_ELLIPTIC_HPP
#define CONSTFILT_FIXED_ELLIPTIC_HPP

#include "elliptic.hpp"
#include "fixed_filter.hpp"

namespace constfilt
{

// Elliptic filter with fixed-point coefficients. Computes coefficients in
// double at compile time, then converts to TFixed. Constructor arguments are
// identical to Elliptic<double, N, Method>.
template <typename TFixed, unsigned N, typename Method = TustinPW,
typename FilterType = LowPass>
class FixedElliptic : public FixedFilter<TFixed, N + 1u, N + 1u>
{
public:
constexpr FixedElliptic(double cutoff_hz, double ripple_db,
double attenuation_db, double sample_rate_hz)
: FixedFilter<TFixed, N + 1u, N + 1u>(
Elliptic<double, N, Method, FilterType>(
cutoff_hz, ripple_db, attenuation_db, sample_rate_hz))
{
}
};

} // namespace constfilt

#endif // CONSTFILT_FIXED_ELLIPTIC_HPP
30 changes: 30 additions & 0 deletions include/constfilt/fixed_filter.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#ifndef CONSTFILT_FIXED_FILTER_HPP
#define CONSTFILT_FIXED_FILTER_HPP

#include "filter.hpp"

namespace constfilt
{

// Concrete fixed-point filter. Converts coefficients from any floating-point
// Filter<U, NB, NA> to a stored fixed-point representation at construction.
// All filtering operations are inherited from Filter and run in TFixed
// arithmetic.
//
// Typical use:
// constexpr Butterworth<double, 4> proto(100.0, 1000.0);
// constexpr FixedFilter<FixedPoint<short, int, 15>, 5, 5> filt(proto);
template <typename TFixed, consteig::Size NB, consteig::Size NA>
class FixedFilter : public Filter<TFixed, NB, NA>
{
public:
template <typename U>
constexpr explicit FixedFilter(const Filter<U, NB, NA> &src)
: Filter<TFixed, NB, NA>(src)
{
}
};

} // namespace constfilt

#endif // CONSTFILT_FIXED_FILTER_HPP
Loading