From c183e2e8c0b72ab487eab6e238dbaa330c3bc9c4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 19 Sep 2026 21:52:22 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20FIR=20Q15=20=D0=B8=20=D0=BE=D0=B1=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20golden=20vectors=20=D0=B4=D0=BB=D1=8F=20Python=20?= =?UTF-8?q?=D0=B8=20C++?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 1 + README.md | 5 ++ dsp_core/CMakeLists.txt | 8 +++ dsp_core/include/dsp_core/fixed_fir.hpp | 36 +++++++++++ dsp_core/tests/dsp_fixed_fir_test.cpp | 59 +++++++++++++++++++ tools/generate_fir_vectors.py | 49 +++++++++++++++ verification/vectors/fir_q15/README.md | 19 ++++++ .../vectors/fir_q15/impulse_expected.csv | 7 +++ .../vectors/fir_q15/impulse_input.csv | 5 ++ verification/vectors/fir_q15/impulse_taps.csv | 4 ++ .../fir_q15/negative_taps_expected.csv | 6 ++ .../vectors/fir_q15/negative_taps_input.csv | 5 ++ .../vectors/fir_q15/negative_taps_taps.csv | 3 + .../vectors/fir_q15/saturation_expected.csv | 6 ++ .../vectors/fir_q15/saturation_input.csv | 5 ++ .../vectors/fir_q15/saturation_taps.csv | 3 + .../vectors/fir_q15/signed_expected.csv | 10 ++++ verification/vectors/fir_q15/signed_input.csv | 8 +++ verification/vectors/fir_q15/signed_taps.csv | 4 ++ .../vectors/fir_q15/ties_expected.csv | 7 +++ verification/vectors/fir_q15/ties_input.csv | 7 +++ verification/vectors/fir_q15/ties_taps.csv | 2 + 22 files changed, 259 insertions(+) create mode 100644 .gitattributes create mode 100644 dsp_core/include/dsp_core/fixed_fir.hpp create mode 100644 dsp_core/tests/dsp_fixed_fir_test.cpp create mode 100644 tools/generate_fir_vectors.py create mode 100644 verification/vectors/fir_q15/README.md create mode 100644 verification/vectors/fir_q15/impulse_expected.csv create mode 100644 verification/vectors/fir_q15/impulse_input.csv create mode 100644 verification/vectors/fir_q15/impulse_taps.csv create mode 100644 verification/vectors/fir_q15/negative_taps_expected.csv create mode 100644 verification/vectors/fir_q15/negative_taps_input.csv create mode 100644 verification/vectors/fir_q15/negative_taps_taps.csv create mode 100644 verification/vectors/fir_q15/saturation_expected.csv create mode 100644 verification/vectors/fir_q15/saturation_input.csv create mode 100644 verification/vectors/fir_q15/saturation_taps.csv create mode 100644 verification/vectors/fir_q15/signed_expected.csv create mode 100644 verification/vectors/fir_q15/signed_input.csv create mode 100644 verification/vectors/fir_q15/signed_taps.csv create mode 100644 verification/vectors/fir_q15/ties_expected.csv create mode 100644 verification/vectors/fir_q15/ties_input.csv create mode 100644 verification/vectors/fir_q15/ties_taps.csv diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..375bbf9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +verification/vectors/fir_q15/*.csv text eol=lf diff --git a/README.md b/README.md index 42bef41..ecd1396 100644 --- a/README.md +++ b/README.md @@ -168,3 +168,8 @@ the code is small enough to study, but structured enough to demonstrate how DSP ## License MIT +# Fixed-point FIR evidence + +The [shared Q15 vector package](verification/vectors/fir_q15/README.md) checks +Python reference, C++ float and C++ fixed-point convolution with explicit +rounding and saturation. RTL and hardware qualification remain future work. diff --git a/dsp_core/CMakeLists.txt b/dsp_core/CMakeLists.txt index 4c24cb9..e714334 100644 --- a/dsp_core/CMakeLists.txt +++ b/dsp_core/CMakeLists.txt @@ -65,12 +65,20 @@ endif() if(BUILD_TESTING) add_executable(dsp_core_tests tests/dsp_fir_test.cpp + tests/dsp_fixed_fir_test.cpp tests/dsp_goertzel_test.cpp tests/dsp_gcc_phat_test.cpp tests/dsp_resampler_test.cpp tests/dsp_api_validation_test.cpp ) target_link_libraries(dsp_core_tests PRIVATE dsp_core::dsp GTest::gtest_main) + target_compile_definitions(dsp_core_tests PRIVATE + DSP_FIR_VECTOR_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../verification/vectors/fir_q15") + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + add_test(NAME fir_vector_regeneration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../tools/generate_fir_vectors.py --check) + endif() dsp_core_enable_warnings(dsp_core_tests) include(GoogleTest) diff --git a/dsp_core/include/dsp_core/fixed_fir.hpp b/dsp_core/include/dsp_core/fixed_fir.hpp new file mode 100644 index 0000000..2cbfa12 --- /dev/null +++ b/dsp_core/include/dsp_core/fixed_fir.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace dsp_core::dsp { + +// Full convolution, signed Q1.15 inputs/taps, zero initial state and zero tail. +// Accumulate exactly in int64, round once (nearest, ties away from zero), +// then saturate. The tap bound keeps even worst-case sums well inside int64. +inline std::vector convolve_q15( + const std::vector& input, + const std::vector& taps) { + if (taps.size() > 65536) { + throw std::invalid_argument("Q15 FIR supports at most 65536 taps"); + } + if (input.empty() || taps.empty()) return {}; + std::vector output(input.size() + taps.size() - 1); + for (std::size_t n = 0; n < output.size(); ++n) { + std::int64_t sum = 0; + const auto first = n >= input.size() ? n - input.size() + 1 : 0; + const auto last = std::min(n + 1, taps.size()); + for (std::size_t k = first; k < last; ++k) { + sum += static_cast(input[n - k]) * taps[k]; + } + // Signed division is defined in C++17; no implementation-defined + // right shift of a negative integer is used here. + const auto rounded = sum < 0 ? -((-sum + 16384) / 32768) : (sum + 16384) / 32768; + output[n] = static_cast(std::clamp(rounded, -32768, 32767)); + } + return output; +} + +} // namespace dsp_core::dsp diff --git a/dsp_core/tests/dsp_fixed_fir_test.cpp b/dsp_core/tests/dsp_fixed_fir_test.cpp new file mode 100644 index 0000000..555d0fd --- /dev/null +++ b/dsp_core/tests/dsp_fixed_fir_test.cpp @@ -0,0 +1,59 @@ +#include "dsp_core/dsp.hpp" +#include "dsp_core/fixed_fir.hpp" +#include +#include +#include +#include + +namespace { +std::vector load(const std::string& path) { + std::ifstream stream(path); + if (!stream) throw std::runtime_error("missing vector: " + path); + std::string line; + std::getline(stream, line); + std::vector values; + while (std::getline(stream, line)) values.push_back(static_cast(std::stoi(line))); + return values; +} +} + +TEST(DspFixedFir, SharedPythonVectorsMatchFixedAndFloatingConvolution) { + for (const auto* name : {"impulse", "signed", "ties", "saturation", "negative_taps"}) { + SCOPED_TRACE(name); + const std::string base = std::string(DSP_FIR_VECTOR_DIR) + "/" + name; + const auto input = load(base + "_input.csv"); + const auto taps = load(base + "_taps.csv"); + const auto fixed = dsp_core::dsp::convolve_q15(input, taps); + dsp_core::dsp::Signal x, h; + for (auto value : input) x.push_back(value / 32768.0); + for (auto value : taps) h.push_back(value / 32768.0); + const auto floating = dsp_core::dsp::convolve(x, h); + std::ifstream expected(base + "_expected.csv"); + ASSERT_TRUE(expected.good()); + std::string line; + std::getline(expected, line); + std::size_t n = 0; + while (std::getline(expected, line)) { + ASSERT_LT(n, fixed.size()); + ASSERT_LT(n, floating.size()); + const auto comma = line.find(','); + ASSERT_NE(comma, std::string::npos); + EXPECT_EQ(fixed[n], std::stoi(line.substr(0, comma))); + const auto reference = std::stod(line.substr(comma + 1)); + EXPECT_NEAR(floating[n], reference, 1e-12); + if (reference >= -1.0 && reference <= 32767.0 / 32768.0) { + EXPECT_LE(std::abs(reference - fixed[n] / 32768.0), 0.5 / 32768.0); + } + ++n; + } + EXPECT_EQ(n, fixed.size()); + EXPECT_EQ(n, floating.size()); + } +} + +TEST(DspFixedFir, EmptyInputsAndAccumulatorBound) { + EXPECT_TRUE(dsp_core::dsp::convolve_q15({}, {1}).empty()); + EXPECT_TRUE(dsp_core::dsp::convolve_q15({1}, {}).empty()); + EXPECT_THROW(dsp_core::dsp::convolve_q15({1}, std::vector(65537)), std::invalid_argument); + EXPECT_EQ(dsp_core::dsp::convolve_q15({-32768}, {-32768})[0], 32767); +} diff --git a/tools/generate_fir_vectors.py b/tools/generate_fir_vectors.py new file mode 100644 index 0000000..6446255 --- /dev/null +++ b/tools/generate_fir_vectors.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Independent integer-convolution fixtures, shared as plain CSV; no numpy needed.""" +import argparse +from fractions import Fraction +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "verification/vectors/fir_q15" +CASES = { + "impulse": ([32767, 0, 0, 0], [8192, 16384, 8192]), + "signed": ([1234, -2345, 32767, -32768, 1, -1, 0], [16384, -8192, 4096]), + "ties": ([1, -1, 3, -3, 32767, -32768], [16384]), + "saturation": ([32767, 32767, -32768, -32768], [32767, 32767]), + "negative_taps": ([-32768, 32767, 0, 0], [-32768, -32768]), +} + + +def fixtures(): + for name, (samples, taps) in CASES.items(): + products = [0] * (len(samples) + len(taps) - 1) + for i, sample in enumerate(samples): + for j, tap in enumerate(taps): + products[i + j] += sample * tap + yield f"{name}_input.csv", "q15\n" + "".join(f"{v}\n" for v in samples) + yield f"{name}_taps.csv", "q15\n" + "".join(f"{v}\n" for v in taps) + rows = ["q15,float64\n"] + for value in products: + exact = Fraction(value, 32768) + rounded = int(abs(exact) + Fraction(1, 2)) * (-1 if exact < 0 else 1) + rows.append(f"{max(-32768, min(32767, rounded))},{value / 1073741824:.17g}\n") + yield f"{name}_expected.csv", "".join(rows) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + ROOT.mkdir(parents=True, exist_ok=True) + for name, value in fixtures(): + path = ROOT / name + if args.check: + if not path.exists() or path.read_bytes() != value.encode("ascii"): + raise SystemExit(f"stale vector: {path}") + else: + path.write_bytes(value.encode("ascii")) + print("FIR Q15 shared vectors: PASS") + + +if __name__ == "__main__": + main() diff --git a/verification/vectors/fir_q15/README.md b/verification/vectors/fir_q15/README.md new file mode 100644 index 0000000..d03095b --- /dev/null +++ b/verification/vectors/fir_q15/README.md @@ -0,0 +1,19 @@ +# Shared FIR Q15 vectors + +Run `python tools/generate_fir_vectors.py --check` from the repository root. +Omit `--check` to regenerate the committed CSV files with the independent Python +integer/Fraction reference. CTest consumes these same files for C++ floating +convolution and `dsp_core::dsp::convolve_q15`. + +Inputs and coefficients are signed Q1.15 integers. Output is full convolution +including the zero-padded tail; state starts at zero. The fixed path accumulates +in int64, rounds once to nearest with ties away from zero and saturates to +[-32768, 32767]. At most 65536 taps are accepted. The float column is the +unclipped convolution of the quantized operands, not a separately designed +floating filter. Unclipped fixed output must agree within half a Q15 LSB. + +Cases exercise impulse response, signed samples/taps, positive and negative +rounding ties, both saturation rails and the -32768 endpoint. Plain CSV allows +MATLAB (`readtable`) and a future RTL testbench to consume the identical data. +This is Python/C++ simulation evidence; MATLAB, RTL and board agreement remain +unmeasured. It does not establish filter-design quantization error or RF accuracy. diff --git a/verification/vectors/fir_q15/impulse_expected.csv b/verification/vectors/fir_q15/impulse_expected.csv new file mode 100644 index 0000000..23d516e --- /dev/null +++ b/verification/vectors/fir_q15/impulse_expected.csv @@ -0,0 +1,7 @@ +q15,float64 +8192,0.24999237060546875 +16384,0.4999847412109375 +8192,0.24999237060546875 +0,0 +0,0 +0,0 diff --git a/verification/vectors/fir_q15/impulse_input.csv b/verification/vectors/fir_q15/impulse_input.csv new file mode 100644 index 0000000..b41ace9 --- /dev/null +++ b/verification/vectors/fir_q15/impulse_input.csv @@ -0,0 +1,5 @@ +q15 +32767 +0 +0 +0 diff --git a/verification/vectors/fir_q15/impulse_taps.csv b/verification/vectors/fir_q15/impulse_taps.csv new file mode 100644 index 0000000..f25bcab --- /dev/null +++ b/verification/vectors/fir_q15/impulse_taps.csv @@ -0,0 +1,4 @@ +q15 +8192 +16384 +8192 diff --git a/verification/vectors/fir_q15/negative_taps_expected.csv b/verification/vectors/fir_q15/negative_taps_expected.csv new file mode 100644 index 0000000..4e18a64 --- /dev/null +++ b/verification/vectors/fir_q15/negative_taps_expected.csv @@ -0,0 +1,6 @@ +q15,float64 +32767,1 +1,3.0517578125e-05 +-32767,-0.999969482421875 +0,0 +0,0 diff --git a/verification/vectors/fir_q15/negative_taps_input.csv b/verification/vectors/fir_q15/negative_taps_input.csv new file mode 100644 index 0000000..3bf56cd --- /dev/null +++ b/verification/vectors/fir_q15/negative_taps_input.csv @@ -0,0 +1,5 @@ +q15 +-32768 +32767 +0 +0 diff --git a/verification/vectors/fir_q15/negative_taps_taps.csv b/verification/vectors/fir_q15/negative_taps_taps.csv new file mode 100644 index 0000000..91ae7cd --- /dev/null +++ b/verification/vectors/fir_q15/negative_taps_taps.csv @@ -0,0 +1,3 @@ +q15 +-32768 +-32768 diff --git a/verification/vectors/fir_q15/saturation_expected.csv b/verification/vectors/fir_q15/saturation_expected.csv new file mode 100644 index 0000000..743a970 --- /dev/null +++ b/verification/vectors/fir_q15/saturation_expected.csv @@ -0,0 +1,6 @@ +q15,float64 +32766,0.99993896577507257 +32767,1.9998779315501451 +-1,-3.0516646802425385e-05 +-32768,-1.99993896484375 +-32767,-0.999969482421875 diff --git a/verification/vectors/fir_q15/saturation_input.csv b/verification/vectors/fir_q15/saturation_input.csv new file mode 100644 index 0000000..d43e321 --- /dev/null +++ b/verification/vectors/fir_q15/saturation_input.csv @@ -0,0 +1,5 @@ +q15 +32767 +32767 +-32768 +-32768 diff --git a/verification/vectors/fir_q15/saturation_taps.csv b/verification/vectors/fir_q15/saturation_taps.csv new file mode 100644 index 0000000..6dabccf --- /dev/null +++ b/verification/vectors/fir_q15/saturation_taps.csv @@ -0,0 +1,3 @@ +q15 +32767 +32767 diff --git a/verification/vectors/fir_q15/signed_expected.csv b/verification/vectors/fir_q15/signed_expected.csv new file mode 100644 index 0000000..7c54885 --- /dev/null +++ b/verification/vectors/fir_q15/signed_expected.csv @@ -0,0 +1,10 @@ +q15,float64 +617,0.018829345703125 +-1481,-0.045196533203125 +17124,0.5225830078125 +-24869,-0.75893783569335938 +12288,0.37501144409179688 +-4097,-0.12502288818359375 +0,1.1444091796875e-05 +0,-3.814697265625e-06 +0,0 diff --git a/verification/vectors/fir_q15/signed_input.csv b/verification/vectors/fir_q15/signed_input.csv new file mode 100644 index 0000000..843b534 --- /dev/null +++ b/verification/vectors/fir_q15/signed_input.csv @@ -0,0 +1,8 @@ +q15 +1234 +-2345 +32767 +-32768 +1 +-1 +0 diff --git a/verification/vectors/fir_q15/signed_taps.csv b/verification/vectors/fir_q15/signed_taps.csv new file mode 100644 index 0000000..69d5f1d --- /dev/null +++ b/verification/vectors/fir_q15/signed_taps.csv @@ -0,0 +1,4 @@ +q15 +16384 +-8192 +4096 diff --git a/verification/vectors/fir_q15/ties_expected.csv b/verification/vectors/fir_q15/ties_expected.csv new file mode 100644 index 0000000..0366964 --- /dev/null +++ b/verification/vectors/fir_q15/ties_expected.csv @@ -0,0 +1,7 @@ +q15,float64 +1,1.52587890625e-05 +-1,-1.52587890625e-05 +2,4.57763671875e-05 +-2,-4.57763671875e-05 +16384,0.4999847412109375 +-16384,-0.5 diff --git a/verification/vectors/fir_q15/ties_input.csv b/verification/vectors/fir_q15/ties_input.csv new file mode 100644 index 0000000..38ca316 --- /dev/null +++ b/verification/vectors/fir_q15/ties_input.csv @@ -0,0 +1,7 @@ +q15 +1 +-1 +3 +-3 +32767 +-32768 diff --git a/verification/vectors/fir_q15/ties_taps.csv b/verification/vectors/fir_q15/ties_taps.csv new file mode 100644 index 0000000..c380f6d --- /dev/null +++ b/verification/vectors/fir_q15/ties_taps.csv @@ -0,0 +1,2 @@ +q15 +16384 From 5bcdaff12aee7fd6efdb00f699774789ac5ecb90 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 19 Sep 2026 22:03:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=A3=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20Windows-=D0=BA=D0=BE=D0=BB=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=20pull=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 25 ------------------------- .github/pull_request_template.md | 4 ++++ README.md | 2 +- 3 files changed, 5 insertions(+), 26 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 4fedfcf..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,25 +0,0 @@ -## What changed - -Describe the implementation or documentation change in 3–5 lines. - -## Why this change is needed - -Explain the DSP, C++, verification, documentation or benchmark problem this PR solves. - -## Validation - -- [ ] CMake configure passed -- [ ] Build passed -- [ ] CTest passed -- [ ] Benchmark/report updated, if relevant -- [ ] Generated plots are reproducible, if relevant -- [ ] Documentation updated, if relevant -- [ ] No private data or credentials committed - -## Evidence - -Link or attach logs, plots, benchmark tables, generated reports or test vectors. - -## Risks - -What can regress? What numerical assumptions or tolerances changed? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index edcc03a..65d4ab2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,3 +16,7 @@ Describe the DSP kernel, test, benchmark, packaging, or documentation change. ## Evidence List commands, tests, benchmark results, plots, or reports used to verify the change. + +## Risks + +Describe numerical assumptions, changed tolerances and possible regressions. diff --git a/README.md b/README.md index ecd1396..144723a 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ the code is small enough to study, but structured enough to demonstrate how DSP ## License MIT -# Fixed-point FIR evidence +## Fixed-point FIR evidence The [shared Q15 vector package](verification/vectors/fir_q15/README.md) checks Python reference, C++ float and C++ fixed-point convolution with explicit