diff --git a/RELEASE.md b/RELEASE.md index e8f208c3..1c024913 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -20,6 +20,11 @@ ``expand4to8bit`` and ``expand24to32bit`` accept const input views. ### Bugfixes: + +- ``ClusterFile::write_frame`` now reports incomplete writes instead of + silently continuing with a truncated file. +- Gain-map application now checks the complete cluster footprint, preventing + out-of-bounds access for cluster sizes larger than 3x3. - Fixed broken reading of old (pre reordering) Moench03 ## 2026.7.2 @@ -168,5 +173,3 @@ dhanya.thattil@psi.ch - - diff --git a/docs/src/ClusterFile.rst b/docs/src/ClusterFile.rst index a2ee162b..01dd372c 100644 --- a/docs/src/ClusterFile.rst +++ b/docs/src/ClusterFile.rst @@ -4,5 +4,3 @@ ClusterFile .. doxygenclass:: aare::ClusterFile :members: :undoc-members: - :private-members: - diff --git a/docs/src/python/file/pyClusterFile.rst b/docs/src/python/file/pyClusterFile.rst index cd391e0e..9eea1277 100644 --- a/docs/src/python/file/pyClusterFile.rst +++ b/docs/src/python/file/pyClusterFile.rst @@ -1,26 +1,36 @@ ClusterFile -============ +=========== +The :func:`ClusterFile` factory is the main interface for reading and writing +legacy cluster files. Use ``mode="r"`` to read, ``mode="w"`` to truncate and +write, or ``mode="a"`` to append. -The :class:`ClusterFile` class is the main interface to read and write clusters in aare. Unfortunately the -old file format does not include metadata like the cluster size and the data type. This means that the -user has to know this information from other sources. Specifying the wrong cluster size or data type -will lead to garbage data being read. +The format does not store the cluster dimensions, value type, coordinate type, +padding, or byte order. The ``cluster_size`` and ``dtype`` arguments must match +the writer; otherwise the bytes are interpreted incorrectly. + +Use ``read_frame()`` when frame boundaries and the frame number matter. +Iteration and ``read_clusters()`` return chunks of up to ``chunk_size`` +selected clusters. A chunk may combine frames, so its frame number is not +reliable per-cluster metadata. + +When a gain map is configured, it is applied to every cluster whose complete +footprint lies inside the map. Clusters whose footprint extends beyond the +gain-map boundaries remain in the returned cluster vector, but all their data +values are set to zero. .. py:currentmodule:: aare -.. autoclass:: ClusterFile - :members: - :undoc-members: - :inherited-members: +.. autofunction:: ClusterFile -Below is the API of the ClusterFile_Cluster3x3i but all variants share the same API. +Below is the API of ``ClusterFile_Cluster3x3i``; all compiled variants share the +same API. .. autoclass:: aare._aare.ClusterFile_Cluster3x3i :special-members: __init__ :members: :undoc-members: :show-inheritance: - :inherited-members: \ No newline at end of file + :inherited-members: diff --git a/include/aare/ClusterFile.hpp b/include/aare/ClusterFile.hpp index 06843700..1e2af1f2 100644 --- a/include/aare/ClusterFile.hpp +++ b/include/aare/ClusterFile.hpp @@ -15,29 +15,19 @@ namespace aare { -/* -Binary cluster file. Expects data to be laid out as: -int32_t frame_number -uint32_t number_of_clusters -int16_t x, int16_t y, int32_t data[9] x number_of_clusters -int32_t frame_number -uint32_t number_of_clusters -.... -*/ - -// TODO: change to support any type of clusters, e.g. header line with -// clsuter_size_x, cluster_size_y, /** - * @brief Class to read and write cluster files - * Expects data to be laid out as: + * @brief Read and write legacy binary cluster files. * + * Each frame is stored as: * * int32_t frame_number * uint32_t number_of_clusters - * int16_t x, int16_t y, int32_t data[9] * number_of_clusters - * int32_t frame_number - * uint32_t number_of_clusters - * etc. + * ClusterType clusters[number_of_clusters] + * + * The format stores clusters as their native in-memory representation and has + * no metadata describing the cluster dimensions, value type, coordinate type, + * padding, or byte order. Readers must therefore use the same ClusterType and + * a compatible platform ABI as the writer. */ template >> @@ -55,13 +45,13 @@ class ClusterFile { public: /** - * @brief Construct a new Cluster File object - * @param fname path to the file - * @param chunk_size number of clusters to read at a time when iterating - * over the file - * @param mode mode to open the file in. "r" for reading, "w" for writing, - * "a" for appending - * @throws std::runtime_error if the file could not be opened + * @brief Open a cluster file. + * @param fname Path to the file. + * @param chunk_size Number of clusters returned by each iterator step. + * @param mode File mode: "r" to read, "w" to truncate and write, or "a" + * to append. + * @throws std::runtime_error If the mode is unsupported or the file cannot + * be opened. */ ClusterFile(const std::filesystem::path &fname, size_t chunk_size = 1000, const std::string &mode = "r") @@ -71,9 +61,13 @@ class ClusterFile { } /** - * @brief Read n_clusters clusters from the file discarding - * frame numbers. If EOF is reached the returned vector will - * have less than n_clusters clusters + * @brief Read up to n_clusters without preserving frame boundaries. + * @param n_clusters Maximum number of selected clusters to return. + * @return A cluster vector that may contain fewer clusters at end of file. + * @note The returned vector may combine data from several frames, so its + * frame number must not be used as per-cluster metadata. + * @throws std::runtime_error If the file is not open for reading or a + * filtered read encounters an incomplete cluster record. */ ClusterVector read_clusters(size_t n_clusters) { if (m_mode != "r") { @@ -87,12 +81,10 @@ class ClusterFile { } /** - * @brief Read a single frame from the file and return the - * clusters. The cluster vector will have the frame number - * set. - * @throws std::runtime_error if the file is not opened for - * reading or the file pointer not at the beginning of a - * frame + * @brief Read the next complete frame. + * @return The selected clusters with the stored frame number set. + * @throws std::runtime_error If the file is not open for reading, a prior + * partial-frame read left clusters unread, or the frame is incomplete. */ ClusterVector read_frame() { if (m_mode != "r") { @@ -105,57 +97,73 @@ class ClusterFile { } } + /** + * @brief Write one frame to the file. + * @param clusters Clusters to write, including their frame number. + * @throws std::runtime_error If the file is not open for writing or any + * part of the frame cannot be written completely. + */ void write_frame(const ClusterVector &clusters) { if (m_mode != "w" && m_mode != "a") { throw std::runtime_error("File not opened for writing"); } int32_t frame_number = clusters.frame_number(); - fwrite(&frame_number, sizeof(frame_number), 1, m_fp.get()); + if (fwrite(&frame_number, sizeof(frame_number), 1, m_fp.get()) != 1) { + throw std::runtime_error(LOCATION + "Could not write frame number"); + } + uint32_t n_clusters = clusters.size(); - fwrite(&n_clusters, sizeof(n_clusters), 1, m_fp.get()); - fwrite(clusters.data(), clusters.item_size(), clusters.size(), - m_fp.get()); + if (fwrite(&n_clusters, sizeof(n_clusters), 1, m_fp.get()) != 1) { + throw std::runtime_error(LOCATION + + "Could not write number of clusters"); + } + + if (fwrite(clusters.data(), clusters.item_size(), clusters.size(), + m_fp.get()) != clusters.size()) { + throw std::runtime_error(LOCATION + "Could not write clusters"); + } } /** - * @brief Return the chunk size + * @brief Return the number of clusters requested by each iterator step. */ size_t chunk_size() const { return m_chunk_size; } /** - * @brief Estimate the number of clusters in the file from its size + * @brief Estimate the number of clusters in the file from its size. * - * Frame headers are included in the estimate, so the result may be slightly - * larger than the actual number of clusters. The file position is not - * changed. + * Frame-header bytes are included in the estimate, so it may exceed the + * actual number of clusters. The file position is not changed. */ size_t estimate_n_clusters() const { return std::filesystem::file_size(m_filename) / sizeof(ClusterType); } /** - * @brief Set the region of interest to use when reading - * clusters. If set only clusters within the ROI will be - * read. + * @brief Select clusters by their center coordinate when reading. + * @param roi Half-open region of interest: [xmin, xmax) x [ymin, ymax). */ void set_roi(ROI roi) { m_roi = roi; } /** - * @brief Set the noise map to use when reading clusters. If - * set clusters below the noise level will be discarded. - * Selection criteria one of: Central pixel above noise, - * highest 2x2 sum above 2 * noise, total sum above 3 * - * noise. + * @brief Discard clusters that do not pass the noise thresholds. + * @param noise_map Per-pixel noise indexed as [y, x]. The map is copied. + * A cluster is retained only when its central pixel exceeds the local + * noise, its highest 2x2 sum exceeds twice the noise, and its total sum + * exceeds three times the noise. + * @warning The map must cover every cluster center coordinate in the file. */ void set_noise_map(const NDView noise_map) { m_noise_map = NDArray(noise_map); } /** - * @brief Set the gain map to use when reading clusters. If set the gain map - * will be applied to the clusters that pass ROI and noise_map selection. - * The gain map is expected to be in ADU/energy. + * @brief Apply a gain map to clusters selected while reading. + * @param gain_map Per-pixel gain in ADU/energy, indexed as [y, x]. The map + * is copied and inverted internally. + * @note Clusters whose complete footprint extends beyond the gain map are + * retained with all cluster data values set to zero. */ void set_gain_map(const NDView gain_map) { m_gain_map = InvertedGainMap(gain_map); @@ -170,16 +178,20 @@ class ClusterFile { } /** - * @brief Close the file. If not closed the file will be - * closed in the destructor + * @brief Close the file. + * + * Calling close more than once is safe. The destructor closes an open file + * automatically. */ - void close() { - m_fp = FilePtr{}; + void close() { + m_fp = FilePtr{}; m_mode = ""; } /** - * @brief Return the current position in the file (bytes) + * @brief Return the current byte position in the file. + * @throws std::runtime_error If the file is closed or its position cannot + * be determined. */ int64_t tell() { if (!m_fp.get()) { diff --git a/include/aare/GainMap.hpp b/include/aare/GainMap.hpp index ff888915..ad11f8be 100644 --- a/include/aare/GainMap.hpp +++ b/include/aare/GainMap.hpp @@ -34,29 +34,37 @@ class InvertedGainMap { template >> void apply_gain_map(ClusterVector &clustervec) { - // in principle we need to know the size of the image for this lookup - size_t ClusterSizeX = clustervec.cluster_size_x(); - size_t ClusterSizeY = clustervec.cluster_size_y(); - using T = typename ClusterVector::value_type; - int64_t index_cluster_center_x = ClusterSizeX / 2; - int64_t index_cluster_center_y = ClusterSizeY / 2; + constexpr size_t cluster_size_x = ClusterType::cluster_size_x; + constexpr size_t cluster_size_y = ClusterType::cluster_size_y; + constexpr ssize_t left = cluster_size_x / 2; + constexpr ssize_t right = cluster_size_x - left - 1; + constexpr ssize_t top = cluster_size_y / 2; + constexpr ssize_t bottom = cluster_size_y - top - 1; + for (size_t i = 0; i < clustervec.size(); i++) { auto &cl = clustervec[i]; + const auto center_x = static_cast(cl.x); + const auto center_y = static_cast(cl.y); - if (cl.x > 0 && cl.y > 0 && cl.x < m_gain_map.shape(1) - 1 && - cl.y < m_gain_map.shape(0) - 1) { - for (size_t j = 0; j < ClusterSizeX * ClusterSizeY; j++) { - size_t x = cl.x + j % ClusterSizeX - index_cluster_center_x; - size_t y = cl.y + j / ClusterSizeX - index_cluster_center_y; + if (center_x >= left && center_y >= top && + center_x < m_gain_map.shape(1) - right && + center_y < m_gain_map.shape(0) - bottom) { + for (size_t j = 0; j < cluster_size_x * cluster_size_y; j++) { + const auto x = center_x + + static_cast(j % cluster_size_x) - + left; + const auto y = center_y + + static_cast(j / cluster_size_x) - + top; cl.data[j] = static_cast( static_cast(cl.data[j]) * m_gain_map( y, x)); // cast after conversion to keep precision } } else { - // clear edge clusters + // Clear clusters whose footprint extends beyond the gain map. cl.data.fill(0); } } @@ -66,4 +74,4 @@ class InvertedGainMap { NDArray m_gain_map{}; }; -} // end of namespace aare \ No newline at end of file +} // end of namespace aare diff --git a/python/aare/ClusterFinder.py b/python/aare/ClusterFinder.py index eae7ad7d..1ef29ac9 100644 --- a/python/aare/ClusterFinder.py +++ b/python/aare/ClusterFinder.py @@ -69,19 +69,47 @@ def ClusterFileSink(clusterfindermt, cluster_file, dtype=np.int32): def ClusterFile(fname, cluster_size=(3,3), dtype=np.int32, chunk_size = 1000, mode = "r"): - """ - Factory function to create a ClusterFile object. Provides a cleaner syntax for - the templated ClusterFile in C++. + """Create a reader or writer for a legacy binary cluster file. + + Parameters + ---------- + fname : path-like + Cluster file to open. + cluster_size : tuple[int, int], default=(3, 3) + Cluster dimensions stored in the file. + dtype : numpy dtype, default=numpy.int32 + Data type of the cluster values stored in the file. + chunk_size : int, default=1000 + Maximum number of selected clusters returned by each iterator step. + mode : {"r", "w", "a"}, default="r" + Open for reading, truncate and write, or append, respectively. + + Returns + ------- + ClusterFile + The compiled ClusterFile specialization matching ``cluster_size`` and + ``dtype``. + + Notes + ----- + The file format contains no cluster shape or data-type metadata. Supplying + values that do not match the file causes its bytes to be interpreted + incorrectly. Iterator chunks may combine frames, so their frame number is + not reliable per-cluster metadata. + + Examples + -------- .. code-block:: python from aare import ClusterFile - - with ClusterFile("clusters.clust", cluster_size=(3,3), dtype=np.int32) as cf: - # cf is now a ClusterFile_Cluster3x3i object but you don't need to know that. + + with ClusterFile( + "clusters.clust", cluster_size=(3, 3), dtype=np.int32 + ) as cf: for clusters in cf: - # Loop over clusters in chunks of 1000 - # The type of clusters will be a ClusterVector_Cluster3x3i in this case + # Process clusters in chunks of at most 1000. + ... """ diff --git a/python/src/bind_ClusterFile.hpp b/python/src/bind_ClusterFile.hpp index 0831d64f..be6cb988 100644 --- a/python/src/bind_ClusterFile.hpp +++ b/python/src/bind_ClusterFile.hpp @@ -28,10 +28,16 @@ void define_ClusterFile(py::module &m, const std::string &typestr) { auto class_name = fmt::format("ClusterFile_{}", typestr); - py::class_>(m, class_name.c_str()) + py::class_>( + m, class_name.c_str(), + "Read and write legacy binary cluster files. The format contains no " + "cluster type or shape metadata, so this class must match the file.") .def(py::init(), - py::arg(), py::arg("chunk_size") = 1000, py::arg("mode") = "r") + py::arg("fname"), py::arg("chunk_size") = 1000, + py::arg("mode") = "r", + "Open a cluster file. Mode must be 'r' to read, 'w' to truncate " + "and write, or 'a' to append.") .def( "read_clusters", [](ClusterFile &self, size_t n_clusters) { @@ -39,32 +45,52 @@ void define_ClusterFile(py::module &m, const std::string &typestr) { self.read_clusters(n_clusters)); return v; }, - py::return_value_policy::take_ownership, py::arg("n_clusters")) - .def("read_frame", - [](ClusterFile &self) { - auto v = new ClusterVector(self.read_frame()); - return v; - }) - .def("set_roi", &ClusterFile::set_roi, py::arg("roi")) - .def("tell", &ClusterFile::tell) + py::return_value_policy::take_ownership, py::arg("n_clusters"), + "Read up to n_clusters without preserving frame boundaries. The " + "result may combine frames, so its frame number is not reliable " + "per-cluster metadata.") + .def( + "read_frame", + [](ClusterFile &self) { + auto v = new ClusterVector(self.read_frame()); + return v; + }, + "Read and return the next complete frame with its frame number.") + .def("set_roi", &ClusterFile::set_roi, py::arg("roi"), + "Select clusters whose centers lie within the half-open ROI.") + .def("tell", &ClusterFile::tell, + "Return the current byte position in the file.") .def("estimate_n_clusters", - &ClusterFile::estimate_n_clusters) + &ClusterFile::estimate_n_clusters, + "Estimate the number of clusters from the file size. Frame " + "headers can make this larger than the actual count.") .def( "set_noise_map", [](ClusterFile &self, py::array_t noise_map) { auto view = make_view_2d(noise_map); self.set_noise_map(view); }, - py::arg("noise_map")) + py::arg("noise_map"), + "Set a two-dimensional, C-contiguous int32 noise map indexed as " + "[y, x]. The map must cover every cluster center coordinate.") - .def("set_gain_map", - [](ClusterFile &self, py::array_t gain_map) { - auto view = make_view_2d(gain_map); - self.set_gain_map(view); - }) + .def( + "set_gain_map", + [](ClusterFile &self, py::array_t gain_map) { + auto view = make_view_2d(gain_map); + self.set_gain_map(view); + }, + py::arg("gain_map"), + "Set a two-dimensional, C-contiguous float64 gain map in " + "ADU/energy, indexed as [y, x]. Clusters whose complete footprint " + "extends beyond the map are retained with all data values set to " + "zero.") - .def("close", &ClusterFile::close) - .def("write_frame", &ClusterFile::write_frame) + .def("close", &ClusterFile::close, + "Close the file. Calling close more than once is safe.") + .def("write_frame", &ClusterFile::write_frame, + py::arg("clusters"), + "Write one ClusterVector, including its frame number.") .def("__enter__", [](ClusterFile &self) { return &self; }) .def("__exit__", [](ClusterFile &self, diff --git a/src/ClusterFile.test.cpp b/src/ClusterFile.test.cpp index c3d5fbfe..72eb9375 100644 --- a/src/ClusterFile.test.cpp +++ b/src/ClusterFile.test.cpp @@ -5,12 +5,72 @@ #include "aare/defs.hpp" #include #include +#include +#include #include +#include +#include using aare::Cluster; using aare::ClusterFile; using aare::ClusterVector; +namespace { + +class TemporaryClusterFile { + public: + TemporaryClusterFile() { + const auto unique = + std::chrono::steady_clock::now().time_since_epoch().count(); + m_path = std::filesystem::temp_directory_path() / + ("aare-cluster-file-" + std::to_string(unique) + ".clust"); + } + + TemporaryClusterFile(const TemporaryClusterFile &) = delete; + TemporaryClusterFile &operator=(const TemporaryClusterFile &) = delete; + + ~TemporaryClusterFile() { + std::error_code error; + std::filesystem::remove(m_path, error); + } + + const std::filesystem::path &path() const { return m_path; } + + private: + std::filesystem::path m_path; +}; + +using TestCluster = Cluster; + +ClusterVector make_test_frame(int32_t frame_number, + double offset) { + ClusterVector clusters(2, frame_number); + clusters.push_back( + TestCluster{5, + 6, + {offset, offset + 1, offset + 2, offset + 3, offset + 4, + offset + 5, offset + 6, offset + 7, offset + 8}}); + clusters.push_back(TestCluster{7, + 8, + {offset + 9, offset + 10, offset + 11, + offset + 12, offset + 13, offset + 14, + offset + 15, offset + 16, offset + 17}}); + return clusters; +} + +void check_frame(const ClusterVector &actual, + const ClusterVector &expected) { + REQUIRE(actual.frame_number() == expected.frame_number()); + REQUIRE(actual.size() == expected.size()); + for (size_t i = 0; i < expected.size(); ++i) { + CHECK(actual[i].x == expected[i].x); + CHECK(actual[i].y == expected[i].y); + CHECK(actual[i].data == expected[i].data); + } +} + +} // namespace + TEST_CASE("Read one frame from a cluster file", "[.with-data]") { // We know that the frame has 97 clusters auto fpath = test_data_path() / "clust" / "single_frame_97_clustrers.clust"; @@ -284,53 +344,64 @@ TEST_CASE("Read cluster from multiple frame file", "[.with-data]") { } } -TEST_CASE("Write cluster with potential padding", - "[.with-data][.ClusterFile]") { +TEST_CASE("ClusterFile flushes a frame when the writer is destroyed", + "[ClusterFile]") { + TemporaryClusterFile file; + auto expected = make_test_frame(42, 0.0); - using ClusterType = Cluster; + { + ClusterFile writer(file.path(), 1000, "w"); + writer.write_frame(expected); + } - REQUIRE(std::filesystem::exists(test_data_path() / "clust")); + const auto expected_file_size = sizeof(int32_t) + sizeof(uint32_t) + + expected.size() * sizeof(TestCluster); + CHECK(std::filesystem::file_size(file.path()) == expected_file_size); - auto fpath = test_data_path() / "clust" / "single_frame_2_clusters.clust"; - - ClusterFile file(fpath, 1000, "w"); - - ClusterVector clustervec(2); - uint16_t coordinate = 5; - clustervec.push_back(ClusterType{ - coordinate, coordinate, {0., 0., 0., 0., 0., 0., 0., 0., 0.}}); - clustervec.push_back(ClusterType{ - coordinate, coordinate, {0., 0., 0., 0., 0., 0., 0., 0., 0.}}); - - file.write_frame(clustervec); - - file.close(); - - ClusterFile read_file(fpath); - - auto read_cluster_vector = read_file.read_frame(); - - CHECK(read_cluster_vector.size() == 2); - CHECK(read_cluster_vector.frame_number() == 0); - - CHECK(read_cluster_vector[0].x == clustervec[0].x); - CHECK(read_cluster_vector[0].y == clustervec[0].y); - CHECK(std::equal( - clustervec[0].data.begin(), clustervec[0].data.end(), - read_cluster_vector[0].data.begin(), [](double a, double b) { - return std::abs(a - b) < std::numeric_limits::epsilon(); - })); - - CHECK(read_cluster_vector[1].x == clustervec[1].x); - CHECK(read_cluster_vector[1].y == clustervec[1].y); - CHECK(std::equal( - clustervec[1].data.begin(), clustervec[1].data.end(), - read_cluster_vector[1].data.begin(), [](double a, double b) { - return std::abs(a - b) < std::numeric_limits::epsilon(); - })); + ClusterFile reader(file.path()); + auto actual = reader.read_frame(); + check_frame(actual, expected); } -TEST_CASE("Read frame and modify cluster data", "[.with-data][.ClusterFile]") { +TEST_CASE("ClusterFile appends frames", "[ClusterFile]") { + TemporaryClusterFile file; + auto first_expected = make_test_frame(42, 0.0); + auto second_expected = make_test_frame(43, 100.0); + + { + ClusterFile writer(file.path(), 1000, "w"); + writer.write_frame(first_expected); + } + { + ClusterFile writer(file.path(), 1000, "a"); + writer.write_frame(second_expected); + } + + ClusterFile reader(file.path()); + auto first_actual = reader.read_frame(); + auto second_actual = reader.read_frame(); + check_frame(first_actual, first_expected); + check_frame(second_actual, second_expected); +} + +TEST_CASE("ClusterFile close is idempotent", "[ClusterFile]") { + TemporaryClusterFile file; + auto expected = make_test_frame(42, 0.0); + + ClusterFile writer(file.path(), 1000, "w"); + writer.write_frame(expected); + writer.close(); + + CHECK_NOTHROW(writer.close()); + CHECK_THROWS_AS(writer.tell(), std::runtime_error); + CHECK_THROWS_AS(writer.write_frame(expected), std::runtime_error); + + ClusterFile reader(file.path()); + auto actual = reader.read_frame(); + check_frame(actual, expected); +} + +TEST_CASE("Read frame and modify cluster data", "[.with-data]") { auto fpath = test_data_path() / "clust" / "single_frame_97_clustrers.clust"; REQUIRE(std::filesystem::exists(fpath)); diff --git a/src/ClusterVector.test.cpp b/src/ClusterVector.test.cpp index 33b0b98c..e4ae0d63 100644 --- a/src/ClusterVector.test.cpp +++ b/src/ClusterVector.test.cpp @@ -1,5 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 #include "aare/ClusterVector.hpp" +#include "aare/GainMap.hpp" +#include "aare/NDArray.hpp" +#include #include #include @@ -275,4 +278,74 @@ TEST_CASE("Gain Map Calculation Index Map") { CHECK(index_map_x == clustertestdata.index_map_x); CHECK(index_map_y == clustertestdata.index_map_y); -} \ No newline at end of file +} + +namespace { + +template +void check_gain_map_cluster_bounds() { + using ClusterType = Cluster; + + constexpr ssize_t rows = 16; + constexpr ssize_t cols = 16; + constexpr uint16_t left = ClusterSizeX / 2; + constexpr uint16_t right = ClusterSizeX - left - 1; + constexpr uint16_t top = ClusterSizeY / 2; + constexpr uint16_t bottom = ClusterSizeY - top - 1; + + aare::NDArray gain_map({rows, cols}, 2.0); + aare::InvertedGainMap inverted_gain_map(gain_map); + ClusterVector clusters(6); + + const auto add_cluster = [&clusters](uint16_t x, uint16_t y) { + ClusterType cluster{}; + cluster.x = x; + cluster.y = y; + cluster.data.fill(2.0); + clusters.push_back(cluster); + }; + + add_cluster(left, top); + add_cluster(cols - right - 1, rows - bottom - 1); + add_cluster(left - 1, top); + add_cluster(left, top - 1); + add_cluster(cols - right, top); + add_cluster(left, rows - bottom); + + inverted_gain_map.apply_gain_map(clusters); + + for (size_t i = 0; i < 2; ++i) { + CHECK(std::all_of(clusters[i].data.begin(), clusters[i].data.end(), + [](double value) { return value == 1.0; })); + } + for (size_t i = 2; i < clusters.size(); ++i) { + CHECK(std::all_of(clusters[i].data.begin(), clusters[i].data.end(), + [](double value) { return value == 0.0; })); + } + + aare::NDArray small_gain_map( + {ClusterSizeY - 1, ClusterSizeX - 1}, 2.0); + aare::InvertedGainMap small_inverted_gain_map(small_gain_map); + ClusterVector oversized_cluster(1); + ClusterType cluster{}; + cluster.x = left; + cluster.y = top; + cluster.data.fill(2.0); + oversized_cluster.push_back(cluster); + + small_inverted_gain_map.apply_gain_map(oversized_cluster); + + CHECK(std::all_of(oversized_cluster[0].data.begin(), + oversized_cluster[0].data.end(), + [](double value) { return value == 0.0; })); +} + +} // namespace + +TEST_CASE("Gain map bounds cover the full cluster footprint", "[GainMap]") { + SECTION("3x3") { check_gain_map_cluster_bounds<3, 3>(); } + SECTION("5x5") { check_gain_map_cluster_bounds<5, 5>(); } + SECTION("7x7") { check_gain_map_cluster_bounds<7, 7>(); } + SECTION("9x9") { check_gain_map_cluster_bounds<9, 9>(); } + SECTION("5x7") { check_gain_map_cluster_bounds<5, 7>(); } +}