From 01954939a8f8898d37d62c092bd9ae997a2b7841 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 27 May 2026 15:41:49 -0400 Subject: [PATCH] VV: Compute Grouping Density V&V completed (cherry picked from commit 60f87ceea3f30756c3974a95b003d47c4b420a77) --- .../docs/ComputeGroupingDensityFilter.md | 41 +- .../ComputeGroupingDensity_Infographic.png | Bin 0 -> 103169 bytes .../ComputeGroupingDensity_Infographic.svg | 610 ++++++++++++++++++ .../Algorithms/ComputeGroupingDensity.cpp | 37 +- .../Filters/ComputeGroupingDensityFilter.cpp | 26 +- src/Plugins/SimplnxCore/test/CMakeLists.txt | 2 +- .../test/ComputeGroupingDensityTest.cpp | 589 ++++++++--------- .../vv/ComputeGroupingDensityFilter.md | 147 +++++ .../ComputeGroupingDensityFilter.md | 91 +++ .../DataStructure/AbstractListStore.hpp | 4 +- src/simplnx/DataStructure/EmptyListStore.hpp | 12 +- src/simplnx/DataStructure/IListStore.hpp | 11 +- src/simplnx/DataStructure/ListStore.hpp | 14 +- 13 files changed, 1222 insertions(+), 362 deletions(-) create mode 100644 src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.png create mode 100644 src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.svg create mode 100644 src/Plugins/SimplnxCore/vv/ComputeGroupingDensityFilter.md create mode 100644 src/Plugins/SimplnxCore/vv/deviations/ComputeGroupingDensityFilter.md diff --git a/src/Plugins/SimplnxCore/docs/ComputeGroupingDensityFilter.md b/src/Plugins/SimplnxCore/docs/ComputeGroupingDensityFilter.md index c8788beac8..4a1d1bc84b 100644 --- a/src/Plugins/SimplnxCore/docs/ComputeGroupingDensityFilter.md +++ b/src/Plugins/SimplnxCore/docs/ComputeGroupingDensityFilter.md @@ -23,18 +23,27 @@ For each **Parent Feature**, the filter: Grouping Density = Parent Volume / Total Checked Volume ``` -If a **Parent Feature** has no child **Features** (total checked volume is zero), the **Grouping Density** is set to **-1.0** to indicate an invalid or empty parent. +If a **Parent Feature** has no child **Features** (total checked volume is zero), the **Grouping Density** is set to the sentinel value *-1.0* to indicate an invalid or empty parent. + +The **Feature Volumes** and **Parent Volumes** are typically expressed as a count of cells (the integer voxel count per feature, the default output of [Compute Feature Sizes](ComputeFeatureSizesFilter.md)). Equivalent-diameter or physical-unit volumes also work — the only requirement is that both *Feature Volumes* and *Parent Volumes* are in the **same** unit, since the **Grouping Density** is a dimensionless ratio of the two. + + +### Algorithm Description + +![ComputeGroupingDensity Algorithm Description](Images/ComputeGroupingDensity_Infographic.png) ### Optional Parameters #### Use Non-Contiguous Neighbors -When enabled, the filter also queries the **Non-Contiguous Neighbor List** for each child **Feature** in addition to the standard **Contiguous Neighbor List**. This expands the set of checked **Features** to include neighbors that are nearby but do not share a direct face/edge/vertex with the child **Feature**. Enable this option if non-contiguous neighbors were used during the original grouping step. Typically the filter "Compute Feature NeighborHoods" is used to generate the Non-contiguous Neighbors lists. That filter's parameter for the "Multiples of Average Diameter can have a large effect on the final Grouping Density value that is computed. +When enabled, the filter also queries the **Non-Contiguous Neighbor List** for each child **Feature** in addition to the standard **Contiguous Neighbor List**. This expands the set of checked **Features** to include neighbors that are nearby but do not share a direct face/edge/vertex with the child **Feature**. Enable this option if non-contiguous neighbors were used during the original grouping step. Typically the [Compute Feature Neighborhoods](ComputeNeighborhoodsFilter.md) filter is used to generate the **Non-Contiguous Neighbor List**. The *Multiples of Average Diameter* parameter on that filter has a large effect on the final **Grouping Density** value that is computed. #### Find Checked Features When enabled, the filter produces an additional output array (**Checked Features**) at the **Feature** level. For each **Feature** that was checked during the density computation, this array records which **Parent Feature** checked it. Since a **Feature** may be checked by multiple **Parent Features** (as a neighbor of children belonging to different parents), the assignment goes to the **Parent Feature** with the **largest Parent Volume**. This provides a way to see which parent had the strongest influence over each region of the microstructure. +**Tie-break behavior:** When two or more **Parent Features** have *exactly equal* **Parent Volumes** and both check the same **Feature**, the first-processed parent (the one with the lower **Parent ID**) keeps the assignment. The strictly-greater comparison (`>`) means equal-volume parents do not overwrite earlier claims. This is deterministic and matches the legacy DREAM3D `FindGroupingDensity` behavior. + ### Worked Example @@ -47,13 +56,13 @@ Consider a 20x5 2D **Image Geometry** with unit spacing (1.0 x 1.0 x 1.0), conta - Features 1, 2, 3 belong to Parent 1 (Parent Volume = 45, i.e. 10 + 20 + 15 cells) - Features 4, 5 belong to Parent 2 (Parent Volume = 55, i.e. 25 + 30 cells) -| Feature | Cells | Volume | Parent | Contiguous Neighbors | -|---------|-------|--------|--------|----------------------| -| 1 | 10 | 10.0 | 1 | {2} | -| 2 | 20 | 20.0 | 1 | {1, 3} | -| 3 | 15 | 15.0 | 1 | {2, 4} | -| 4 | 25 | 25.0 | 2 | {3, 5} | -| 5 | 30 | 30.0 | 2 | {4} | +| Feature | Cells | Volume (cells) | Parent | Contiguous Neighbors | +|---------|-------|----------------|--------|----------------------| +| 1 | 10 | 10.0 | 1 | {2} | +| 2 | 20 | 20.0 | 1 | {1, 3} | +| 3 | 15 | 15.0 | 1 | {2, 4} | +| 4 | 25 | 25.0 | 2 | {3, 5} | +| 5 | 30 | 30.0 | 2 | {4} | **Parent 1:** Child features {1, 2, 3} plus their contiguous neighbors include feature 4 (neighbor of feature 3). Total checked volume = 10 + 20 + 15 + 25 = 70. Density = 45 / 70 = **0.6429**. @@ -68,14 +77,20 @@ Note that both densities are less than 1.0 because each parent's children have n | > 1.0 | Parent volume exceeds the total checked region; the grouping is compact and dense | | = 1.0 | Parent volume equals the total checked region | | 0.0 < d < 1.0 | Parent volume is smaller than the total checked region; the grouping is dispersed | -| -1.0 | No child features found for this parent (invalid/empty parent) | +| *-1.0* | No child features found for this parent (invalid/empty parent) | -% Auto generated parameter table will be inserted here +### Required Input Sources +| Parameter | Source | +|---------------|----------------| +| Feature Volumes | produced by [Compute Feature Sizes](ComputeFeatureSizesFilter.md). Cell-count volumes (integer count of voxels per feature) are the typical usage. Equivalent-diameter or physical-unit volumes also work as long as both *Feature Volumes* and *Parent Volumes* use the **same** unit. | +| Parent Volumes | produced by running [Compute Feature Sizes](ComputeFeatureSizesFilter.md) on the parent **Attribute Matrix** (after the upstream parent-grouping step has assigned each cell to a **Parent Feature**). | +| Feature Parent Ids | produced by a parent-grouping filter such as [Group MicroTexture Regions](../SimplnxReview/GroupMicroTextureRegionsFilter.md) or [Merge Colonies](../SimplnxReview/MergeColoniesFilter.md), which assign each child **Feature** to a **Parent Feature**.| +| Contiguous Neighbor List | produced by [Compute Feature Neighbors](ComputeFeatureNeighborsFilter.md).| +| Non-Contiguous Neighbor List | (optional, only when "Use Non-Contiguous Neighbors" is enabled)* -- produced by [Compute Feature Neighborhoods](ComputeNeighborhoodsFilter.md). See the "Use Non-Contiguous Neighbors" parameter guidance above for notes on how the upstream *Multiples of Average Diameter* parameter affects results.| -### Algorithm Flowchart -![ComputeGroupingDensity Algorithm Flowchart](Images/ComputeGroupingDensity_Algorithm.png) +% Auto generated parameter table will be inserted here ## References diff --git a/src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.png b/src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.png new file mode 100644 index 0000000000000000000000000000000000000000..b26c77dc4259d07270a8bb3b0914f60368d434f2 GIT binary patch literal 103169 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∨UL0g29WpbfsoYa`4sqa-fNyoC9B!!1LaE9%2jU5 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

vP$6C-0Ti2JlaI&C;S?Tb#C?{CZWVBmCN15! zmARx=R*-H5gOPip%rN&gLR$h3+iq5qo-Q`54Xk%lmm7q%#^gEEaNGe=)V_4Dg1fG_ z;u*clnuN#gNK}Z%0D8q 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 z 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 literal 0 HcmV?d00001 diff --git a/src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.svg b/src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.svg new file mode 100644 index 0000000000..ff1b61194e --- /dev/null +++ b/src/Plugins/SimplnxCore/docs/Images/ComputeGroupingDensity_Infographic.svg @@ -0,0 +1,610 @@ + + +Compute Grouping Densities — How the Algorithm Works +20×5 toy dataset: 5 features grouped into 2 parents +1. Input data +Parent 1 (volume = 45) +Parent 2 (volume = 55) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 +vol = 10 +vol = 20 +vol = 15 +vol = 25 +vol = 30 +Each row above is a Feature; the number inside is the FeatureId. +2. Contiguous neighbors only (UseNonContiguousNeighbors = false) +Each parent's child features plus their contiguous-list neighbors form the 'touched set'. +Parent 1's touched set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 +Parent 2's touched set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 +touched = {1,2,3,4}, Σ vol = 70 +density = 45 / 70 = 0.6429 +touched = {3,4,5}, Σ vol = 70 +density = 55 / 70 = 0.7857 +3. With non-contiguous neighbors (UseNonContiguousNeighbors = true) +Non-contiguous links (dashed arcs) reach farther neighbors. The touched sets grow; densities shrink. +Parent 1's touched set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + +non-contig 1↔4 + +non-contig 2↔5 +Parent 2's touched set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + +non-contig 1↔4 + +non-contig 2↔5 +touched = {1,2,3,4,5}, Σ vol = 100 +density = 45 / 100 = 0.45 +touched = {1,2,3,4,5}, Σ vol = 100 +density = 55 / 100 = 0.55 + \ No newline at end of file diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.cpp index 3082220c8c..ea0c5376b9 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.cpp @@ -10,6 +10,10 @@ using namespace nx::core; namespace { +// Compile-time policy struct that selects between the 4 algorithm variants +// (UseNonContiguousNeighbors x FindCheckedFeatures). Resolving these flags +// at compile time via template specialization keeps the inner-loop hot path +// free of runtime branches on the flag values. template struct FindDensitySpecializations { @@ -17,6 +21,14 @@ struct FindDensitySpecializations static constexpr bool FindingCheckedFeatures = FindCheckedFeatures; }; +// Core grouping-density computation. For each parent, walk its assigned +// features and their neighbors (contiguous always; non-contiguous when the +// template flag is set), accumulating totalFeatureCheckVolume, then write +// GroupingDensities[parent] = parentVolume / totalFeatureCheckVolume, or +// the sentinel -1.0f if no features touched the parent. When +// FindingCheckedFeatures is set, also write the largest-volume claiming +// parent into CheckedFeatures[feature] (ties go to first-encountered parent +// because the comparison uses strict `>`). template > class FindDensityGrouping { @@ -36,10 +48,10 @@ class FindDensityGrouping } ~FindDensityGrouping() noexcept = default; - FindDensityGrouping(const FindDensityGrouping&) = delete; // Copy Constructor Default Implemented - FindDensityGrouping(FindDensityGrouping&&) = delete; // Move Constructor Not Implemented - FindDensityGrouping& operator=(const FindDensityGrouping&) = delete; // Copy Assignment Not Implemented - FindDensityGrouping& operator=(FindDensityGrouping&&) = delete; // Move Assignment Not Implemented + FindDensityGrouping(const FindDensityGrouping&) = delete; + FindDensityGrouping(FindDensityGrouping&&) = delete; + FindDensityGrouping& operator=(const FindDensityGrouping&) = delete; + FindDensityGrouping& operator=(FindDensityGrouping&&) = delete; Result<> operator()() { @@ -111,6 +123,10 @@ class FindDensityGrouping curParentVolume = parentVolumesRef[currentParentId]; if(totalFeatureCheckVolume == 0.0f) { + // Sentinel: this parent had no assigned features (so no neighbors + // were walked, and totalFeatureCheckVolume stayed at 0). Downstream + // consumers treat -1.0f in GroupingDensities as "density is not + // defined for this parent." See the filter documentation. outGroupingDensitiesRef[currentParentId] = -1.0f; } else @@ -128,14 +144,17 @@ class FindDensityGrouping float32& totalFeatureCheckVolume, const AbstractDataStore& parentVolumesRef, std::vector& checkedFeatureVolumes, AbstractDataStore& outCheckedFeaturesRef) { - auto featureNeighbors = neighborList.at(currentFeatureId); - auto numNeighbors = static_cast(featureNeighbors.size()); - + const usize numNeighbors = neighborList.getListSize(currentFeatureId); for(int32 neighborIdx = 0; neighborIdx < numNeighbors; neighborIdx++) { - auto neighborId = featureNeighbors.at(neighborIdx); + bool ok = false; + int32 neighborId = neighborList.getValue(currentFeatureId, neighborIdx, ok); + if(!ok) // If trying to retrieve the value fails for some reason. This should never happen. + { + return; + } - // If the current neighbor is NOT in the check list... + // If the current neighbor is NOT in the checklist... if(!totalFeatureCheckList.contains(neighborId)) { // update the volumes and the check list diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeGroupingDensityFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeGroupingDensityFilter.cpp index c346e53ebc..c560fefbd5 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeGroupingDensityFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeGroupingDensityFilter.cpp @@ -17,8 +17,16 @@ using namespace nx::core; namespace { -const DataPath k_ThrowawayCheckedFeatures = DataPath({"HiddenTempCheckedFeatures"}); -const DataPath k_ThrowawayNonContiguous = DataPath({"HiddenNonContiguousNL"}); +// Throwaway DataPaths used when the user opts out of either output. The +// underlying algorithm always expects a valid Int32Array for CheckedFeatures +// and a valid Int32NeighborList for NonContiguousNeighbors, even when those +// outputs are unused. preflightImpl() creates these temporary objects when +// the user opts out and schedules a deferred delete; the algorithm writes +// to them (the writes are harmless) and they are cleaned up at the end of +// execute. This keeps the algorithm interface free of std::optional or +// nullable references. +const auto k_ThrowawayCheckedFeatures = DataPath({"HiddenTempCheckedFeatures"}); +const auto k_ThrowawayNonContiguous = DataPath({"HiddenNonContiguousNL"}); } // namespace namespace nx::core @@ -155,13 +163,14 @@ IFilter::PreflightResult ComputeGroupingDensityFilter::preflightImpl(const DataS return MakePreflightErrorResult(-15673, fmt::format("Feature Volumes [{}] must be stored in an Attribute Matrix.", pFeatureVolumesPath.toString())); } + // CheckedFeatures output: create the real output when requested; otherwise + // create the throwaway placeholder (see the k_ThrowawayCheckedFeatures + // comment block at the top of this file) and schedule its deletion. if(pFindCheckedFeatures) { - { - DataPath checkedFeaturesPath = pFeatureVolumesPath.replaceName(pCheckedFeaturesName); - auto createArrayAction = std::make_unique(nx::core::DataType::int32, pFeatureAM->getShape(), ShapeType{1}, checkedFeaturesPath); - resultOutputActions.value().appendAction(std::move(createArrayAction)); - } + DataPath checkedFeaturesPath = pFeatureVolumesPath.replaceName(pCheckedFeaturesName); + auto createArrayAction = std::make_unique(nx::core::DataType::int32, pFeatureAM->getShape(), ShapeType{1}, checkedFeaturesPath); + resultOutputActions.value().appendAction(std::move(createArrayAction)); } else { @@ -175,6 +184,9 @@ IFilter::PreflightResult ComputeGroupingDensityFilter::preflightImpl(const DataS } } + // Non-contiguous neighbor list: when the user has opted out, create a + // throwaway 1-tuple neighbor list and schedule its deletion. See the + // k_ThrowawayNonContiguous comment block at the top of this file. if(!pUseNonContiguousNeighbors) { { diff --git a/src/Plugins/SimplnxCore/test/CMakeLists.txt b/src/Plugins/SimplnxCore/test/CMakeLists.txt index d9097e61c2..a33e7dc8fb 100644 --- a/src/Plugins/SimplnxCore/test/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/test/CMakeLists.txt @@ -288,7 +288,7 @@ if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME winding_surface_nets.tar.gz SHA512 b45567fd89ea8ebac4764b37491041afa04516fee4b11b5c22d9d3a03c988e335f97395539438d008a7d0f006375a6ec0c62df2b3929ac59671f2e066bc2123f) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME write_lammps_test.tar.gz SHA512 82bb5360b76e857f3233d37733c602f67fd2ac667e49b24741a70ab649e8046fb7905493df37d142808b740c2771fe7cdccd71c9d70679afafe398529ee5771e) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME write_stl_overflow_test.tar.gz SHA512 d8f8eac901479100ffb5813b3ac72c86da496d3620d406f8691adc0d95eb4670bf4e887f57ccc702bac7e6eeaffdf61db9b4c06157423845dd198722870b0c0b) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_grouping_densities.tar.gz SHA512 96066196d6aa5f87cc7b717f959848c2f3025b7129589abe1eded2a8d725c539a89b0a6290a388a56b5a401e0bd3041698fbd8e8cf37a1f18fdd937debd21531) + download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_grouping_densities_v2.tar.gz SHA512 3aaabb63c4fa16f7fa192ae4ee9dbba9394ec7f1cd19aff55e399a624d495a3a778c7f6f282911f681e85cea99e4c6d15344274e9107f337af7d4a19f93784ff) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME hierarchical_smoothing.tar.gz SHA512 47217ee420d9438c3d36a195c06ae060917f5fb7ee295feffdabf05741bec87bf29c3b44016b744930cda7383cd05e0d58df7e7776a7732dc46c12b780e51398) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME identify_sample_v2.tar.gz SHA512 a7ffac3eaad479c07215c1dd16274c45a52466708a9d27b5f85a29b0eba3b6705b627e1052a7a27e9bfe89cd6e7df673beb7a1e98b262b6c52ea383b4848ac31) diff --git a/src/Plugins/SimplnxCore/test/ComputeGroupingDensityTest.cpp b/src/Plugins/SimplnxCore/test/ComputeGroupingDensityTest.cpp index d8f7bf4165..ed7646db5e 100644 --- a/src/Plugins/SimplnxCore/test/ComputeGroupingDensityTest.cpp +++ b/src/Plugins/SimplnxCore/test/ComputeGroupingDensityTest.cpp @@ -21,419 +21,368 @@ using namespace nx::core; namespace { -// DataStructure path constants +// ============================================================================= +// Exemplar archive paths (compute_grouping_densities_v2.tar.gz) +// ============================================================================= +const std::string k_TestDataDirName = "compute_grouping_densities_v2"; +const fs::path k_TestDataDir = fs::path(unit_test::k_TestFilesDir.view()) / k_TestDataDirName; +const fs::path k_InputFile = k_TestDataDir / "data" / "compute_grouping_density_inputs.dream3d"; +const fs::path k_ExemplarFile = k_TestDataDir / "output_simplnx" / "simplnx_compute_grouping_density_ab.dream3d"; + +// ============================================================================= +// DataPath constants for the v2 input file +// ============================================================================= +const std::string k_DataContainerName = "DataContainer"; +const std::string k_FeatureAMName = "FeatureData"; +const std::string k_ParentAMName = "ParentData"; + +const auto k_VolumesPath = DataPath({k_DataContainerName, k_FeatureAMName, "Volumes"}); +const auto k_ParentIdsPath = DataPath({k_DataContainerName, k_FeatureAMName, "ParentIds"}); +const auto k_ContiguousNLPath = DataPath({k_DataContainerName, k_FeatureAMName, "ContiguousNeighborList"}); +const auto k_NonContiguousNLPath = DataPath({k_DataContainerName, k_FeatureAMName, "NonContiguousNeighborList"}); +const auto k_ParentVolumesPath = DataPath({k_DataContainerName, k_ParentAMName, "ParentVolumes"}); + +// Output array names (placed by the filter into the same AMs as the inputs) +const std::string k_ComputedGroupingDensitiesName = "ComputedGroupingDensities"; +const std::string k_ComputedCheckedFeaturesName = "ComputedCheckedFeatures"; +const auto k_ComputedGroupingDensitiesPath = DataPath({k_DataContainerName, k_ParentAMName, k_ComputedGroupingDensitiesName}); +const auto k_ComputedCheckedFeaturesPath = DataPath({k_DataContainerName, k_FeatureAMName, k_ComputedCheckedFeaturesName}); + +// Constants used by the inline preflight-error and edge-case tests. const std::string k_ImageGeomName = "ImageGeom"; -const std::string k_FeatureAMName = "CellFeatureData"; -const std::string k_ParentAMName = "ParentFeatureData"; -const std::string k_VolumesName = "Volumes"; -const std::string k_ParentIdsName = "ParentIds"; -const std::string k_ContiguousNLName = "ContiguousNeighborList"; -const std::string k_NonContiguousNLName = "NonContiguousNeighborList"; -const std::string k_ParentVolumesName = "Volumes"; -const std::string k_ComputedGroupingDensitiesName = "Computed GroupingDensities"; -const std::string k_CheckedFeaturesName = "CheckedFeatures"; - -const DataPath k_VolumesPath = DataPath({k_ImageGeomName, k_FeatureAMName, k_VolumesName}); -const DataPath k_ParentIdsPath = DataPath({k_ImageGeomName, k_FeatureAMName, k_ParentIdsName}); -const DataPath k_ContiguousNLPath = DataPath({k_ImageGeomName, k_FeatureAMName, k_ContiguousNLName}); -const DataPath k_NonContiguousNLPath = DataPath({k_ImageGeomName, k_FeatureAMName, k_NonContiguousNLName}); -const DataPath k_ParentVolumesPath = DataPath({k_ImageGeomName, k_ParentAMName, k_ParentVolumesName}); -const DataPath k_GroupingDensitiesPath = DataPath({k_ImageGeomName, k_ParentAMName, k_ComputedGroupingDensitiesName}); -const DataPath k_CheckedFeaturesPath = DataPath({k_ImageGeomName, k_FeatureAMName, k_CheckedFeaturesName}); - -// Test data dimensions matching the 20x5 2D Image Geometry: -// 6 features (index 0 = placeholder, features 1-5) -// 3 parents (index 0 = placeholder, parents 1-2) -// Features 1,2,3 -> Parent 1 (volume = 10+20+15 = 45) -// Features 4,5 -> Parent 2 (volume = 25+30 = 55) constexpr usize k_NumFeatures = 6; constexpr usize k_NumParents = 3; +} // namespace -/** - * @brief Builds a DataStructure with all input data needed for the ComputeGroupingDensity filter. - * Optionally includes a non-contiguous neighbor list. - * - * Data matches the 20x5 2D Image Geometry worked example: - * Feature Volumes: [0, 10, 20, 15, 25, 30] - * Parent IDs: [0, 1, 1, 1, 2, 2] - * Parent Volumes: [0, 45, 55] - * Contiguous Neighbors: chain 1-2-3-4-5 - */ -DataStructure createTestDataStructure(bool includeNonContiguousNL) +// ============================================================================= +// Exemplar-based test: exercises all 4 (UseNonContiguousNeighbors, FindCheckedFeatures) +// configurations against pre-validated outputs in compute_grouping_densities_v2.tar.gz. +// +// The v2 exemplar archive was hand-reviewed and signed off by the filter author, +// and the SIMPLNX outputs in it were independently confirmed bit-identical to +// the legacy DREAM3D 6.5.172 `FindGroupingDensity` filter (the pre-SIMPLNX port +// source) — see src/Plugins/SimplnxCore/vv/ComputeGroupingDensityFilter.md and +// src/Plugins/SimplnxCore/vv/deviations/ComputeGroupingDensityFilter.md. +// +// Driving the test from the same exemplar archive used for the V&V comparison +// gives a single source of truth: any future change to either the algorithm or +// the exemplar surfaces here. +// ============================================================================= + +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Exemplar A/B — all 4 configurations", "[SimplnxCore][ComputeGroupingDensityFilter]") { - DataStructure dataStructure; + UnitTest::LoadPlugins(); - // Create ImageGeom (just a container for the AMs) - auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); - imageGeom->setDimensions({1, 1, 1}); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "compute_grouping_densities_v2.tar.gz", k_TestDataDirName); + + // Generate all 4 (UseNonContig, FindCheckedFeatures) combinations. + // The suffix matches the exemplar array naming inside the v2 archive. + auto config = GENERATE(table({ + {false, false, "NC0_CF0"}, + {false, true, "NC0_CF1"}, + {true, false, "NC1_CF0"}, + {true, true, "NC1_CF1"}, + })); + const bool useNonContiguous = std::get<0>(config); + const bool findCheckedFeatures = std::get<1>(config); + const std::string suffix = std::get<2>(config); + + DYNAMIC_SECTION("Config " << suffix << " UseNonContig=" << useNonContiguous << " FindChecked=" << findCheckedFeatures) + { + // Fresh DataStructure per configuration so output paths don't collide + DataStructure dataStructure = UnitTest::LoadDataStructure(k_InputFile); + + ComputeGroupingDensityFilter filter; + Arguments args; + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(k_VolumesPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(k_ContiguousNLPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(useNonContiguous)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(useNonContiguous ? k_NonContiguousNLPath : DataPath{})); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(k_ParentIdsPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(k_ParentVolumesPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(findCheckedFeatures)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any(k_ComputedCheckedFeaturesName)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any(k_ComputedGroupingDensitiesName)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + + auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Load the exemplar — it contains all 4 pre-computed output configurations + // as separately-named arrays (e.g., "GroupingDensities_NC0_CF1") + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + + // GroupingDensities is always produced + const DataPath exemplarDensitiesPath = DataPath({k_DataContainerName, k_ParentAMName, "GroupingDensities_" + suffix}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_ComputedGroupingDensitiesPath)); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDensitiesPath)); + const auto& computedDensities = dataStructure.getDataRefAs(k_ComputedGroupingDensitiesPath); + const auto& exemplarDensities = exemplarDS.getDataRefAs(exemplarDensitiesPath); + UnitTest::CompareDataArrays(exemplarDensities, computedDensities); + + // CheckedFeatures is produced only when FindCheckedFeatures==true + if(findCheckedFeatures) + { + const DataPath exemplarCheckedFeaturesPath = DataPath({k_DataContainerName, k_FeatureAMName, "CheckedFeatures_" + suffix}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_ComputedCheckedFeaturesPath)); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarCheckedFeaturesPath)); + const auto& computedCheckedFeatures = dataStructure.getDataRefAs(k_ComputedCheckedFeaturesPath); + const auto& exemplarCheckedFeatures = exemplarDS.getDataRefAs(exemplarCheckedFeaturesPath); + UnitTest::CompareDataArrays(exemplarCheckedFeatures, computedCheckedFeatures); + } + + // Class 4 (Invariant) oracle assertions — properties that any valid output must satisfy. + // Independent of the exemplar bit-comparison above; these would catch a future + // regression even if someone "fixed" the exemplar incorrectly to match buggy code. + const auto& invDensities = dataStructure.getDataRefAs(k_ComputedGroupingDensitiesPath); + REQUIRE(invDensities[0] == 0.0f); // placeholder parent never touched + for(usize i = 1; i < invDensities.getNumberOfTuples(); ++i) + { + // density is either positive (parent had at least one assigned feature) + // or exactly the -1.0f sentinel (parent had no assigned features). + REQUIRE((invDensities[i] > 0.0f || invDensities[i] == -1.0f)); + // totalCheckVolume always includes the parent's own features, + // so it is never smaller than ParentVolumes[i] -> density <= 1.0. + REQUIRE(invDensities[i] <= 1.0f); + } + if(findCheckedFeatures) + { + const auto& invChecked = dataStructure.getDataRefAs(k_ComputedCheckedFeaturesPath); + REQUIRE(invChecked[0] == 0); // placeholder feature never claimed + const int32 maxParentId = static_cast(invDensities.getNumberOfTuples()) - 1; + for(usize i = 1; i < invChecked.getNumberOfTuples(); ++i) + { + REQUIRE((invChecked[i] >= 0 && invChecked[i] <= maxParentId)); + } + } - // Feature-level AttributeMatrix (6 tuples: indices 0-5) - auto* featureAM = AttributeMatrix::Create(dataStructure, k_FeatureAMName, {k_NumFeatures}, imageGeom->getId()); + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} - // Parent-level AttributeMatrix (3 tuples: indices 0-2) - auto* parentAM = AttributeMatrix::Create(dataStructure, k_ParentAMName, {k_NumParents}, imageGeom->getId()); +// ============================================================================= +// Edge case: a parent with no features assigned -> totalCheckVolume == 0 +// triggers the -1.0f sentinel write at ComputeGroupingDensity.cpp line 114. +// Not exercised by the v2 exemplar (every parent has features). +// ============================================================================= + +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Empty-parent edge case (-1.0f sentinel)", "[SimplnxCore][ComputeGroupingDensityFilter]") +{ + UnitTest::LoadPlugins(); + + // 3 features (indices 1, 2 carry data; 0 is the SIMPL placeholder). + // 3 parents (index 1 has features assigned; index 2 has NONE). + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); + imageGeom->setDimensions({1, 1, 1}); - // --- Feature-level arrays --- + auto* featureAM = AttributeMatrix::Create(dataStructure, "FeatureData", {3}, imageGeom->getId()); + auto* parentAM = AttributeMatrix::Create(dataStructure, "ParentData", {3}, imageGeom->getId()); - // Feature Volumes: [0, 10, 20, 15, 25, 30] - auto* featureVolumes = UnitTest::CreateTestDataArray(dataStructure, k_VolumesName, {k_NumFeatures}, {1}, featureAM->getId()); + // Feature volumes: [0, 5, 10] + auto* featureVolumes = UnitTest::CreateTestDataArray(dataStructure, "Volumes", {3}, {1}, featureAM->getId()); auto& featureVolumesRef = featureVolumes->getDataStoreRef(); featureVolumesRef[0] = 0.0f; - featureVolumesRef[1] = 10.0f; - featureVolumesRef[2] = 20.0f; - featureVolumesRef[3] = 15.0f; - featureVolumesRef[4] = 25.0f; - featureVolumesRef[5] = 30.0f; - - // Parent IDs: [0, 1, 1, 1, 2, 2] - auto* parentIds = UnitTest::CreateTestDataArray(dataStructure, k_ParentIdsName, {k_NumFeatures}, {1}, featureAM->getId()); + featureVolumesRef[1] = 5.0f; + featureVolumesRef[2] = 10.0f; + + // All non-placeholder features map to parent 1; parent 2 has no features. + auto* parentIds = UnitTest::CreateTestDataArray(dataStructure, "ParentIds", {3}, {1}, featureAM->getId()); auto& parentIdsRef = parentIds->getDataStoreRef(); parentIdsRef[0] = 0; parentIdsRef[1] = 1; parentIdsRef[2] = 1; - parentIdsRef[3] = 1; - parentIdsRef[4] = 2; - parentIdsRef[5] = 2; - - // Contiguous Neighbor List (chain: 1-2-3-4-5) - // Feature 0: {} - // Feature 1: {2} - // Feature 2: {1, 3} - // Feature 3: {2, 4} - // Feature 4: {3, 5} - // Feature 5: {4} - auto* contiguousNL = NeighborList::Create(dataStructure, k_ContiguousNLName, ShapeType{k_NumFeatures}, featureAM->getId()); - contiguousNL->setList(0, std::make_shared>(std::vector{})); - contiguousNL->setList(1, std::make_shared>(std::vector{2})); - contiguousNL->setList(2, std::make_shared>(std::vector{1, 3})); - contiguousNL->setList(3, std::make_shared>(std::vector{2, 4})); - contiguousNL->setList(4, std::make_shared>(std::vector{3, 5})); - contiguousNL->setList(5, std::make_shared>(std::vector{4})); - - // Non-Contiguous Neighbor List (optional) - // Feature 0: {} - // Feature 1: {4} - // Feature 2: {5} - // Feature 3: {} - // Feature 4: {1} - // Feature 5: {2} - if(includeNonContiguousNL) - { - auto* nonContiguousNL = NeighborList::Create(dataStructure, k_NonContiguousNLName, ShapeType{k_NumFeatures}, featureAM->getId()); - nonContiguousNL->setList(0, std::make_shared>(std::vector{})); - nonContiguousNL->setList(1, std::make_shared>(std::vector{4})); - nonContiguousNL->setList(2, std::make_shared>(std::vector{5})); - nonContiguousNL->setList(3, std::make_shared>(std::vector{})); - nonContiguousNL->setList(4, std::make_shared>(std::vector{1})); - nonContiguousNL->setList(5, std::make_shared>(std::vector{2})); - } - // --- Parent-level arrays --- + // Trivial contiguous neighbor list — empty for every feature. + auto* contiguousNL = NeighborList::Create(dataStructure, "ContigNL", ShapeType{3}, featureAM->getId()); + contiguousNL->setList(0, std::make_shared>(std::vector{})); + contiguousNL->setList(1, std::make_shared>(std::vector{})); + contiguousNL->setList(2, std::make_shared>(std::vector{})); - // Parent Volumes: [0, 45, 55] (sum of child feature cell volumes) - auto* parentVolumes = UnitTest::CreateTestDataArray(dataStructure, k_ParentVolumesName, {k_NumParents}, {1}, parentAM->getId()); + // Parent volumes: parent 1 sums to 15 (5+10); parent 2 is non-zero but + // irrelevant — totalCheckVolume==0 path triggers regardless of ParentVolumes[2]. + auto* parentVolumes = UnitTest::CreateTestDataArray(dataStructure, "ParentVolumes", {3}, {1}, parentAM->getId()); auto& parentVolumesRef = parentVolumes->getDataStoreRef(); parentVolumesRef[0] = 0.0f; - parentVolumesRef[1] = 45.0f; - parentVolumesRef[2] = 55.0f; - - return dataStructure; -} - -/** - * @brief Creates the filter Arguments for the given boolean option combination. - */ -Arguments createFilterArgs(bool useNonContiguous, bool findCheckedFeatures) -{ - Arguments args; - args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(k_VolumesPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(k_ContiguousNLPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(useNonContiguous)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(useNonContiguous ? k_NonContiguousNLPath : DataPath{})); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(k_ParentIdsPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(k_ParentVolumesPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(findCheckedFeatures)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any(k_CheckedFeaturesName)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any(k_ComputedGroupingDensitiesName)); - return args; -} -} // namespace - -// ============================================================================= -// Exemplar-Based Test - Compare against DREAM3D-NX pipeline output -// ============================================================================= - -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Basic Density (contiguous, no checked features)", "[SimplnxReview][ComputeGroupingDensityFilter]") -{ - - const std::string k_GroupingDensitiesName = "GroupingDensities (false, false)"; - const DataPath k_ExemplarGroupingDensitiesPath = DataPath({k_ImageGeomName, k_ParentAMName, k_GroupingDensitiesName}); - - UnitTest::LoadPlugins(); - - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "compute_grouping_densities.tar.gz", "compute_grouping_densities"); + parentVolumesRef[1] = 15.0f; + parentVolumesRef[2] = 7.0f; - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/compute_grouping_densities/compute_grouping_densities.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + const DataPath volumesPath = DataPath({k_ImageGeomName, "FeatureData", "Volumes"}); + const DataPath parentIdsPath = DataPath({k_ImageGeomName, "FeatureData", "ParentIds"}); + const DataPath contigNLPath = DataPath({k_ImageGeomName, "FeatureData", "ContigNL"}); + const DataPath parentVolumesPath = DataPath({k_ImageGeomName, "ParentData", "ParentVolumes"}); + const DataPath outputDensitiesPath = DataPath({k_ImageGeomName, "ParentData", "GroupingDensities"}); ComputeGroupingDensityFilter filter; Arguments args; - args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(k_VolumesPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(k_ContiguousNLPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(volumesPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(contigNLPath)); args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(false)); args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(DataPath{})); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(k_ParentIdsPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(k_ParentVolumesPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(parentIdsPath)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(parentVolumesPath)); args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(false)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any(k_CheckedFeaturesName)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any(k_ComputedGroupingDensitiesName)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any("CheckedFeatures")); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any("GroupingDensities")); auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - // Compare computed densities against the exemplar from the DREAM3D-NX pipeline - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GroupingDensitiesPath)); - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_ExemplarGroupingDensitiesPath)); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(outputDensitiesPath)); + const auto& densities = dataStructure.getDataRefAs(outputDensitiesPath); - const auto& computedDensities = dataStructure.getDataRefAs(k_GroupingDensitiesPath); - const auto& exemplarDensities = dataStructure.getDataRefAs(k_ExemplarGroupingDensitiesPath); + // Parent 1: totalCheckVolume = 5 + 10 = 15; density = 15/15 = 1.0 + // Parent 2: NO features assigned -> totalCheckVolume == 0 -> -1.0f sentinel + REQUIRE(densities[1] == Approx(1.0f).epsilon(0.0001f)); + REQUIRE(densities[2] == -1.0f); - REQUIRE(computedDensities.getNumberOfTuples() == exemplarDensities.getNumberOfTuples()); - for(usize i = 0; i < computedDensities.getNumberOfTuples(); i++) - { - REQUIRE(computedDensities[i] == Approx(exemplarDensities[i]).epsilon(0.0001f)); - } - - // Verify against hand-calculated values: - // Parent Volumes: [0, 45, 55] - // Parent 1: children {1,2,3}, neighbors add feature 4 - // totalCheckVolume = 10 + 20 + 15 + 25 = 70 - // density = 45 / 70 = 0.642857 - // Parent 2: children {4,5}, neighbors add feature 3 - // totalCheckVolume = 25 + 30 + 15 = 70 - // density = 55 / 70 = 0.785714 - REQUIRE(computedDensities[1] == Approx(45.0f / 70.0f).epsilon(0.0001f)); - REQUIRE(computedDensities[2] == Approx(55.0f / 70.0f).epsilon(0.0001f)); + UnitTest::CheckArraysInheritTupleDims(dataStructure); } // ============================================================================= -// Execution Tests - Exercise all 4 template specializations +// Preflight error tests — one TEST_CASE per error code in preflightImpl(). // ============================================================================= -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Contiguous Only, No Checked Features", "[SimplnxReview][ComputeGroupingDensityFilter]") -{ - UnitTest::LoadPlugins(); - - DataStructure dataStructure = createTestDataStructure(false); - ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(false, false); - - auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - - // Parent 1: children {1,2,3}, contiguous neighbors add feature 4 - // totalCheckVolume = 10 + 20 + 15 + 25 = 70 - // density = 45 / 70 = 0.642857 - // Parent 2: children {4,5}, contiguous neighbors add feature 3 - // totalCheckVolume = 25 + 30 + 15 = 70 - // density = 55 / 70 = 0.785714 - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GroupingDensitiesPath)); - const auto& groupingDensities = dataStructure.getDataRefAs(k_GroupingDensitiesPath); - - REQUIRE(groupingDensities[1] == Approx(45.0f / 70.0f).epsilon(0.0001f)); - REQUIRE(groupingDensities[2] == Approx(55.0f / 70.0f).epsilon(0.0001f)); -} - -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: With Non-Contiguous Neighbors", "[SimplnxReview][ComputeGroupingDensityFilter]") -{ - UnitTest::LoadPlugins(); - - DataStructure dataStructure = createTestDataStructure(true); - ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(true, false); - - auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - - // With non-contiguous neighbors, all 5 features get checked for each parent - // Parent 1: totalCheckVolume = 10+20+15+25+30 = 100, density = 45/100 = 0.45 - // Parent 2: totalCheckVolume = 25+30+15+10+20 = 100, density = 55/100 = 0.55 - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GroupingDensitiesPath)); - const auto& groupingDensities = dataStructure.getDataRefAs(k_GroupingDensitiesPath); - - REQUIRE(groupingDensities[1] == Approx(45.0f / 100.0f).epsilon(0.0001f)); - REQUIRE(groupingDensities[2] == Approx(55.0f / 100.0f).epsilon(0.0001f)); -} - -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: With Checked Features", "[SimplnxReview][ComputeGroupingDensityFilter]") +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Preflight Error - Feature tuple count mismatch (-15671)", "[SimplnxCore][ComputeGroupingDensityFilter]") { UnitTest::LoadPlugins(); - DataStructure dataStructure = createTestDataStructure(false); - ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(false, true); - - auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + // ParentIds in a different AM with mismatched tuple count + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); + imageGeom->setDimensions({1, 1, 1}); - // Densities same as contiguous-only case - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GroupingDensitiesPath)); - const auto& groupingDensities = dataStructure.getDataRefAs(k_GroupingDensitiesPath); - - REQUIRE(groupingDensities[1] == Approx(45.0f / 70.0f).epsilon(0.0001f)); - REQUIRE(groupingDensities[2] == Approx(55.0f / 70.0f).epsilon(0.0001f)); - - // Checked features: each feature is assigned to the parent with the largest volume that checked it - // Parent 1 (vol=45) processes first and checks features {1,2,3,4} - // Parent 2 (vol=55) processes second and checks features {3,4,5} - // Feature 3: checked by Parent 1 (45) then Parent 2 (55 > 45) -> overridden to Parent 2 - // Feature 4: checked by Parent 1 (45) then Parent 2 (55 > 45) -> overridden to Parent 2 - // Feature 5: only checked by Parent 2 - // Expected: [0, 1, 1, 2, 2, 2] - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_CheckedFeaturesPath)); - const auto& checkedFeatures = dataStructure.getDataRefAs(k_CheckedFeaturesPath); - - REQUIRE(checkedFeatures[0] == 0); - REQUIRE(checkedFeatures[1] == 1); - REQUIRE(checkedFeatures[2] == 1); - REQUIRE(checkedFeatures[3] == 2); - REQUIRE(checkedFeatures[4] == 2); - REQUIRE(checkedFeatures[5] == 2); -} + auto* featureAM = AttributeMatrix::Create(dataStructure, "FeatureData", {k_NumFeatures}, imageGeom->getId()); + auto* parentAM = AttributeMatrix::Create(dataStructure, "ParentData", {k_NumParents}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Volumes", {k_NumFeatures}, {1}, featureAM->getId()); + NeighborList::Create(dataStructure, "ContigNL", ShapeType{k_NumFeatures}, featureAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentVolumes", {k_NumParents}, {1}, parentAM->getId()); -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Both Options Enabled", "[SimplnxReview][ComputeGroupingDensityFilter]") -{ - UnitTest::LoadPlugins(); + auto* mismatchAM = AttributeMatrix::Create(dataStructure, "MismatchAM", {10}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentIds", {10}, {1}, mismatchAM->getId()); - DataStructure dataStructure = createTestDataStructure(true); ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(true, true); - - auto executeResult = filter.execute(dataStructure, args, nullptr, IFilter::MessageHandler{[](const IFilter::Message& message) { fmt::print("{}\n", message.message); }}); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + Arguments args; + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "Volumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "ContigNL"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(DataPath{})); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(DataPath({k_ImageGeomName, "MismatchAM", "ParentIds"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(DataPath({k_ImageGeomName, "ParentData", "ParentVolumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any("CheckedFeatures")); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any("GroupingDensities")); - // With non-contiguous neighbors, all features get checked by both parents - // Parent 1: density = 45/100 = 0.45 - // Parent 2: density = 55/100 = 0.55 - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GroupingDensitiesPath)); - const auto& groupingDensities = dataStructure.getDataRefAs(k_GroupingDensitiesPath); - - REQUIRE(groupingDensities[1] == Approx(45.0f / 100.0f).epsilon(0.0001f)); - REQUIRE(groupingDensities[2] == Approx(55.0f / 100.0f).epsilon(0.0001f)); - - // Parent 1 (vol=45) checks ALL features {1,2,3,4,5} via non-contiguous links - // Parent 2 (vol=55) also checks ALL features, and 55 > 45 so all get overridden - // Expected: [0, 2, 2, 2, 2, 2] - REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_CheckedFeaturesPath)); - const auto& checkedFeatures = dataStructure.getDataRefAs(k_CheckedFeaturesPath); - - REQUIRE(checkedFeatures[0] == 0); - REQUIRE(checkedFeatures[1] == 2); - REQUIRE(checkedFeatures[2] == 2); - REQUIRE(checkedFeatures[3] == 2); - REQUIRE(checkedFeatures[4] == 2); - REQUIRE(checkedFeatures[5] == 2); + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); } -// ============================================================================= -// Preflight Error Tests -// ============================================================================= - -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Preflight Error - Feature tuple count mismatch", "[SimplnxReview][ComputeGroupingDensityFilter]") +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Preflight Error - NonContiguousNL tuple count mismatch (-15672)", "[SimplnxCore][ComputeGroupingDensityFilter]") { UnitTest::LoadPlugins(); - // Build a DataStructure where ParentIds has a different tuple count than Volumes + // Feature-level arrays are all 6 tuples; NonContiguousNL is 4 tuples in a different AM DataStructure dataStructure; auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); imageGeom->setDimensions({1, 1, 1}); - auto* featureAM = AttributeMatrix::Create(dataStructure, k_FeatureAMName, {k_NumFeatures}, imageGeom->getId()); - auto* parentAM = AttributeMatrix::Create(dataStructure, k_ParentAMName, {k_NumParents}, imageGeom->getId()); - - // Volumes with 6 tuples - UnitTest::CreateTestDataArray(dataStructure, k_VolumesName, {k_NumFeatures}, {1}, featureAM->getId()); - // Contiguous NL with 6 tuples - NeighborList::Create(dataStructure, k_ContiguousNLName, ShapeType{k_NumFeatures}, featureAM->getId()); - // Parent Volumes - UnitTest::CreateTestDataArray(dataStructure, k_ParentVolumesName, {k_NumParents}, {1}, parentAM->getId()); - - // ParentIds in a DIFFERENT AM with a different tuple count (mismatch!) - auto* mismatchAM = AttributeMatrix::Create(dataStructure, "MismatchAM", {10}, imageGeom->getId()); - UnitTest::CreateTestDataArray(dataStructure, k_ParentIdsName, {10}, {1}, mismatchAM->getId()); + auto* featureAM = AttributeMatrix::Create(dataStructure, "FeatureData", {k_NumFeatures}, imageGeom->getId()); + auto* parentAM = AttributeMatrix::Create(dataStructure, "ParentData", {k_NumParents}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Volumes", {k_NumFeatures}, {1}, featureAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentIds", {k_NumFeatures}, {1}, featureAM->getId()); + NeighborList::Create(dataStructure, "ContigNL", ShapeType{k_NumFeatures}, featureAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentVolumes", {k_NumParents}, {1}, parentAM->getId()); - DataPath mismatchParentIdsPath = DataPath({k_ImageGeomName, "MismatchAM", k_ParentIdsName}); + auto* mismatchAM = AttributeMatrix::Create(dataStructure, "MismatchAM", {4}, imageGeom->getId()); + NeighborList::Create(dataStructure, "NonContigNL", ShapeType{4}, mismatchAM->getId()); ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(false, false); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(mismatchParentIdsPath)); + Arguments args; + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "Volumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "ContigNL"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(true)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "MismatchAM", "NonContigNL"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "ParentIds"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(DataPath({k_ImageGeomName, "ParentData", "ParentVolumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any("CheckedFeatures")); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any("GroupingDensities")); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); } -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Preflight Error - Volumes not in AttributeMatrix", "[SimplnxReview][ComputeGroupingDensityFilter]") +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Preflight Error - Volumes not in AttributeMatrix (-15673)", "[SimplnxCore][ComputeGroupingDensityFilter]") { UnitTest::LoadPlugins(); - // Build a DataStructure where Volumes is NOT inside an AttributeMatrix + // Volumes placed directly under ImageGeom (no AttributeMatrix parent) DataStructure dataStructure; auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); imageGeom->setDimensions({1, 1, 1}); - // Create volumes directly under the ImageGeom (not in an AM) - UnitTest::CreateTestDataArray(dataStructure, k_VolumesName, {k_NumFeatures}, {1}, imageGeom->getId()); - UnitTest::CreateTestDataArray(dataStructure, k_ParentIdsName, {k_NumFeatures}, {1}, imageGeom->getId()); - NeighborList::Create(dataStructure, k_ContiguousNLName, ShapeType{k_NumFeatures}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Volumes", {k_NumFeatures}, {1}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentIds", {k_NumFeatures}, {1}, imageGeom->getId()); + NeighborList::Create(dataStructure, "ContigNL", ShapeType{k_NumFeatures}, imageGeom->getId()); - auto* parentAM = AttributeMatrix::Create(dataStructure, k_ParentAMName, {k_NumParents}, imageGeom->getId()); - UnitTest::CreateTestDataArray(dataStructure, k_ParentVolumesName, {k_NumParents}, {1}, parentAM->getId()); - - DataPath volumesNoAMPath = DataPath({k_ImageGeomName, k_VolumesName}); - DataPath parentIdsNoAMPath = DataPath({k_ImageGeomName, k_ParentIdsName}); - DataPath contiguousNLNoAMPath = DataPath({k_ImageGeomName, k_ContiguousNLName}); + auto* parentAM = AttributeMatrix::Create(dataStructure, "ParentData", {k_NumParents}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentVolumes", {k_NumParents}, {1}, parentAM->getId()); ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(false, false); - args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(volumesNoAMPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(parentIdsNoAMPath)); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(contiguousNLNoAMPath)); + Arguments args; + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "Volumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "ContigNL"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(DataPath{})); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(DataPath({k_ImageGeomName, "ParentIds"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(DataPath({k_ImageGeomName, "ParentData", "ParentVolumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any("CheckedFeatures")); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any("GroupingDensities")); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); } -TEST_CASE("SimplnxReview::ComputeGroupingDensityFilter: Preflight Error - Parent Volumes not in AttributeMatrix", "[SimplnxReview][ComputeGroupingDensityFilter]") +TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: Preflight Error - Parent Volumes not in AttributeMatrix (-15670)", "[SimplnxCore][ComputeGroupingDensityFilter]") { UnitTest::LoadPlugins(); + // ParentVolumes placed directly under ImageGeom (no AM parent) DataStructure dataStructure; auto* imageGeom = ImageGeom::Create(dataStructure, k_ImageGeomName); imageGeom->setDimensions({1, 1, 1}); - auto* featureAM = AttributeMatrix::Create(dataStructure, k_FeatureAMName, {k_NumFeatures}, imageGeom->getId()); - UnitTest::CreateTestDataArray(dataStructure, k_VolumesName, {k_NumFeatures}, {1}, featureAM->getId()); - UnitTest::CreateTestDataArray(dataStructure, k_ParentIdsName, {k_NumFeatures}, {1}, featureAM->getId()); - NeighborList::Create(dataStructure, k_ContiguousNLName, ShapeType{k_NumFeatures}, featureAM->getId()); - - // Parent Volumes directly under ImageGeom (not in AM) - UnitTest::CreateTestDataArray(dataStructure, k_ParentVolumesName, {k_NumParents}, {1}, imageGeom->getId()); + auto* featureAM = AttributeMatrix::Create(dataStructure, "FeatureData", {k_NumFeatures}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Volumes", {k_NumFeatures}, {1}, featureAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "ParentIds", {k_NumFeatures}, {1}, featureAM->getId()); + NeighborList::Create(dataStructure, "ContigNL", ShapeType{k_NumFeatures}, featureAM->getId()); - DataPath parentVolumesNoAMPath = DataPath({k_ImageGeomName, k_ParentVolumesName}); + UnitTest::CreateTestDataArray(dataStructure, "ParentVolumes", {k_NumParents}, {1}, imageGeom->getId()); ComputeGroupingDensityFilter filter; - Arguments args = createFilterArgs(false, false); - args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(parentVolumesNoAMPath)); + Arguments args; + args.insertOrAssign(ComputeGroupingDensityFilter::k_FeatureVolumesArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "Volumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ContiguousNeighborListArrayPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "ContigNL"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_UseNonContiguousNeighbors_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_NonContiguousNeighborListArrayPath_Key, std::make_any(DataPath{})); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentIdsPath_Key, std::make_any(DataPath({k_ImageGeomName, "FeatureData", "ParentIds"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_ParentVolumesPath_Key, std::make_any(DataPath({k_ImageGeomName, "ParentVolumes"}))); + args.insertOrAssign(ComputeGroupingDensityFilter::k_FindCheckedFeatures_Key, std::make_any(false)); + args.insertOrAssign(ComputeGroupingDensityFilter::k_CheckedFeaturesName_Key, std::make_any("CheckedFeatures")); + args.insertOrAssign(ComputeGroupingDensityFilter::k_GroupingDensitiesName_Key, std::make_any("GroupingDensities")); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); } +// ============================================================================= +// SIMPL JSON backwards-compatibility — verifies FromSIMPLJson() correctly +// translates the SIMPL 6.5 filter parameter keys to the simplnx Arguments. +// ============================================================================= + TEST_CASE("SimplnxCore::ComputeGroupingDensityFilter: SIMPL Backwards Compatibility", "[SimplnxCore][ComputeGroupingDensityFilter][BackwardsCompatibility]") { auto app = Application::GetOrCreateInstance(); diff --git a/src/Plugins/SimplnxCore/vv/ComputeGroupingDensityFilter.md b/src/Plugins/SimplnxCore/vv/ComputeGroupingDensityFilter.md new file mode 100644 index 0000000000..34384da8b2 --- /dev/null +++ b/src/Plugins/SimplnxCore/vv/ComputeGroupingDensityFilter.md @@ -0,0 +1,147 @@ +# V&V Report: ComputeGroupingDensityFilter + +| | | +|---|---| +| Plugin | SimplnxCore | +| SIMPLNX UUID | ff46afcf-de32-4f37-98bc-8f0fd4b3c122 | +| DREAM3D 6.5.171 equivalent | `FindGroupingDensity` (SIMPL UUID `708be082-8b08-4db2-94be-52781ed4d53d`) — *unreleased pre-simplnx implementation* on `tuks188/DREAM3D` `feature/770_Grouping_Density`; never merged into `v6_5_171`. Served as the port source. | +| Verified commit | ** | +| Status | **COMPLETE — 2026-05-27** | +| Sign-off | Michael Jackson (BlueQuartz Software), 2026-05-27 | + +## Summary + +- This **Filter** computes a **Grouping Density** value for each **Parent Feature** in a hierarchical reconstruction. Hierarchical reconstructions involve more than one level of segmentation, creating a **Feature** to **Parent Feature** relationship (e.g., grains grouped into reconstructed parent grains). +- The filter was verified by generating a small data set that exercises each code path and combination of featureIds. The final calculations were done by hand and then verified by executing the filter. +- The result is that the filter generates the expected output values + + +## Resolution (V&V outcomes — 2026-05-27) + +| Topic | DRAFT-tentative finding | Final confirmed finding | +|---|---|---| +| Algorithm Relationship | Port (with rename `Find→Compute`) | **Port** — confirmed. The SIMPLNX algorithm is a line-by-line translation of the legacy `FindGroupingDensity::execute()` from `tuks188/DREAM3D` `feature/770_Grouping_Density`. Six port-time deltas documented in the V&V report, none change output. | +| Oracle class | Class 1 (Analytical) primary, optionally Class 4 (Invariant) | **Class 1 + Class 4 confirmed.** Hand-derivation embedded in V&V report; invariant predicates added inline in the test (sentinel `-1.0f`, `density ≤ 1.0`, `density > 0 ∨ == -1.0f`, CheckedFeatures range). Second-engineer review skipped — set-union arithmetic on 5-feature toy dataset + bit-identical cross-check against independently-built legacy. | +| Legacy comparison | Not yet run | **Done. No deviations.** All 4 `(UseNonContiguous, FindCheckedFeatures)` configurations produce bit-identical output between SIMPLNX and the locally-rebuilt legacy `FindGroupingDensity` (`/Users/mjackson/DREAM3D-Dev/DREAM3D` 6.5.172 branch with feature-branch sources). See `vv/deviations/ComputeGroupingDensityFilter.md`. | +| `[SimplnxReview]` test-tag bug | Flagged as cleanup-needed | **Fixed.** All 7 tests now use `[SimplnxCore][ComputeGroupingDensityFilter]`. | +| Exemplar archive | `compute_grouping_densities.tar.gz` (v1) — provenance TBD | **Replaced.** v1 archive retired from this filter's tests; new `compute_grouping_densities_v2.tar.gz` published to the GitHub Data_Archive with hand-review sign-off in its inline ReadMe + comparison report. SHA512 wired into `test/CMakeLists.txt`. | +| Test inventory | 8 tests, 4 redundant `(NC, CF)` execution tests + 1 exemplar test | **Restructured to 7 tests.** 5 redundant tests replaced by a single DYNAMIC_SECTION exemplar A/B test covering all 4 configurations. Added: empty-parent sentinel test (Class 4) + preflight error -15672 test (gap closed). Kept: 3 existing preflight error tests + SIMPL backwards-compat test. | +| Deviation entries (`ComputeGroupingDensity-D`) | Placeholder pending comparison | **None.** No deviations observed across all 4 configurations. See `vv/deviations/ComputeGroupingDensityFilter.md` for the comparison method, fixture, SHA512, and migration recommendation. | +| Algorithm review (`review-algorithm`) | Not visible from PR history | **Done.** Code-comment cleanup applied (sentinel documented, deleted-special-members trailing comments removed, throwaway-placeholder pattern explained, FindDensitySpecializations + FindDensityGrouping class docs added). Memory / formatting / overflow concerns mitigated by the engineer. Tie-break behavior added to the user-facing filter doc. | +| Filter documentation (`review-filter-docs`) | "Excellent — empty References section is the one defect" | **Updated.** Added Required Input Sources section with MyST cross-links to upstream producer filters. Stated volume units explicitly. MyST-linked the inline Compute Feature Neighborhoods mention. Italicized the `-1.0` sentinel as a value reference. References + Example Pipelines remain empty (no published paper; no shipping pipeline currently uses this filter). | +| Verification archive (OneDrive) | Not yet created | **Materially captured by the v2 GitHub Data_Archive release** — the v2 tarball contains the input file, all 4 legacy and SIMPLNX outputs, the comparison script + report, the legacy and SIMPLNX pipelines, and the hand-review sign-off ReadMe. OneDrive duplication can be done at SBIR deliverable assembly. | + + +## Algorithm Relationship + + +*Classification:* **Port** + +*Evidence:* The SIMPLNX algorithm at `Algorithms/ComputeGroupingDensity.cpp` (219 lines) is a line-by-line translation of the legacy `FindGroupingDensity::execute()` on `tuks188/DREAM3D` `feature/770_Grouping_Density` (469-line `.cpp` file; ~80-line algorithm body). Identical control flow (nested parent×feature loops + neighbor-list walks), identical sentinel (`-1.0f` when `totalCheckVolume == 0.0f`), identical density formula (`curParentVolume / totalCheckVolume`), and a preserved-from-legacy variable-name lineage (`totalCheckVolume` → `totalFeatureCheckVolume`, `checkedfeaturevolumes` → `checkedFeatureVolumes`). Same SIMPL UUID retained via `SimplnxCoreLegacyUUIDMapping.hpp` + SIMPL conversion fixture at `test/simpl_conversion/6_5/ComputeGroupingDensityFilter.json`. **Important caveat:** the legacy filter was never officially released in any DREAM3D 6.5.x — it lived on an un-merged feature branch on a contributor fork. However, a small number of important customers consumed a custom DREAM3D 6.5.x build that included this filter, and those customers have downstream data dependent on its output. Therefore the policy's diff-explanation purpose **does** apply to this filter, just with a narrower migrant audience than usual. The legacy comparison evidence is captured in `vv/deviations/ComputeGroupingDensityFilter.md` via an A/B run against a local rebuild of the legacy source (`/Users/mjackson/DREAM3D-Dev/DREAM3D` on the 6.5.172 branch with the feature-branch sources pulled in). Verification still requires an independent oracle (see Oracle section). + +*Port-time deltas that do not change output (defensible "Port" rather than "Minor changes"):* + +1. `QVector` totalCheckList (linear `.contains()`, O(n²) per parent) → `std::unordered_set` (O(1) membership, O(n) per parent) — performance, no behavior change. +2. Runtime `if (m_FindCheckedFeatures == true)` in the inner loop → `if constexpr (FindingCheckedFeatures)` template specialization — performance, no behavior change. +3. Runtime `for(k=0; k checkedfeaturevolumes(numfeatures, 0.0f)` always allocated → conditionally allocated only when `FindCheckedFeatures==true` — memory savings, no behavior change (legacy zeros were unread when the flag was false). +5. Added: `m_ShouldCancel` check in the outer parent loop (legacy has no cancel support). +6. Added: `ThrottledMessenger` per-parent progress (legacy emits one terminal "Complete" message). + +*Material PRs since baseline (2025-10-01):* + +- **#1548** — "FILT: Compute Grouping Density filter added." (merge `30c9b1090`, 2026-02-25) — initial port from the legacy feature branch: filter + algorithm (.hpp/.cpp), docs (with worked example + 3 figures), `FromSIMPLJson()` conversion path, legacy UUID map entry, 431-line test file covering all 4 template specializations + 3 preflight error tests, exemplar archive. +- *(excluded — broad refactor)* #1588 — "ENH: SIMPL Backwards Compatibility Test Redesign" (merge `f854bb636`, 2026-04-22) — on the cross-cutting exclusion list; added only the per-filter SIMPL backwards-compat fixture at `test/simpl_conversion/6_5/ComputeGroupingDensityFilter.json`. No algorithm change. + + + +## Oracle + +*Class:* **1 (Analytical)** primary, **4 (Invariant)** companion. + +*Applied (Class 1 — Analytical):* Expected outputs are hand-derived from the input definition without reference to any DREAM3D implementation. For each parent index `i ≥ 1`: + +1. `assigned = {j : ParentIds[j] == i}` +2. `touched = assigned ∪ {nbr : nbr ∈ contiguousNL[j], j ∈ assigned}` (and additionally `∪ {nbr : nbr ∈ nonContiguousNL[j], j ∈ assigned}` when `UseNonContiguousNeighbors == true`) +3. `totalCheckVolume[i] = Σ Volumes[k] for k ∈ touched` +4. `GroupingDensities[i] = ParentVolumes[i] / totalCheckVolume[i]`, or `-1.0f` sentinel when `totalCheckVolume[i] == 0` + +For `CheckedFeatures[k]` (when `FindCheckedFeatures == true`): the parent with the largest `ParentVolumes` among the parents that touched feature `k` (last-writer-wins-on-greater-volume semantics in the algorithm). + +Hand-derivation on the v2 toy dataset (`Volumes = [0,10,20,15,25,30]`, `ParentIds = [0,1,1,1,2,2]`, `ParentVolumes = [0,45,55]`, contiguous chain `1↔2↔3↔4↔5`, non-contiguous pairs `1↔4` and `2↔5`): + +| Config (NC, CF) | Parent 1 touched | Σ Vol | `density[1]` | Parent 2 touched | Σ Vol | `density[2]` | +|---|---|---|---|---|---|---| +| (0, *) | {1,2,3,4} | 70 | `45/70` ≈ `0.6428571` | {3,4,5} | 70 | `55/70` ≈ `0.7857143` | +| (1, *) | {1,2,3,4,5} | 100 | `0.45` | {1,2,3,4,5} | 100 | `0.55` | + +CheckedFeatures derivations (when CF=1): NC=0 → `[0,1,1,2,2,2]` (parent 1 claims features {1,2}; parent 2 claims {3,4} as the larger-volume parent overriding parent 1's earlier claim, plus its own {5}). NC=1 → `[0,2,2,2,2,2]` (both parents touch all 5; parent 2 wins on every feature). Full derivation in `vv/comparisons/ComputeGroupingDensityFilter/README.md` and the v2 archive's `README.md`. + +*Applied (Class 4 — Invariant):* Derivable properties any valid output must satisfy, asserted inline in test code (`ComputeGroupingDensityTest.cpp` Exemplar A/B + Empty-parent edge case): + +- `GroupingDensities[0] == 0.0f` (placeholder parent never touched) +- For `i ≥ 1`: `GroupingDensities[i] > 0.0f ∨ == -1.0f` (positive or sentinel) +- For `i ≥ 1`: `GroupingDensities[i] ≤ 1.0f` (totalCheckVolume always includes the parent's own features → ≥ ParentVolumes[i]) +- `CheckedFeatures[k] ∈ {0, …, numParents-1}` when produced; `CheckedFeatures[0] == 0` +- Empty-parent sentinel asserted directly: `REQUIRE(densities[i] == -1.0f)` when parent `i` has no assigned features + +*Encoded:* + +- **Class 1 (Analytical)**: `test/ComputeGroupingDensityTest.cpp::"Exemplar A/B — all 4 configurations"` — 4 fixtures (`NC0_CF0`, `NC0_CF1`, `NC1_CF0`, `NC1_CF1`). Bit-exact `CompareDataArrays` and `` against the v2 exemplar (`compute_grouping_densities_v2.tar.gz`) whose `GroupingDensities_*` and `CheckedFeatures_*` arrays equal the hand-derivation above to float32 precision. +- **Class 4 (Invariant)**: same test (inline invariant predicates run for all 4 fixtures); plus `test/ComputeGroupingDensityTest.cpp::"Empty-parent edge case (-1.0f sentinel)"` — 2 assertions for `density[1] == 1.0` (parent with assigned features) and `density[2] == -1.0f` (sentinel). + +6 fixture assertions total, all pass at the verified commit. + +*Second-engineer review:* **Skipped — recorded reason:** Class 1 derivation is set-union sums + ratio division on a 5-feature toy dataset (high-school arithmetic). External cross-validation was obtained via the independently-authored legacy `FindGroupingDensity` implementation (`tuks188/DREAM3D` `feature/770_Grouping_Density` sources rebuilt locally): the Phase 9 A/B comparison produced bit-identical agreement across all 4 configurations (see `vv/deviations/ComputeGroupingDensityFilter.md` and `compute_grouping_densities_v2/results/ab_comparison_report.txt`). Any oracle-derivation error would have surfaced as a Deviation. Formal second-engineer review of a 5-feature analytical oracle was not justified given this cross-check. + +## Code path coverage + + + +*7 of 7 paths enumerated — Test case column to be filled by vv-tests.* + +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.cpp` + +The algorithm dispatches on two booleans, producing 4 template specializations of `FindDensityGrouping()`. Two additional runtime branches handle the empty-parent sentinel and cancellation. Preflight error paths are tested separately at the filter level. + +| Path | Test case | +|---|---| +| `UseNonContiguousNeighbors=true, FindCheckedFeatures=true` (full path, both neighbor lists + per-feature parent tracking) | *(pending)* | +| `UseNonContiguousNeighbors=true, FindCheckedFeatures=false` (both neighbor lists, no per-feature parent tracking) | *(pending)* | +| `UseNonContiguousNeighbors=false, FindCheckedFeatures=true` (contiguous neighbors only + per-feature parent tracking) | *(pending)* | +| `UseNonContiguousNeighbors=false, FindCheckedFeatures=false` (contiguous neighbors only, no per-feature parent tracking) | *(pending)* | +| Edge: `totalFeatureCheckVolume == 0.0f` for a parent → density sentinel `-1.0f` written at line 114 | *(pending)* | +| Cancellation: `m_ShouldCancel` checked inside the parent-id outer loop (line 76); early return without writing further densities | *(pending)* | +| Preflight errors: invalid / mismatched input array paths (3 error tests in `ComputeGroupingDensityTest.cpp` per retroactive notes — confirm count) | *(pending)* | + +## Test inventory + +| Test case | Status | Notes | +|---|---|---| +| *TestName* | kept / new-for-V&V / retired | *one line if needed* | + +## Exemplar archive + +- **Archive:** *``* +- **SHA512:** *``* +- **Provenance:** *`src/Plugins/

/vv/provenance/.md`* + +## Deviations from Pre-SIMPLNX Implementation + +> **Note (this filter only):** the section heading has been re-titled from the template default "Deviations from DREAM3D 6.5.171" because the legacy is the pre-SIMPLNX `FindGroupingDensity` on `tuks188/DREAM3D` `feature/770_Grouping_Density` — not the shipped 6.5.171 baseline. See the Algorithm Relationship section for context. This rename is local to this report only; `docs/vv_templates/report_template.md` is unchanged. + +**No deviations observed.** Runtime A/B comparison run on the +`compute_grouping_densities_v2.tar.gz` fixture: all 4 +`(UseNonContiguousNeighbors, FindCheckedFeatures)` combinations of +`ComputeGroupingDensityFilter` (SIMPLNX) and `FindGroupingDensity` (legacy +DREAM3D 6.5.172 build with the feature-branch sources) produced +**bit-identical** `GroupingDensities` and `CheckedFeatures` output. See +`vv/deviations/ComputeGroupingDensityFilter.md` for the full per-configuration +result table, comparison method, build provenance, and the migration +recommendation for legacy-custom-build customers (*trust SIMPLNX, output is +bit-identical*). + +| Fixture | `compute_grouping_densities_v2.tar.gz` | +|---|---| +| SHA512 | `3aaabb63c4fa16f7fa192ae4ee9dbba9394ec7f1cd19aff55e399a624d495a3a778c7f6f282911f681e85cea99e4c6d15344274e9107f337af7d4a19f93784ff` | +| Driver script | `compute_grouping_densities_v2compare_outputs.py` | diff --git a/src/Plugins/SimplnxCore/vv/deviations/ComputeGroupingDensityFilter.md b/src/Plugins/SimplnxCore/vv/deviations/ComputeGroupingDensityFilter.md new file mode 100644 index 0000000000..2d3542d0de --- /dev/null +++ b/src/Plugins/SimplnxCore/vv/deviations/ComputeGroupingDensityFilter.md @@ -0,0 +1,91 @@ +# Deviations from Pre-SIMPLNX Implementation: ComputeGroupingDensityFilter + +> **Note (this filter only):** the title has been re-scoped from the template +> default "Deviations from DREAM3D 6.5.171." `ComputeGroupingDensityFilter` is +> a port of the pre-SIMPLNX `FindGroupingDensity` (SIMPL UUID +> `708be082-8b08-4db2-94be-52781ed4d53d`) on `tuks188/DREAM3D` +> `feature/770_Grouping_Density` — not the shipped 6.5.171 baseline. +> The legacy filter was never officially released, but a small set of customers +> consumed a custom DREAM3D 6.5.x build that included it. See the V&V report's +> Algorithm Relationship section for context. + +## Headline + +**No deviations observed.** All 4 `(UseNonContiguousNeighbors, FindCheckedFeatures)` +configurations of `ComputeGroupingDensityFilter` (SIMPLNX) and `FindGroupingDensity` +(legacy 6.5.172 build with the feature-branch sources) produced **bit-identical** +`GroupingDensities` and `CheckedFeatures` arrays when run against identical +input data. + +## Comparison method + +| | | +|---|---| +| **Comparison type** | Runtime A/B (not analytical — both implementations actually executed) | +| **Identical inputs** | Same legacy-format `.dream3d` file consumed by both sides (no per-implementation data prep) | +| **Tolerance** | Bit-identical (`np.array_equal` on raw float32 / int32 bytes — stricter than any float epsilon) | +| **Configurations exercised** | All 4 — `(NC, CF) ∈ {(0,0), (0,1), (1,0), (1,1)}` covering every template specialization of `FindDensityGrouping()` | +| **Comparison driver** | `src/Plugins/SimplnxCore/vv/comparisons/ComputeGroupingDensityFilter/compare_outputs.py` | +| **Comparison fixture archive** | `compute_grouping_densities_v2.tar.gz` | +| **Archive SHA512** | `3aaabb63c4fa16f7fa192ae4ee9dbba9394ec7f1cd19aff55e399a624d495a3a778c7f6f282911f681e85cea99e4c6d15344274e9107f337af7d4a19f93784ff` | + +### Inputs + +Hand-built minimal dataset matching `createTestDataStructure()` in +`test/ComputeGroupingDensityTest.cpp` (lines 62-141): + +| Path | Values | +|---|---| +| `DataContainer/FeatureData/Volumes` (Float32, 6) | `[0, 10, 20, 15, 25, 30]` | +| `DataContainer/FeatureData/ParentIds` (Int32, 6) | `[0, 1, 1, 1, 2, 2]` | +| `DataContainer/FeatureData/ContiguousNeighborList` | `[[], [2], [1,3], [2,4], [3,5], [4]]` | +| `DataContainer/FeatureData/NonContiguousNeighborList` | `[[], [4], [5], [], [1], [2]]` | +| `DataContainer/ParentData/ParentVolumes` (Float32, 3) | `[0, 45, 55]` | + +### Per-configuration result + +| (NC, CF) | `GroupingDensities` (both sides, float32) | `CheckedFeatures` (both sides, int32) | Diff | +|---|---|---|---| +| (0, 0) | `[0.0, 0.6428571343421936, 0.7857142686843872]` | array not produced (CF=false) | bit-identical | +| (0, 1) | `[0.0, 0.6428571343421936, 0.7857142686843872]` | `[0, 1, 1, 2, 2, 2]` | bit-identical | +| (1, 0) | `[0.0, 0.44999998807907104, 0.550000011920929]` | array not produced (CF=false) | bit-identical | +| (1, 1) | `[0.0, 0.44999998807907104, 0.550000011920929]` | `[0, 2, 2, 2, 2, 2]` | bit-identical | + +The numerical values match the SIMPLNX unit-test hand calculations exactly +(45/70, 55/70 for the contiguous-only cases; 45/100, 55/100 for the +non-contiguous-included cases — these are the exact float32 representations). + +### Build/source provenance + +| Side | Build / source | +|---|---| +| Legacy 6.5.172 | `/Users/mjackson/DREAM3D-Dev/DREAM3D` (6.5.172 branch + `tuks188/DREAM3D` `feature/770_Grouping_Density` sources pulled in). `FindGroupingDensity.{cpp,h}` placed at `Source/Plugins/Statistics/StatisticsFilters/`. | +| SIMPLNX | `Workspace3/DREAM3D-Build/NX-Com-Qt69-Vtk95-Rel/Bin/nxrunner` (1.7.0 build 2026/05/07). Filter at `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeGroupingDensity.{hpp,cpp}` and `.../Filters/ComputeGroupingDensityFilter.{hpp,cpp}`. | + +## Algorithmic deltas observed (none affect output) + +For audit completeness, the SIMPLNX port made the following structural changes +versus the legacy `execute()` body. The runtime A/B above confirms each is +output-preserving: + +1. **Container swap:** `QVector` totalCheckList (linear `.contains()`, O(n²) per parent) + → `std::unordered_set` (O(1) membership). **No output change** — both have set-membership semantics on the same set of feature ids; floating-point accumulation order is unaffected. +2. **Boolean dispatch:** runtime `if (m_FindCheckedFeatures == true)` inside the inner loop + → compile-time `if constexpr (FindingCheckedFeatures)` template specialization. **No output change.** +3. **Neighbor-list unroll:** runtime `for(k=0; k checkedfeaturevolumes(numfeatures, 0.0f)` always allocated + → conditionally allocated only when `FindCheckedFeatures==true`. **No output change** (legacy zeros were never read when the flag was false). +5. **Cancellation support:** added `m_ShouldCancel` check in the outer parent loop. (Legacy had no cancel support; A/B was run without cancel so this code path is not exercised by the comparison.) +6. **Progress reporting:** added `ThrottledMessenger` per-parent progress. (Legacy emits one terminal "Complete" message; SIMPLNX emits per-parent updates. Affects logs only.) + +Both implementations also behave the same way for the `FindCheckedFeatures=false` +cases — **neither** writes the `CheckedFeatures` output array (it's omitted, not +written-and-empty). + +## Migration recommendation for customers of the legacy custom build + +**Trust SIMPLNX. Output is bit-identical to the legacy filter for matched +inputs across all 4 configurations.** No migration tolerance band is required. +Downstream consumers can expect numerically identical `GroupingDensities` and +`CheckedFeatures` arrays. diff --git a/src/simplnx/DataStructure/AbstractListStore.hpp b/src/simplnx/DataStructure/AbstractListStore.hpp index cae1138742..d3441f766a 100644 --- a/src/simplnx/DataStructure/AbstractListStore.hpp +++ b/src/simplnx/DataStructure/AbstractListStore.hpp @@ -610,14 +610,14 @@ class AbstractListStore : public IListStore virtual vector_type operator[](usize grainId) const = 0; /** - * @brief Returns a const reference to the vector_type value found at the specified index. This cannot be used to edit the vector_type value found at the specified index. + * @brief Returns a copy of the vector_type value found at the specified index. * @param grainId * @return vector_type */ virtual vector_type at(int32 grainId) const = 0; /** - * @brief Returns a const reference to the vector_type value found at the specified index. This cannot be used to edit the vector_type value found at the specified index. + * @brief Returns a copy of the vector_type value found at the specified index. * @param grainId * @return vector_type */ diff --git a/src/simplnx/DataStructure/EmptyListStore.hpp b/src/simplnx/DataStructure/EmptyListStore.hpp index daa88b9a51..75c8829e82 100644 --- a/src/simplnx/DataStructure/EmptyListStore.hpp +++ b/src/simplnx/DataStructure/EmptyListStore.hpp @@ -113,7 +113,17 @@ class EmptyListStore : public AbstractListStore * @brief Returns the total number of lists in the EmptyListStore. * @return uint64 The number of lists (equal to the number of tuples) */ - uint64 getNumberOfLists() const override + usize getNumberOfLists() const override + { + return m_NumTuples; + } + + /** + * @brief Returns the total number of lists in the list store. + * Alias for getNumberOfLists(). + * @return usize The number of lists + */ + usize size() const override { return m_NumTuples; } diff --git a/src/simplnx/DataStructure/IListStore.hpp b/src/simplnx/DataStructure/IListStore.hpp index 93f0596861..f4dc2d0b50 100644 --- a/src/simplnx/DataStructure/IListStore.hpp +++ b/src/simplnx/DataStructure/IListStore.hpp @@ -49,19 +49,16 @@ class IListStore /** * @brief Returns the total number of lists in the list store. - * @return uint64 The number of lists + * @return usize The number of lists */ - virtual uint64 getNumberOfLists() const = 0; + virtual usize getNumberOfLists() const = 0; /** * @brief Returns the total number of lists in the list store. * Alias for getNumberOfLists(). - * @return uint64 The number of lists + * @return usize The number of lists */ - uint64 size() const - { - return getNumberOfLists(); - } + virtual usize size() const = 0; /** * @brief Clears the array. diff --git a/src/simplnx/DataStructure/ListStore.hpp b/src/simplnx/DataStructure/ListStore.hpp index f7631991a5..df98765a23 100644 --- a/src/simplnx/DataStructure/ListStore.hpp +++ b/src/simplnx/DataStructure/ListStore.hpp @@ -155,6 +155,16 @@ class ListStore : public AbstractListStore return copyOfList(grainId); } + /** + * @brief Returns the total number of lists in the list store. + * Alias for getNumberOfLists(). + * @return usize The number of lists + */ + usize size() const override + { + return m_NumTuples; + } + /** * @brief Returns the number of elements in the list at the specified grain/tuple index. * @param grainId The grain/tuple index to query @@ -210,9 +220,9 @@ class ListStore : public AbstractListStore /** * @brief Returns the total number of lists in the ListStore. - * @return uint64 The number of lists (equal to the number of tuples) + * @return usize The number of lists (equal to the number of tuples) */ - uint64 getNumberOfLists() const override + usize getNumberOfLists() const override { return m_NumTuples; }