diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md index 29d6fd447c..6b381deb03 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md @@ -6,15 +6,22 @@ Statistics (Crystallography) ## Description -This **Filter** determines the Kernel Average Misorientation (KAM) for each **Cell**. The user can select the size of the kernel to be used in the calculation. The kernel size entered by the user is the *radius* of the kernel (i.e., entering values of *1*, *2*, *3* will result in a kernel that is *3*, *5*, and *7* **Cells** in size in the X, Y and Z directions, respectively). The algorithm for determination of KAM is as follows: +This **Filter** determines the Kernel Average Misorientation (KAM) for each **Cell**. This **Filter** requires an **Image Geometry**, and the output KAM values are stored in degrees. The user can select the size of the kernel to be used in the calculation. The kernel size entered by the user is the *radius* of the kernel (i.e., entering values of *1*, *2*, *3* will result in a kernel that is *3*, *5*, and *7* **Cells** in size in the X, Y and Z directions, respectively). The algorithm for determination of KAM is as follows: 1. Calculate the misorientation angle between each **Cell** in a kernel and the central **Cell** of the kernel 2. Average all of the misorientations for the kernel and store at the central **Cell** -The calculation will **not** consider cells that belong to different 'feature Ids', i.e., different grains. +The **Use Feature Ids** option controls which **Cells** within the kernel are included in the average: + ++ **Checked (default):** only **Cells** that belong to the same *Feature* (same *Feature Id*) as the central **Cell** are considered — the calculation will **not** cross grain boundaries. This is the traditional per-grain KAM. ++ **Unchecked:** the *Feature Id* grouping is ignored and the average may cross grain boundaries, producing a per-voxel KAM. A kernel **Cell** is still excluded if its *Feature Id* is 0 (invalid/background data) or if its *Phase* differs from the central **Cell**'s *Phase* (averaging is restricted to Cells of the same Phase). + +In both modes, **Cells** with a *Feature Id* of 0 or a *Phase* of 0 are considered invalid and receive a KAM value of 0. *Note:* All **Cells** in the kernel are weighted equally during the averaging, though they are not equidistant from the central **Cell**. +For related per-feature misorientation metrics, see the **Compute Feature Reference Misorientations** and **Compute Misorientation** filters. + % Auto generated parameter table will be inserted here ## Example Pipelines diff --git a/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/aptr12_Analysis.d3dpipeline b/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/aptr12_Analysis.d3dpipeline index 521054339e..3ea3bd22c0 100644 --- a/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/aptr12_Analysis.d3dpipeline +++ b/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/aptr12_Analysis.d3dpipeline @@ -869,10 +869,14 @@ ], "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, "quats_array_path": { "value": "fw-ar-IF1-aptr12-corr/Cell Data/Quats", "version": 1 + }, + "use_feature_ids": { + "value": true, + "version": 1 } }, "comments": "", diff --git a/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/avtr12_Analysis.d3dpipeline b/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/avtr12_Analysis.d3dpipeline index c996a29a11..a1345a1b87 100644 --- a/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/avtr12_Analysis.d3dpipeline +++ b/src/Plugins/OrientationAnalysis/pipelines/EBSD_File_Processing/avtr12_Analysis.d3dpipeline @@ -869,10 +869,14 @@ ], "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, "quats_array_path": { "value": "fw-ar-IF1-avtr12-corr/Cell Data/Quats", "version": 1 + }, + "use_feature_ids": { + "value": true, + "version": 1 } }, "comments": "", diff --git a/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(04) Small IN100 Crystallographic Statistics.d3dpipeline b/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(04) Small IN100 Crystallographic Statistics.d3dpipeline index ac6002ebb9..e177503e07 100644 --- a/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(04) Small IN100 Crystallographic Statistics.d3dpipeline +++ b/src/Plugins/OrientationAnalysis/pipelines/Small_IN100_Processing/(04) Small IN100 Crystallographic Statistics.d3dpipeline @@ -257,10 +257,14 @@ ], "version": 1 }, - "parameters_version": 1, + "parameters_version": 2, "quats_array_path": { "value": "DataContainer/Cell Data/Quats", "version": 1 + }, + "use_feature_ids": { + "value": true, + "version": 1 } }, "comments": "", diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.cpp index ab80ebb2de..b7170904a0 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.cpp @@ -9,6 +9,7 @@ #include +#include #include using namespace nx::core; @@ -25,8 +26,12 @@ class FindKernelAvgMisorientationsImpl , m_InputValues(inputValues) , m_ShouldCancel(shouldCancel) { + m_OrientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); } + // For each valid focal cell in the chunk: walk the (2rx+1)x(2ry+1)x(2rz+1) kernel, admit + // neighbors per the Use Feature Ids mode, accumulate symmetry-reduced misorientation via + // LaueOps, and store the average (degrees) at the focal cell. Invalid focal cells get 0. void convert(size_t zStart, size_t zEnd, size_t yStart, size_t yEnd, size_t xStart, size_t xEnd) const { // Input Arrays / Parameter Data @@ -39,22 +44,21 @@ class FindKernelAvgMisorientationsImpl const auto& crystalStructuresArray = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); const auto& crystalStructures = crystalStructuresArray.getDataStoreRef(); const auto kernelSize = m_InputValues->KernelSize; + const bool useFeatureIds = m_InputValues->UseFeatureIds; // Output Arrays auto& kernelAvgMisorientationsArray = m_DataStructure.getDataRefAs(m_InputValues->KernelAverageMisorientationsArrayName); auto& kernelAvgMisorientations = kernelAvgMisorientationsArray.getDataStoreRef(); - std::vector m_OrientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); - - auto* gridGeom = m_DataStructure.getDataAs(m_InputValues->InputImageGeometry); - SizeVec3 udims = gridGeom->getDimensions(); + const auto& imageGeom = m_DataStructure.getDataRefAs(m_InputValues->InputImageGeometry); + SizeVec3 udims = imageGeom.getDimensions(); ebsdlib::QuatD q1; ebsdlib::QuatD q2; // messenger values usize counter = 0; - usize increment = (zEnd - zStart) / 100; + usize increment = std::max(static_cast(1), (zEnd - zStart) / 100); ProgressMessenger progressMessenger = m_ProgressMessageHelper.createProgressMessenger(); @@ -63,11 +67,6 @@ class FindKernelAvgMisorientationsImpl auto zPoints = static_cast(udims[2]); for(size_t plane = zStart; plane < zEnd; plane++) { - if(m_ShouldCancel) - { - break; - } - if(counter > increment) { progressMessenger.sendProgressMessage(counter); @@ -76,6 +75,11 @@ class FindKernelAvgMisorientationsImpl for(size_t row = yStart; row < yEnd; row++) { + if(m_ShouldCancel) + { + return; + } + for(size_t col = xStart; col < xEnd; col++) { size_t point = (plane * xPoints * yPoints) + (row * xPoints) + col; @@ -92,29 +96,35 @@ class FindKernelAvgMisorientationsImpl for(int32_t j = -kernelSize[2]; j < kernelSize[2] + 1; j++) { - - if(plane + j < 0 || plane + j > zPoints - 1) + const int64_t zIdx = static_cast(plane) + j; + if(zIdx < 0 || zIdx > zPoints - 1) { continue; } - const int64_t jStride = j * xPoints * yPoints; for(int32_t k = -kernelSize[1]; k < kernelSize[1] + 1; k++) { - if(row + k < 0 || row + k > yPoints - 1) + const int64_t yIdx = static_cast(row) + k; + if(yIdx < 0 || yIdx > yPoints - 1) { continue; } - const int64_t kStride = k * xPoints; for(int32_t l = -kernelSize[0]; l < kernelSize[0] + 1; l++) { - if(col + l < 0 || col + l > xPoints - 1) + const int64_t xIdx = static_cast(col) + l; + if(xIdx < 0 || xIdx > xPoints - 1) { continue; } - const int64_t neighbor = static_cast(point) + jStride + kStride + l; - if(neighbor >= 0 && featureIds[point] == featureIds[static_cast(neighbor)]) + // All three indices are clamped in-bounds, so the flattened neighbor index is + // always valid; no separate negative-index guard is needed. + const auto neighborIdx = static_cast((zIdx * xPoints * yPoints) + (yIdx * xPoints) + xIdx); + // Per-grain mode: neighbor must belong to the same feature as the central cell. + // Per-voxel mode (use_feature_ids == false): neighbor must be a valid cell + // (featureId > 0) of the same phase as the central cell. + const bool neighborContributes = useFeatureIds ? (featureIds[point] == featureIds[neighborIdx]) : (featureIds[neighborIdx] > 0 && cellPhases[neighborIdx] == cellPhases[point]); + if(neighborContributes) { - quatIndex = neighbor * 4; + quatIndex = neighborIdx * 4; q2[0] = quats[quatIndex]; q2[1] = quats[quatIndex + 1]; q2[2] = quats[quatIndex + 2]; @@ -127,13 +137,11 @@ class FindKernelAvgMisorientationsImpl } } } + // numVoxel is always >= 1 here: the focal cell passes both neighbor gates (j=k=l=0) + // and contributes a self-misorientation of 0 degrees. kernelAvgMisorientations[point] = totalMisorientation / static_cast(numVoxel); - if(numVoxel == 0) - { - kernelAvgMisorientations[point] = 0.0f; - } } - if(featureIds[point] == 0 || cellPhases[point] == 0) + else { kernelAvgMisorientations[point] = 0.0f; } @@ -155,6 +163,7 @@ class FindKernelAvgMisorientationsImpl DataStructure& m_DataStructure; const ComputeKernelAvgMisorientationsInputValues* m_InputValues = nullptr; const std::atomic_bool& m_ShouldCancel; + std::vector m_OrientationOps; }; } // namespace @@ -175,8 +184,8 @@ ComputeKernelAvgMisorientations::~ComputeKernelAvgMisorientations() noexcept = d // ----------------------------------------------------------------------------- Result<> ComputeKernelAvgMisorientations::operator()() { - auto* gridGeom = m_DataStructure.getDataAs(m_InputValues->InputImageGeometry); - SizeVec3 udims = gridGeom->getDimensions(); + const auto& imageGeom = m_DataStructure.getDataRefAs(m_InputValues->InputImageGeometry); + SizeVec3 udims = imageGeom.getDimensions(); MessageHelper messageHelper(m_MessageHandler); ProgressMessageHelper progressMessageHelper = messageHelper.createProgressMessageHelper(); diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.hpp index 4fe32ce72d..49329656be 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeKernelAvgMisorientations.hpp @@ -15,6 +15,7 @@ namespace nx::core struct ORIENTATIONANALYSIS_EXPORT ComputeKernelAvgMisorientationsInputValues { VectorInt32Parameter::ValueType KernelSize; + bool UseFeatureIds = true; DataPath FeatureIdsArrayPath; DataPath CellPhasesArrayPath; DataPath QuatsArrayPath; @@ -24,7 +25,12 @@ struct ORIENTATIONANALYSIS_EXPORT ComputeKernelAvgMisorientationsInputValues }; /** - * @class + * @class ComputeKernelAvgMisorientations + * @brief Computes the Kernel Average Misorientation (KAM) for each cell of an Image Geometry. + * For each valid cell (featureId > 0 and phase > 0), the misorientation between the cell and + * every admitted neighbor in a user-sized kernel is averaged and stored in degrees. Neighbors + * are admitted per-grain (same feature id, the default) or per-voxel (featureId > 0 and same + * phase) depending on the UseFeatureIds input. */ class ORIENTATIONANALYSIS_EXPORT ComputeKernelAvgMisorientations { diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.cpp index b667109f2d..7d1a2bf3d8 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.cpp @@ -5,6 +5,7 @@ #include "simplnx/DataStructure/DataPath.hpp" #include "simplnx/Filter/Actions/CreateArrayAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" @@ -56,6 +57,11 @@ Parameters ComputeKernelAvgMisorientationsFilter::parameters() const params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); params.insert(std::make_unique(k_KernelSize_Key, "Kernel Radius", "Size of the kernel in the X, Y and Z directions (in number of Cells)", std::vector{1, 1, 1}, std::vector{"X", "Y", "Z"})); + params.insert(std::make_unique( + k_UseFeatureIds_Key, "Use Feature Ids", + "When checked (default), only kernel Cells belonging to the same Feature as the central Cell are included in the average (per-grain KAM). When unchecked, every in-bounds kernel Cell with a " + "Feature Id greater than 0 and the same phase as the central Cell is included, allowing the average to cross Feature boundaries (per-voxel KAM).", + true)); params.insertSeparator(Parameters::Separator{"Input Cell Data"}); params.insert(std::make_unique(k_SelectedImageGeometryPath_Key, "Selected Image Geometry", "Path to the target geometry", DataPath({"Data Container"}), GeometrySelectionParameter::AllowedTypes{IGeometry::Type::Image})); @@ -81,7 +87,11 @@ Parameters ComputeKernelAvgMisorientationsFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType ComputeKernelAvgMisorientationsFilter::parametersVersion() const { - return 1; + return 2; + // Version 1 -> 2 + // Change 1: + // Added k_UseFeatureIds_Key = "use_feature_ids" (default true preserves the + // original per-grain behavior; false enables per-voxel KAM per issue #1613) } //------------------------------------------------------------------------------ @@ -127,6 +137,7 @@ Result<> ComputeKernelAvgMisorientationsFilter::executeImpl(DataStructure& dataS ComputeKernelAvgMisorientationsInputValues inputValues; inputValues.KernelSize = filterArgs.value(k_KernelSize_Key); + inputValues.UseFeatureIds = filterArgs.value(k_UseFeatureIds_Key); inputValues.FeatureIdsArrayPath = filterArgs.value(k_CellFeatureIdsArrayPath_Key); inputValues.CellPhasesArrayPath = filterArgs.value(k_CellPhasesArrayPath_Key); inputValues.QuatsArrayPath = filterArgs.value(k_QuatsArrayPath_Key); diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.hpp index e6cbd0b51e..463eecf3ae 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeKernelAvgMisorientationsFilter.hpp @@ -9,7 +9,9 @@ namespace nx::core { /** * @class ComputeKernelAvgMisorientationsFilter - * @brief This filter will .... + * @brief Computes the Kernel Average Misorientation (KAM) for each cell, averaging the + * misorientation between the cell and its kernel neighbors either within the cell's feature + * (per-grain, default) or across feature boundaries (per-voxel) per the Use Feature Ids option. */ class ORIENTATIONANALYSIS_EXPORT ComputeKernelAvgMisorientationsFilter : public IFilter { @@ -25,6 +27,7 @@ class ORIENTATIONANALYSIS_EXPORT ComputeKernelAvgMisorientationsFilter : public // Parameter Keys static constexpr StringLiteral k_KernelSize_Key = "kernel_size"; + static constexpr StringLiteral k_UseFeatureIds_Key = "use_feature_ids"; static constexpr StringLiteral k_CellFeatureIdsArrayPath_Key = "feature_ids_path"; static constexpr StringLiteral k_CellPhasesArrayPath_Key = "cell_phases_array_path"; static constexpr StringLiteral k_QuatsArrayPath_Key = "quats_array_path"; diff --git a/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp index 7ec48011ac..df2dee51e7 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp @@ -105,10 +105,11 @@ void SetCellQuat(FixtureData& td, usize cellIdx, const std::array& q (*td.quats)[cellIdx * 4 + 3] = q[3]; } -Arguments BuildArgs(const std::vector& kernelRadius) +Arguments BuildArgs(const std::vector& kernelRadius, bool useFeatureIds = true) { Arguments args; args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_KernelSize_Key, std::make_any(kernelRadius)); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_UseFeatureIds_Key, std::make_any(useFeatureIds)); args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_SelectedImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_FeatureIdsName))); args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_CellPhasesName))); @@ -361,6 +362,179 @@ TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - UnitTest::CheckArraysInheritTupleDims(td.ds); } +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Per-Voxel Mode (use_feature_ids = false)", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Same 6x1x1 layout as the Multi-Feature Multi-Voxel fixture, but run with use_feature_ids = + // false (per-voxel KAM, issue #1613). In this mode a kernel neighbor contributes iff it is + // in-bounds AND featureIds[neighbor] > 0 AND cellPhases[neighbor] == cellPhases[focal]. The + // focal-validity gate (featureIds[focal] > 0 && cellPhases[focal] > 0) is unchanged. + // cell x=0: featureId=1, phase=1, phi1=0 degrees + // cell x=1: featureId=1, phase=1, phi1=10 degrees + // cell x=2: featureId=2, phase=1, phi1=0 degrees + // cell x=3: featureId=2, phase=1, phi1=20 degrees + // cell x=4: featureId=0, phase=0, phi1=N/A (background) + // cell x=5: featureId=1, phase=1, phi1=30 degrees + // + // Kernel radius {1,0,0}. Expected per-voxel KAM: + // cell 0: neighbors {self=0, x=1(F1,P1)=10} -> avg 10/2 = 5.0 + // cell 1: neighbors {x=0(F1,P1)=10, self=0, x=2(F2,P1)=10} -> avg 20/3 ~= 6.6667 + // (default per-grain mode gives 5.0 here - x=2 was skipped; this cell proves the mode differs) + // cell 2: neighbors {x=1(F1,P1)=10, self=0, x=3(F2,P1)=20} -> avg 30/3 = 10.0 + // cell 3: neighbors {x=2(F2,P1)=20, self=0}; x=4 SKIPPED (featureId=0) -> avg 20/2 = 10.0 + // cell 4: focal-invalid (featureId=0, phase=0) -> KAM = 0 exactly + // cell 5: x=4 SKIPPED (featureId=0), {self=0} -> avg 0/1 = 0.0 + // + // Exercises: cross-feature accumulation (cells 1, 2), featureId=0 neighbor exclusion in + // per-voxel mode (cells 3, 5), unchanged focal-invalid path (cell 4). + AnalyticalFixtures::FixtureData td = AnalyticalFixtures::CreateScaffold(6, 1, 1); + // FeatureIds: [1, 1, 2, 2, 0, 1] + (*td.featureIds)[0] = 1; + (*td.featureIds)[1] = 1; + (*td.featureIds)[2] = 2; + (*td.featureIds)[3] = 2; + (*td.featureIds)[4] = 0; + (*td.featureIds)[5] = 1; + // CellPhases: [1, 1, 1, 1, 0, 1] + (*td.cellPhases)[0] = 1; + (*td.cellPhases)[1] = 1; + (*td.cellPhases)[2] = 1; + (*td.cellPhases)[3] = 1; + (*td.cellPhases)[4] = 0; + (*td.cellPhases)[5] = 1; + AnalyticalFixtures::SetCellQuat(td, 0, AnalyticalFixtures::QuatFromPhi1Deg(0.0f)); + AnalyticalFixtures::SetCellQuat(td, 1, AnalyticalFixtures::QuatFromPhi1Deg(10.0f)); + AnalyticalFixtures::SetCellQuat(td, 2, AnalyticalFixtures::QuatFromPhi1Deg(0.0f)); + AnalyticalFixtures::SetCellQuat(td, 3, AnalyticalFixtures::QuatFromPhi1Deg(20.0f)); + AnalyticalFixtures::SetCellQuat(td, 5, AnalyticalFixtures::QuatFromPhi1Deg(30.0f)); + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = AnalyticalFixtures::BuildArgs({1, 0, 0}, false); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = AnalyticalFixtures::GetOutputKAM(td.ds); + const std::array expected = {5.0f, 20.0f / 3.0f, 10.0f, 10.0f, 0.0f, 0.0f}; + for(usize i = 0; i < 6; ++i) + { + REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f)); + } + REQUIRE(kam[4] == 0.0f); + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Per-Voxel Mode Two-Phase Gates", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // 5x1x1 image, every cell its own feature, two cubic phases. Verifies the per-voxel mode's + // neighbor gates: (a) different-phase neighbors are excluded, (b) featureId=0 neighbors are + // excluded, (c) a focal cell with featureId=0 is still forced to KAM=0 even when its phase > 0. + // CrystalStructures: index 0 = sentinel, index 1 = Cubic_High, index 2 = Cubic_High. + // cell x=0: featureId=1, phase=1, phi1=0 degrees + // cell x=1: featureId=2, phase=1, phi1=10 degrees + // cell x=2: featureId=3, phase=2, phi1=20 degrees + // cell x=3: featureId=4, phase=1, phi1=30 degrees + // cell x=4: featureId=0, phase=1, phi1=40 degrees (invalid focal: featureId == 0) + // + // Kernel radius {1,0,0}, use_feature_ids = false. Expected KAM: + // cell 0: {self=0, x=1(P1)=10} -> 10/2 = 5.0 + // (per-grain mode would give 0.0 - every feature is a single cell) + // cell 1: {x=0(P1)=10, self=0}; x=2 SKIPPED (phase 2 != 1) -> 10/2 = 5.0 + // cell 2: {self=0}; x=1 and x=3 SKIPPED (phase 1 != 2) -> 0/1 = 0.0 + // cell 3: {self=0}; x=2 SKIPPED (phase), x=4 SKIPPED (featureId=0) -> 0/1 = 0.0 + // cell 4: focal featureId == 0 -> KAM = 0 exactly + AnalyticalFixtures::FixtureData td = AnalyticalFixtures::CreateScaffold(5, 1, 1, 3); + (*td.crystalStructures)[2] = 1u; // Cubic_High for phase 2 + const std::array featureIds = {1, 2, 3, 4, 0}; + const std::array phases = {1, 1, 2, 1, 1}; + const std::array phi1Deg = {0.0f, 10.0f, 20.0f, 30.0f, 40.0f}; + for(usize i = 0; i < 5; ++i) + { + (*td.featureIds)[i] = featureIds[i]; + (*td.cellPhases)[i] = phases[i]; + AnalyticalFixtures::SetCellQuat(td, i, AnalyticalFixtures::QuatFromPhi1Deg(phi1Deg[i])); + } + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = AnalyticalFixtures::BuildArgs({1, 0, 0}, false); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = AnalyticalFixtures::GetOutputKAM(td.ds); + const std::array expected = {5.0f, 5.0f, 0.0f, 0.0f, 0.0f}; + for(usize i = 0; i < 5; ++i) + { + REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f)); + } + // The invalid focal cell takes the explicit KAM=0 short-circuit. + REQUIRE(kam[4] == 0.0f); + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Mode Equivalence on Single Feature", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Invariant: on single-feature single-phase data, per-grain and per-voxel modes admit exactly + // the same neighbor set (every neighbor passes both gates), so the outputs must be identical + // bit-for-bit. 3x3x3 gradient fixture, phi1 = 2x + 3y + 4z degrees (max 18, well under the + // 45-degree cubic FZ bound), full 3D kernel {1,1,1}. + auto buildFixture = []() { + AnalyticalFixtures::FixtureData td = AnalyticalFixtures::CreateScaffold(3, 3, 3); + for(usize z = 0; z < 3; ++z) + { + for(usize y = 0; y < 3; ++y) + { + for(usize x = 0; x < 3; ++x) + { + const usize idx = (z * 9) + (y * 3) + x; + const auto phi1 = static_cast(2 * x + 3 * y + 4 * z); + AnalyticalFixtures::SetCellQuat(td, idx, AnalyticalFixtures::QuatFromPhi1Deg(phi1)); + } + } + } + return td; + }; + + AnalyticalFixtures::FixtureData tdPerGrain = buildFixture(); + AnalyticalFixtures::FixtureData tdPerVoxel = buildFixture(); + + ComputeKernelAvgMisorientationsFilter filter; + auto runFilter = [&filter](AnalyticalFixtures::FixtureData& td, bool useFeatureIds) { + Arguments args = AnalyticalFixtures::BuildArgs({1, 1, 1}, useFeatureIds); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + }; + runFilter(tdPerGrain, true); + runFilter(tdPerVoxel, false); + + const auto& kamPerGrain = AnalyticalFixtures::GetOutputKAM(tdPerGrain.ds); + const auto& kamPerVoxel = AnalyticalFixtures::GetOutputKAM(tdPerVoxel.ds); + bool anyNonzero = false; + for(usize i = 0; i < tdPerGrain.totalCells; ++i) + { + REQUIRE(kamPerGrain[i] == kamPerVoxel[i]); + if(kamPerGrain[i] > 1e-4f) + { + anyNonzero = true; + } + } + REQUIRE(anyNonzero); // sanity: the fixture is non-trivial + + UnitTest::CheckArraysInheritTupleDims(tdPerGrain.ds); + UnitTest::CheckArraysInheritTupleDims(tdPerVoxel.ds); +} + TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Invariants", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md index 1c8cac18a8..32b8e7fef6 100644 --- a/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md @@ -7,21 +7,21 @@ | SIMPLNX Human Name | Compute Kernel Average Misorientations | | DREAM3D 6.5.171 equivalent | `FindKernelAvgMisorientations` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindKernelAvgMisorientations.{h,cpp}` (UUID `88d332c1-cf6c-52d3-a38d-22f6eae19fa6`) | | Verified commit | ** | -| Status | COMPLETE | -| Sign-off | *Michael Jackson (V&V cycle completion, 2026-06-03)* | +| Status | DRAFT | +| Sign-off | *Reopened 2026-07-15 for the `use_feature_ids` feature (issue #1613, branch `topic/kam_ignore_feature_ids`). Prior sign-off: Michael Jackson (2026-06-03) — superseded; re-sign-off pending second-engineer review.* | ## At a glance | Aspect | Current state | |------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Algorithm Relationship | **Port with bug fix** — per-voxel kernel averaging; focal-validity gate; per-voxel divisor always ≥1 (focal self-included). `QuatF`→`QuatD`; `getMisoQuat`→`calculateMisorientation`; iteration order optimized for cache (`col→row→plane` → `plane→row→col`). D2 (legacy l-loop bound typo `KernelSize.z+1` → `KernelSize.x+1`) corrected at port. UUID reassigned. | -| Oracle (confirmed) | **Class 1 (Analytical) primary** — 4 hand-derived data fixtures covering uniform single-feature, x-axis gradient, z-axis gradient (3D path), and multi-feature multi-voxel with background. **Class 4 (Invariant) companion** — non-negativity, cubic max-angle bound (62.8°), uniform-within-feature implies KAM=0, background-voxel implies KAM=0 exactly. Class 1 oracle uses pure φ1 Bunge ZXZ rotations `(φ1, 0, 0)` so that for cubic symmetry, the misorientation between any two cells equals `|Δφ1|` (the c-axis 4-fold reduction is identity when φ1 differences are ≤45°). | -| Code paths enumerated | 6 of 6 algorithmic paths exercised: (1) focal-valid (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter kernel; (2) inner kernel cell in-bounds + feature-id match → accumulate miso + numVoxel++; (3) inner kernel cell in-bounds + feature-id mismatch → skip (no accumulate); (4) inner kernel cell out-of-bounds (boundary clamp) → `continue`; (5) focal-invalid (`featureIds[point] == 0 || cellPhases[point] == 0`) → KAM = 0 directly; (6) the `numVoxel == 0` fallback at line 131 is dead code in practice (the focal voxel always self-matches when the focal is valid, giving numVoxel ≥ 1) and is not exercised by the fixtures by design. | -| Tests today | **6 TEST_CASEs / 6 ctest entries**, 100% pass (~0.3s combined on EbsdLib 2.4.1+). 4 Class 1 fixtures + 1 Class 4 invariants test (with 3 sub-sections) + 1 SIMPL backwards-compatibility test. **No exemplar archive consumed by this filter.** | -| Exemplar archive | **None — inline-constructed in test source.** The pre-existing main exemplar TEST_CASE (consumed `6_6_stats_test_v2.tar.gz`) was **retired 2026-06-03** because the exemplar `KernelAverageMisorientations` array was a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output, where the precision shift documented as D1 manifests as a non-zero spurious self-misorientation contribution in every focal voxel — see deviations doc). The 4+1 hand-derived data fixtures cover all 5 active algorithmic paths and replace the retired test. The shared archive `6_6_stats_test_v2.tar.gz` remains downloaded for `AlignSectionsMutualInformation`, `ComputeShapes`, and `ComputeSchmids` tests; only F#5's consumption line was removed. | -| Legacy comparison | **Source-inspection comparison against DREAM3D 6.5.171** completed. Two deviations observed: **D1 (EbsdLib 2.4.1 CubicOps precision improvement)** — precision-class deviation analogous to BadDataNeighborOrientationCheck, ComputeFeatureFaceMisorientation, ComputeFeatureNeighborMisorientations, and ComputeFeatureReferenceMisorientations of this cycle. The KAM filter is *more sensitive* to this fix than the per-pair misorientation filters because the kernel inclusion of the focal voxel triggers a self-misorientation call per cell, where the pre-2.4.1 `acos(w near 1)` form returns a spurious ~0.03° on float32-sourced quaternions instead of 0°. This precision noise propagates directly into the per-cell average. **D2 (legacy kernel-bound bug at `FindKernelAvgMisorientations.cpp:264`)** — legacy `for(int32_t l = -m_KernelSize.x; l < m_KernelSize.z + 1; l++)` uses `KernelSize.z + 1` as the upper bound for the x-direction inner loop (should be `KernelSize.x + 1`). SIMPLNX has the correct form at line 108. Bug is dormant when `KernelSize.x == KernelSize.z` (default `{1,1,1}` case); fires for asymmetric kernels. | -| Bug flags | **One real legacy bug (D2) corrected at port time** — see deviations. SIMPLNX has been correct from the port onward; no SIMPLNX-side source change required by this V&V cycle. Logged to `/Users/mjackson/Desktop/bug_triage.md` as a known legacy DREAM3D 6.5.171 issue. | -| V&V phase | **All V&V work complete per V2 policy.** Class 1 + Class 4 oracle confirmed against 6-test suite; circular-oracle archive consumption retired; legacy A/B by source inspection; user-facing doc updated (pipeline name typo fixed; orphan `MassifPipeline` reference removed; `aptr12_Analysis` and `avtr12_Analysis` added). Three source-tree deliverables (this report + `vv/deviations/...` + `vv/provenance/...`) in place. **Outstanding:** Status promotion DRAFT → READY FOR REVIEW pending second-engineer oracle review (recommend Joey Kleingers, especially the multi-feature multi-voxel fixture's per-cell hand-derivation). | +| Algorithm Relationship | **Port with bug fix + one NX-only feature addition** — per-voxel kernel averaging; focal-validity gate; divisor always ≥1 (focal self-included). `QuatF`→`QuatD`; `getMisoQuat`→`calculateMisorientation`; iteration order optimized for cache (`col→row→plane` → `plane→row→col`). D2 (legacy l-loop bound typo `KernelSize.z+1` → `KernelSize.x+1`) corrected at port. UUID reassigned. **New this cycle (issue #1613):** `use_feature_ids` BoolParameter (default `true`). `true` = legacy per-grain behavior (neighbor gate `featureIds[point]==featureIds[neighbor]`, behavior-identical to before). `false` = per-voxel KAM: neighbor contributes iff in-bounds AND `featureIds[neighbor]>0` AND `cellPhases[neighbor]==cellPhases[point]`. Focal gate unchanged. No legacy counterpart — see D3. | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 6 hand-derived data fixtures: uniform single-feature, x-axis gradient, z-axis gradient (3D path), multi-feature multi-voxel with background, **per-voxel multi-feature (`use_feature_ids=false`), and per-voxel two-phase gates**. **Class 4 (Invariant) companion** — non-negativity, cubic max-angle bound (62.8°), uniform-within-feature ⇒ KAM=0, background-voxel ⇒ KAM=0 exactly, **and mode-equivalence (per-grain ≡ per-voxel bit-for-bit on single-feature single-phase data)**. Class 1 oracle uses pure φ1 Bunge ZXZ rotations `(φ1, 0, 0)` so that for cubic symmetry, the misorientation between any two cells equals `|Δφ1|` (the c-axis 4-fold reduction is identity when φ1 differences are ≤45°). | +| Code paths enumerated | 8 paths enumerated, 8 exercised (re-enumerated this cycle for the per-mode neighbor gate; the former path 9, the dead `numVoxel==0` fallback, was removed from SIMPLNX by the review-driven cleanup commit `7f9cddc7d` — see below): (1) focal-valid gate → enter kernel; (2) boundary clamp (out-of-bounds j/k/l, now a signed-index comparison with no separate `neighbor<0` guard) → `continue`; (3) per-grain neighbor feature-id match → accumulate; (4) per-grain neighbor feature-id mismatch → skip; (5) per-voxel neighbor `featureId>0` + phase-match → accumulate; (6) per-voxel neighbor `featureId==0` → skip; (7) per-voxel neighbor phase-mismatch → skip; (8) focal-invalid (`featureIds==0 \|\| cellPhases==0`, now reached via `else`) → KAM=0 directly. | +| Tests today | **9 TEST_CASEs / 9 ctest entries**, 100% pass on in-core and out-of-core builds (see full regression sweep, 2026-07-15). 6 Class 1 fixtures + 2 Class 4 invariant tests (Mode-Equivalence + the 3-sub-section Invariants) + 1 SIMPL backwards-compatibility test. **No exemplar archive consumed by this filter.** | +| Exemplar archive | **None — inline-constructed in test source.** The pre-existing main exemplar TEST_CASE (consumed `6_6_stats_test_v2.tar.gz`) was **retired 2026-06-03** because the exemplar `KernelAverageMisorientations` array was a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output, where the precision shift documented as D1 manifests as a non-zero spurious self-misorientation contribution in every focal voxel — see deviations doc). The 6 Class 1 hand-derived data fixtures, plus the Class 4 invariants, cover all 9 re-enumerated active algorithmic paths and replace the retired test. The shared archive `6_6_stats_test_v2.tar.gz` remains downloaded for `AlignSectionsMutualInformation`, `ComputeShapes`, and `ComputeSchmids` tests; only F#5's consumption line was removed. | +| Legacy comparison | **Runtime A/B against DREAM3D 6.5.171 completed this cycle (default per-grain path).** Identical synthetic input (12³ cube, 8 features, single cubic phase, kernel `{1,1,1}`) fed through both `PipelineRunner` (6.5.171 `FindKernelAvgMisorientations`) and `nxrunner`; inputs bit-identical (Quats, FeatureIds). KAM delta: **max \|Δ\|=0.0072°, mean \|Δ\|=0.00075°, 0/1728 cells exceed 0.01°, bidirectional** (928 cells legacy>nx, 800 legacy 0`, and shares the focal cell's phase — feature boundaries are ignored, so the kernel averages across grain boundaries within the same phase. The focal-validity gate (`featureIds[point] > 0 && cellPhases[point] > 0`) is identical in both modes. + ## Algorithm Relationship -*Classification:* **Port (with UUID reassignment + name rename + one inherent legacy bug corrected at port time).** +*Classification:* **Port (with UUID reassignment + name rename + one inherent legacy bug corrected at port time), plus one NX-only feature addition (`use_feature_ids`, issue #1613).** -*Evidence:* Cross-checked SIMPLNX algorithm against legacy `FindKernelAvgMisorientations.cpp::execute()`. Same per-voxel outer loop, same per-kernel inner loop, same same-feature gate, same `KAM = totalMisorientation / numVoxel`. Port-time deltas: +*Evidence:* Cross-checked SIMPLNX algorithm against legacy `FindKernelAvgMisorientations.cpp::execute()`. Same per-voxel outer loop, same per-kernel inner loop, same `KAM = totalMisorientation / numVoxel`. On the **default per-grain path (`use_feature_ids = true`)** the neighbor gate is identical: legacy `if(good && m_FeatureIds[point] == m_FeatureIds[neighbor])` (line 292) ≡ SIMPLNX `featureIds[point] == featureIds[neighborIdx]`. Port-time deltas: -- `QuatF` → `QuatD`; `getMisoQuat` → `calculateMisorientation` (EbsdLib 2.4.1+ `2·atan2(|v|, w)` precision form — see D1). +- `QuatF` → `QuatD`; `getMisoQuat` → `calculateMisorientation` (EbsdLib 2.4.1+ `2·atan2(|v|, w)` precision form — see D1). The `float32`→`float64` promotion of the misorientation math is the second component of the precision family observed in the runtime A/B (below). - `setParallelizationEnabled` removed (now always parallel via `ParallelData3DAlgorithm`). - Iteration order `col→row→plane` → `plane→row→col` (cache-friendlier for x-fastest-varying storage; mathematically identical since all writes go to the same `point` index). - D2 corrected at port. - UUID reassigned; `Find` → `Compute` rename. -*Material PRs since baseline (filter-introduction):* (none specifically targeting this filter — the algorithm has been stable since the OrientationAnalysis plugin port). +*NX-only feature addition (issue #1613, this cycle):* the `use_feature_ids` BoolParameter. When `true` (default) the code path is bit-for-bit the pre-feature per-grain algorithm — the feature is opt-in and does not alter the default output. When `false`, the neighbor gate becomes `featureIds[neighborIdx] > 0 && cellPhases[neighborIdx] == cellPhases[point]` (per-voxel, phase-gated, feature-agnostic). This is a **new capability with no DREAM3D 6.5.171 counterpart** — validated by the Class 1 / Class 4 oracle only, never by legacy comparison (see D3). + +*Material PRs since baseline:* commits `25959c1f2` through `3e863da2f` (inclusive) on branch `topic/kam_ignore_feature_ids` add the `use_feature_ids` parameter, the per-mode neighbor gate, three unit tests, and the user-doc update, followed by a small follow-up commit (`3e863da2f`) correcting the A/B tallies in this V&V report. No other change to the core algorithm. + +*Review-driven cleanup (2026-07-16, commit `7f9cddc7d`):* a review-flagged, behavior-preserving cleanup pass refactored the kernel boundary-index handling to signed comparisons (the neighbor index is now computed directly from the clamped `zIdx`/`yIdx`/`xIdx`, removing the vacuous unsigned `< 0` checks and the separate `neighbor<0` guard), hoisted `LaueOps` construction out of the per-cell hot path into a worker member built once per run (constructor-time, shared read-only across parallel ranges — same pattern as `ComputeFeatureFaceMisorientationPerTriangleImpl`), tightened cancellation checking from per-plane to per-row granularity, floored the progress-message increment at 1, and removed the dead `numVoxel==0` fallback (folding the invalid-cell reset into an `else` branch, logically equivalent by De Morgan's law since the two branch conditions are complementary for these non-negative `int32` arrays). No algorithmic or numeric change — verified by the unchanged 9-test suite passing on both in-core and out-of-core builds. ## Oracle @@ -63,9 +69,28 @@ Class 1 oracle derived by hand for each fixture in terms of `|Δφ1|` between ce | `Class 1 - 1D x-axis Gradient` | 5x1x1 | {1,0,0} | [2.5, 10/3, 10/3, 10/3, 2.5] (see derivation in test comments) | | `Class 1 - 1D z-axis Gradient (3D path)` | 1x1x3 | {0,0,1} | [5.0, 20/3, 5.0] | | `Class 1 - Multi-Feature Multi-Voxel + BG` | 6x1x1 | {1,0,0} | [5.0, 5.0, 10.0, 10.0, 0.0, 0.0] | +| `Class 1 - Per-Voxel Mode` (`use_feature_ids=false`) | 6x1x1 | {1,0,0} | [5.0, 20/3, 10.0, 10.0, 0.0, 0.0] | +| `Class 1 - Per-Voxel Mode Two-Phase Gates` (`use_feature_ids=false`) | 5x1x1 | {1,0,0} | [5.0, 5.0, 0.0, 0.0, 0.0] | Detailed per-cell hand-derivations are in the test file's TEST_CASE comments and in `vv/provenance/ComputeKernelAvgMisorientationsFilter.md`. +**Per-voxel-mode fixture derivations (new this cycle, issue #1613):** + +*`Class 1 - Per-Voxel Mode`* — the same 6×1×1 layout as the Multi-Feature fixture (featureIds `[1,1,2,2,0,1]`, phases `[1,1,1,1,0,1]`, φ1 `[0,10,0,20,–,30]°`), kernel `{1,0,0}`, run with `use_feature_ids=false`. A neighbor now contributes iff `featureId>0 && phase==focalPhase`, so feature boundaries are crossed but `featureId=0` and phase-mismatched cells are still excluded: +- cell 0: {self=0, x1(F1,P1)=|10−0|} → 10/2 = **5.0** (same as per-grain). +- cell 1: {x0(F1,P1)=|0−10|, self=0, x2(F2,P1)=|0−10|} → 20/3 ≈ **6.667** — the *diagnostic* cell: per-grain gives 5.0 (x2 skipped as different feature); per-voxel includes x2 because it is a valid same-phase cell. This one value proves the mode actually changed behavior. +- cell 2: {x1(F1,P1)=|10−0|, self=0, x3(F2,P1)=|20−0|} → 30/3 = **10.0**. +- cell 3: {x2(F2,P1)=|0−20|, self=0}; x4 excluded (`featureId=0`) → 20/2 = **10.0**. +- cell 4: focal-invalid (`featureId=0, phase=0`) → **0.0** exactly. +- cell 5: {self=0}; x4 excluded (`featureId=0`) → 0/1 = **0.0**. + +*`Class 1 - Per-Voxel Mode Two-Phase Gates`* — 5×1×1, every cell its own feature, two cubic phases (ensemble `[999,1,1]`); featureIds `[1,2,3,4,0]`, phases `[1,1,2,1,1]`, φ1 `[0,10,20,30,40]°`, kernel `{1,0,0}`, `use_feature_ids=false`: +- cell 0: {self=0, x1(P1)=|0−10|} → 10/2 = **5.0** (per-grain would give 0.0 — every feature is a single cell). +- cell 1: {x0(P1)=|10−0|, self=0}; x2 skipped (phase 2 ≠ 1) → 10/2 = **5.0**. +- cell 2: {self=0}; x1, x3 skipped (phase 1 ≠ 2) → 0/1 = **0.0**. +- cell 3: {self=0}; x2 skipped (phase), x4 skipped (`featureId=0`) → 0/1 = **0.0**. +- cell 4: focal `featureId=0` (invalid) → **0.0** exactly, even though its phase>0 — proves the focal gate is unchanged in per-voxel mode. + ### Class 4 (Invariant) Class 4 invariants asserted in the `Class 4 - Invariants` TEST_CASE across 3 sub-sections: @@ -74,6 +99,8 @@ Class 4 invariants asserted in the `Class 4 - Invariants` TEST_CASE across 3 sub 2. **Background cell → KAM == 0 exactly.** Asserted on a 3x1x1 fixture with the middle cell flagged as `(featureId=0, phase=0)`. 3. **Range and non-triviality on the x-axis gradient fixture:** (i) `KAM[i] >= 0` for all cells, (ii) `KAM[i] <= 62.8°` (Mackenzie cubic upper bound), (iii) at least one cell has `KAM > 0` (sanity check that the algorithm actually computed something). +**Mode-equivalence invariant (new this cycle — `Class 4 - Mode Equivalence on Single Feature`):** on single-feature single-phase data, the per-grain gate (`featureId` match) and the per-voxel gate (`featureId>0 && phase match`) admit *exactly the same* neighbor set for every focal cell, so the two modes must produce **bit-for-bit identical** output. Asserted on a 3×3×3 gradient fixture (φ1 = 2x+3y+4z°, max 18° < 45° FZ bound) with kernel `{1,1,1}`: `REQUIRE(kamPerGrain[i] == kamPerVoxel[i])` for every cell, plus a non-triviality guard that at least one cell is non-zero. This is a derived property (not a hand-computed value), so it holds regardless of the EbsdLib precision class and pins the invariant "the feature is opt-in and does not change output where the two gates coincide." + The Class 4 invariants are oracle-agnostic — they hold for any input, so they catch regressions even if specific Class 1 expected values were edited away. ### Class 2, 3, 5 @@ -86,28 +113,37 @@ Recommended pending another engineer review. The multi-feature multi-voxel fixtu ## Code path coverage +Re-enumerated this cycle for the per-mode neighbor gate (`ComputeKernelAvgMisorientations.cpp:124`). Paths 3–4 are the per-grain branch (`use_feature_ids = true`); paths 5–7 are the per-voxel branch (`use_feature_ids = false`). **Updated 2026-07-16 (commit `7f9cddc7d`):** the former path 9 (`numVoxel==0` fallback) was removed as unreachable dead code — see path 8's note. Path 2's boundary clamp is now a signed-index comparison (`zIdx`/`yIdx`/`xIdx` at `.cpp:100,107,114`) with the neighbor index computed directly from the clamped indices (`.cpp:120`); the separate `neighbor < 0` guard no longer exists because it is now provably unreachable by construction rather than checked at runtime. + | Path | Description | Exercised by | |------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------| -| 1 | Focal-valid gate (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter kernel | All 4 Class 1 fixtures; Class 4 sub-sections (i) and (iii) | -| 2 | Inner kernel cell in-bounds + feature-id match → accumulate miso + numVoxel++ | All Class 1 fixtures; Class 4 (i) and (iii) | -| 3 | Inner kernel cell in-bounds + feature-id mismatch → skip (no accumulate) | `Class 1 - Multi-Feature Multi-Voxel + Background` (cells 1, 2 see different-feature in-bounds neighbors) | -| 4 | Inner kernel cell out-of-bounds (boundary clamp `col+l > xPoints-1` etc.) → `continue` | `Class 1 - 1D x-axis Gradient` cells 0/4 (x-boundary); `Class 1 - 1D z-axis Gradient` planes 0/2 (z-boundary); `Class 1 - Uniform 2D` corner cells (xy-corner) | -| 5 | Focal-invalid (`featureIds == 0 || cellPhases == 0`) → KAM = 0 directly | `Class 1 - Multi-Feature Multi-Voxel + Background` cell 4 (explicit REQUIRE that KAM == 0 exactly); Class 4 sub-section (ii) (background-cell invariant) | -| 6 | `numVoxel == 0` fallback at line 131 → KAM = 0 | **Not exercised** — dead code in practice. The focal voxel always self-matches (j=k=l=0 case satisfies `featureIds[point] == featureIds[point]`), guaranteeing numVoxel ≥ 1 whenever path 1 is entered. Path 5 (focal-invalid) skips the kernel entirely, so it never reaches path 6. | +| 1 | Focal-valid gate (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter kernel | All 6 Class 1 fixtures; both Class 4 tests | +| 2 | Kernel cell out-of-bounds (signed boundary clamp on `zIdx`/`yIdx`/`xIdx`) → `continue` | `Class 1 - 1D x-axis Gradient` cells 0/4 (x-boundary); `Class 1 - 1D z-axis Gradient` planes 0/2 (z-boundary); `Class 1 - Uniform 2D` corners; both 3×3×3 fixtures (all faces) | +| 3 | **Per-grain** (`use_feature_ids=true`): in-bounds neighbor + `featureId` match → accumulate miso + numVoxel++ | All default-mode Class 1 fixtures; Class 4 Invariants (i)/(iii); Class 4 Mode-Equivalence (per-grain run) | +| 4 | **Per-grain**: in-bounds neighbor + `featureId` mismatch → skip (no accumulate) | `Class 1 - Multi-Feature Multi-Voxel + Background` (cells 1, 2 see different-feature in-bounds neighbors) | +| 5 | **Per-voxel** (`use_feature_ids=false`): in-bounds neighbor + `featureId>0` + phase match → accumulate | `Class 1 - Per-Voxel Mode` cells 1, 2 (cross-feature same-phase include); `Class 1 - Per-Voxel Two-Phase Gates` cells 0, 1; Class 4 Mode-Equivalence (per-voxel run) | +| 6 | **Per-voxel**: neighbor `featureId == 0` → skip | `Class 1 - Per-Voxel Mode` cells 3, 5 (x=4 background excluded); `Class 1 - Per-Voxel Two-Phase Gates` cell 3 (x=4 excluded) | +| 7 | **Per-voxel**: neighbor phase mismatch (`cellPhases[neighbor] != cellPhases[point]`) → skip | `Class 1 - Per-Voxel Two-Phase Gates` cell 2 (excludes phase-1 x=1/x=3), cells 1 & 3 (exclude phase-2 x=2) | +| 8 | Focal-invalid (`featureIds == 0 \|\| cellPhases == 0`, reached via `else` since commit `7f9cddc7d`) → KAM = 0 directly. The former path 9 (`numVoxel == 0` fallback) was removed by this commit: in both modes the focal voxel always self-contributes when the focal is valid (per-grain: `featureId==featureId`; per-voxel: focal-valid ⇒ `featureId>0` and `phase==phase`), guaranteeing numVoxel ≥ 1 whenever path 1 is entered, so the fallback was unreachable by construction. | `Class 1 - Multi-Feature` cell 4; `Class 1 - Per-Voxel Mode` cell 4; `Class 1 - Per-Voxel Two-Phase Gates` cell 4 (`featureId=0` yet phase>0 — proves focal gate unchanged); Class 4 Invariants (ii) | -5 of 6 paths exercised by the V&V suite; the 6th is unreachable by construction and is flagged in the algorithm review as removable dead code. +8 of 8 paths exercised by the V&V suite. The former path 9 (`numVoxel==0` fallback, previously "unreachable by construction") was removed from SIMPLNX by the review-driven cleanup commit `7f9cddc7d` (2026-07-16); it was purely dead code and its removal is behavior-preserving, confirmed by the unchanged 9-test suite passing on both in-core and out-of-core builds. ## Test inventory -| TEST_CASE | Category | Lines | ctest entry | -|----------------------------------------------------------------------------------------|----------|-------|----------------------------------------------------------------------------------------| -| `: SIMPL Backwards Compatibility` | Compat | ~40 | Yes (2 dynamic sections: 6.4 + 6.5) | -| `: Class 1 - Uniform 2D Single Feature` | Class 1 | ~25 | Yes | -| `: Class 1 - 1D x-axis Gradient` | Class 1 | ~35 | Yes | -| `: Class 1 - 1D z-axis Gradient (3D path)` | Class 1 | ~30 | Yes | -| `: Class 1 - Multi-Feature Multi-Voxel + Background` | Class 1 | ~60 | Yes | -| `: Class 4 - Invariants` (3 sub-sections) | Class 4 | ~55 | Yes (3 SECTIONs) | -| ~~`: ComputeKernelAvgMisorientationsFilter` (legacy exemplar test)~~ | RETIRED | ~50 | Retired 2026-06-03 (circular oracle from pre-EbsdLib-2.4.1 SIMPLNX output) | +| TEST_CASE | Category | Status | ctest entry | +|----------------------------------------------------------------------------------------|----------|--------|----------------------------------------------------------------------------------------| +| `: SIMPL Backwards Compatibility` | Compat | kept | Yes (2 dynamic sections: 6.4 + 6.5) | +| `: Class 1 - Uniform 2D Single Feature` | Class 1 | kept | Yes | +| `: Class 1 - 1D x-axis Gradient` | Class 1 | kept | Yes | +| `: Class 1 - 1D z-axis Gradient (3D path)` | Class 1 | kept | Yes | +| `: Class 1 - Multi-Feature Multi-Voxel + Background` | Class 1 | kept | Yes | +| `: Class 1 - Per-Voxel Mode (use_feature_ids = false)` | Class 1 | **new (#1613)** | Yes — per-voxel cross-feature include + `featureId=0` exclude; expected `{5.0, 20/3, 10.0, 10.0, 0, 0}` | +| `: Class 1 - Per-Voxel Mode Two-Phase Gates` | Class 1 | **new (#1613)** | Yes — per-voxel phase-mismatch exclude + `featureId=0` focal gate; expected `{5.0, 5.0, 0, 0, 0}` | +| `: Class 4 - Mode Equivalence on Single Feature` | Class 4 | **new (#1613)** | Yes — per-grain ≡ per-voxel bit-for-bit on single-feature single-phase 3×3×3 | +| `: Class 4 - Invariants` (3 sub-sections) | Class 4 | kept | Yes (3 SECTIONs) | +| ~~`: ComputeKernelAvgMisorientationsFilter` (legacy exemplar test)~~ | RETIRED | — | Retired 2026-06-03 (circular oracle from pre-EbsdLib-2.4.1 SIMPLNX output) | + +9 active TEST_CASEs / 9 ctest entries (confirmed via `ctest -N -R "ComputeKernelAvgMisorientations"`); 100% pass on both in-core and out-of-core builds (full regression sweep, 2026-07-15). ## Exemplar archive @@ -119,8 +155,9 @@ The shared archive remains referenced in `src/Plugins/OrientationAnalysis/test/C See `vv/deviations/ComputeKernelAvgMisorientationsFilter.md` for the canonical, ID-stable list: -- **`ComputeKernelAvgMisorientationsFilter-D1`** — EbsdLib 2.4.1 CubicOps precision improvement (non-deviation in the algorithmic sense; precision class). The pre-2.4.1 `acos(w near 1)` form produces a spurious ~0.03° angle on float32-sourced identical quaternions, which inflates every per-cell self-misorientation contribution to the KAM. The 2.4.1 `2*atan2(|v|, w)` form returns 0° as expected. +- **`ComputeKernelAvgMisorientationsFilter-D1`** — EbsdLib 2.4.1 CubicOps precision improvement (non-deviation in the algorithmic sense; precision class). The pre-2.4.1 `acos(w near 1)` form produces a spurious ~0.03° angle on float32-sourced identical quaternions, which inflates every per-cell self-misorientation contribution to the KAM. The 2.4.1 `2*atan2(|v|, w)` form returns 0° as expected. **Confirmed by runtime A/B this cycle:** on identical 12³ synthetic input (default per-grain path, kernel `{1,1,1}`), max \|Δ\|=0.0072°, mean \|Δ\|=0.00075°, 0/1728 cells > 0.01°, gating provably identical — the delta is purely `calculateMisorientation` precision (D1 + the `QuatF`→`QuatD` port delta). - **`ComputeKernelAvgMisorientationsFilter-D2`** — Legacy `FindKernelAvgMisorientations.cpp:264` uses `KernelSize.z + 1` as the upper bound of the x-direction inner loop (should be `KernelSize.x + 1`). SIMPLNX has the correct form. Dormant when `KernelSize.x == KernelSize.z`; fires for asymmetric kernels. Logged in `bug_triage.md`. +- **`ComputeKernelAvgMisorientationsFilter-D3`** — `use_feature_ids = false` (per-voxel KAM) is an **NX-only capability** added for issue #1613. DREAM3D 6.5.171 `FindKernelAvgMisorientations` has no equivalent (it is per-grain only), so there is nothing to A/B against; the mode is validated by the Class 1 per-voxel fixtures and the Class 4 mode-equivalence invariant. The default (`use_feature_ids = true`) is unchanged and remains legacy-comparable. ## Provenance diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md index 3874716015..3e34886968 100644 --- a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md @@ -6,7 +6,9 @@ Entries are referenced by stable ID (`ComputeKernelAvgMisorientationsFilter-D ## Comparison summary -The legacy A/B comparison was performed by **source inspection** rather than empirical run. Justification: SIMPLNX `ComputeKernelAvgMisorientations` is a clean Port of legacy `FindKernelAvgMisorientations::execute()` (same per-voxel outer triple loop; same per-kernel inner triple loop; same focal-validity gate; same same-feature gate inside the kernel; same per-voxel average with the focal voxel always included in the divisor). The port-time deltas are documented in the V&V report's Algorithm Relationship section. Two deviations were identified: a precision-class non-deviation (D1) traceable to the EbsdLib 2.4.1 release, and a legacy bug (D2) at the inner x-loop bound that was corrected at port time. +The legacy A/B comparison was performed by source inspection (2026-06-03) **and, this cycle, by an empirical runtime A/B on the default per-grain path** (2026-07-15, branch `topic/kam_ignore_feature_ids`). SIMPLNX `ComputeKernelAvgMisorientations` is a clean Port of legacy `FindKernelAvgMisorientations::execute()` (same per-voxel outer triple loop; same per-kernel inner triple loop; same focal-validity gate; on the default path the same same-feature gate inside the kernel — legacy line 292 ≡ SIMPLNX line 124 `true`-branch; same per-voxel average with the focal voxel always included in the divisor). The port-time deltas are documented in the V&V report's Algorithm Relationship section. Three deviations are recorded: a precision-class non-deviation (D1) traceable to the EbsdLib 2.4.1 release **and now empirically quantified** (see D1's runtime-A/B block); a legacy bug (D2) at the inner x-loop bound corrected at port time; and an NX-only capability (D3), the `use_feature_ids=false` per-voxel mode added for issue #1613, which has no legacy counterpart and is therefore validated by oracle only, not by comparison. + +**Runtime A/B setup (this cycle).** A synthetic legacy-format input (`.superpowers/sdd/task-6-ab/kam_ab_input.dream3d`, authored with the `compare-legacy-dream3d` writer helper) — a 12×12×12 image, 8 features (2×2×2 octant blocks), single cubic phase, per-cell orientations = per-feature base rotation ⊗ ≤3° intra-grain perturbation — was fed **unchanged** through both binaries: `PipelineRunner` (6.5.171 `FindKernelAvgMisorientations`) via `legacy_kam.json`, and `nxrunner` (`ComputeKernelAvgMisorientations`, `use_feature_ids=true`) via `nx_kam.d3dpipeline` (nxrunner imports the legacy v7 file directly). Kernel `{1,1,1}` (default; D2 dormant). The round-tripped `Quats` and `FeatureIds` were verified bit-identical in both outputs, so any KAM difference is algorithmic/numeric, not an input artifact. --- @@ -54,6 +56,23 @@ For the full root-cause walkthrough of the EbsdLib precision improvement, see th The data-fixture unit tests assert the analytical oracle directly (margin `1e-3`, no tolerance for the pre-fix artifact). With EbsdLib pinned `≥ 2.4.1` in `vcpkg.json` this is the correct, regression-sensitive choice: it holds in every supported build and would immediately flag any future regression of the EbsdLib precision fix, rather than silently absorbing it under a loose tolerance. +**Runtime A/B confirmation on general 3D orientations (V&V cycle, branch `topic/kam_ignore_feature_ids`, 2026-07-15):** the setup described in *Comparison summary* above (identical 12³ / 8-feature / single-cubic-phase input through both `PipelineRunner` 6.5.171 and `nxrunner`, default per-grain path, kernel `{1,1,1}`) produced the following per-cell `KernelAverageMisorientations` deltas over all 1728 cells: + +**Input recipe and seed (for reproducibility):** the input was generated by `.superpowers/sdd/task-6-ab/make_input.py` from a single seeded RNG, `np.random.default_rng(1613)` (seed = issue #1613). The 12×12×12 grid is partitioned into 8 features as 2×2×2 octant blocks (single cubic phase, `CrystalStructures = [999, 1]`); each feature is assigned a random-axis base rotation with angle drawn uniformly from 5–25°, and every cell within that feature then receives an independent small intra-grain perturbation (≤3°, about its own random axis) composed onto the feature's base rotation. This is not raw uniform-random unit quaternions across the volume — it is a per-grain-base-plus-scatter construction, chosen so that a spread of focal-cell self-misorientations exercises the D1 precision path. The script is the source of truth for the exact construction. + +| Metric | Value | +|---|---| +| legacy KAM range (min/mean/max) | 0.952 / 2.142 / 3.551° | +| nx KAM range (min/mean/max) | 0.952 / 2.141 / 3.549° | +| \|Δ\| min / mean / max | 2.4e-7 / 7.5e-4 / **7.2e-3°** | +| cells \|Δ\| > 0.001° | 461 / 1728 | +| cells \|Δ\| > 0.01° | **0** / 1728 | +| signed (legacy − nx): cells legacy>nx / legacy 0`, and shares the focal cell's phase — regardless of whether it belongs to the same feature. There is no way to produce this output with DREAM3D 6.5.171, which only ever computes the per-grain KAM (neighbor must share the focal cell's `featureId`). + +**Root cause:** **Algorithmic choice** — a deliberate feature addition (issue #1613), not a bug, precision effect, or library difference. SIMPLNX adds a second neighbor-inclusion mode; the legacy filter has only the per-grain mode. + +The two modes differ only in the neighbor gate at `Algorithms/ComputeKernelAvgMisorientations.cpp:124`: + +```cpp +const bool neighborContributes = useFeatureIds + ? (featureIds[point] == featureIds[neighborIdx]) // per-grain (legacy-equivalent) + : (featureIds[neighborIdx] > 0 && cellPhases[neighborIdx] == cellPhases[point]); // per-voxel (NX-only, #1613) +``` + +The focal-validity gate (`featureIds[point] > 0 && cellPhases[point] > 0`), the boundary clamps, the divisor semantics (focal self always included), and the background short-circuit are all identical between the two modes and unchanged from the legacy behavior. + +**Relationship to the default path:** `use_feature_ids = true` is the default and is behavior-identical to every prior SIMPLNX release and to DREAM3D 6.5.171 (up to the D1/D2 precision/bug notes). The per-voxel mode is strictly opt-in; enabling it cannot change the default output. On single-feature single-phase data the two modes are provably equivalent (see the Class 4 mode-equivalence invariant in the V&V report), because every neighbor that passes the per-grain gate also passes the per-voxel gate and vice-versa. + +**Affected users:** none in the migration sense — this is additive. Users who want per-voxel KAM (e.g. to visualize sub-grain orientation gradients without feature segmentation, or to include grain-boundary-adjacent lattice curvature) now have it in SIMPLNX with no DREAM3D 6.5.171 equivalent. Users reproducing legacy pipelines leave the parameter at its `true` default and see no change. + +**Validation:** Class 1 analytical fixtures (`Class 1 - Per-Voxel Mode`, expected `{5.0, 20/3, 10.0, 10.0, 0, 0}`; `Class 1 - Per-Voxel Mode Two-Phase Gates`, expected `{5.0, 5.0, 0, 0, 0}`) plus the Class 4 `Mode Equivalence on Single Feature` invariant. Because there is no legacy counterpart, this mode is **never** validated by an A/B numeric comparison — the oracle is the sole authority, per V&V policy. + +**Recommendation:** **Trust SIMPLNX.** This is a new, oracle-validated capability. No legacy-parity concern applies. + +--- + ## Non-deviations (algorithm characteristics common to both filters) The following behaviors are NOT deviations — SIMPLNX (post-EbsdLib 2.4.1) and DREAM3D 6.5.171 (with D2 dormant on symmetric kernels) agree on them where D1 precision noise is below the user's tolerance. Captured here so future engineers don't re-discover them and propose them as deviations. @@ -109,12 +160,12 @@ Both implementations have the focal cell as a same-feature neighbor of itself (t ### Background cell short-circuit to KAM = 0 -Both implementations branch on `featureIds[point] == 0 || cellPhases[point] == 0` at the *end* of the per-cell processing (legacy line 311, SIMPLNX line 136) and unconditionally set `KAM = 0` for these cells. The logic is logically AFTER the kernel loop but only fires when the focal validity check at the top failed (so the kernel loop didn't run). **Both filters share this behavior**. +Both implementations short-circuit background cells to `KAM = 0` at the *end* of the per-cell processing. Legacy explicitly checks `featureIds[point] == 0 || cellPhases[point] == 0` (line 311). **Since the review-driven cleanup (commit `7f9cddc7d`, 2026-07-16), SIMPLNX expresses the identical condition as an `else` branch** (`.cpp:144-146`) on the preceding `if(featureIds[point] > 0 && cellPhases[point] > 0)` gate (`.cpp:86`) rather than a second explicit `if` (previously at the pre-cleanup line 146) — logically identical by De Morgan's law for these `int32` arrays whose valid domain is non-negative (the only way to fail `> 0` is to equal `0`). For hypothetical out-of-domain negative ids the pre-cleanup code left the output cell holding uninitialized memory (the `DataStore` allocates without a fill value); the `else` now writes a deterministic `0.0f` there — a strict improvement over undefined behavior, not a preserved-behavior change. The logic is AFTER the kernel loop but only fires when the focal validity check at the top failed (so the kernel loop didn't run). **Both filters share this behavior**; the SIMPLNX code shape changed, the observable output did not (confirmed by the unchanged 9-test suite). -### `numVoxel == 0` fallback (dead code in practice) +### `numVoxel == 0` fallback (dead code — removed from SIMPLNX 2026-07-16) -Both implementations include an `if(numVoxel == 0) { KAM[point] = 0; }` guard immediately after the `KAM[point] = totalMiso / numVoxel` divide. In practice the focal voxel always self-matches (path 6 in the V&V report's code path coverage), so `numVoxel >= 1` whenever the focal validity check at the top of the cell processing was passed. The fallback is dead code. **Both filters share this dead code** — could be removed in both, but no functional issue. +Legacy `FindKernelAvgMisorientations` still includes an `if(numVoxel == 0) { KAM[point] = 0; }` guard immediately after the `KAM[point] = totalMiso / numVoxel` divide. Prior to the review-driven cleanup, SIMPLNX carried the identical dead-code guard (the former path 9 in the V&V report's code path coverage). In practice the focal voxel always self-matches (former path 6), so `numVoxel >= 1` whenever the focal-validity gate passed — the fallback was provably unreachable in both implementations. **Commit `7f9cddc7d` (2026-07-16) removed the SIMPLNX-side guard as pure dead code** (behavior-preserving — confirmed by the unchanged 9-test suite passing on both in-core and out-of-core builds); legacy retains its copy unchanged. This is now a code-shape difference only, not a behavioral one: both implementations compute the identical KAM for every reachable input. ### Multi-threading model -Both implementations parallelize over the outer cell loop. Legacy uses `tbb::parallel_for` over a single dimension after marshalling; SIMPLNX uses `ParallelData3DAlgorithm` with a 3D `Range3D`. Both make concurrent reads of the shared input `DataArray`s (FeatureIds, CellPhases, Quats, CrystalStructures). Per the SIMPLNX project policy (`CLAUDE.md`), DataArray subscript access is not formally thread-safe for concurrent reads, but in practice this works for read-only access on contiguous in-memory DataStores. The algorithm has been stable under parallel execution on shipping pipelines; no thread-safety issue surfaced during the V&V cycle's 6-test suite. Out-of-core (OOC) DataStore variants would need explicit testing, but this filter explicitly calls `parallelAlgorithm.requireArraysInMemory(algArrays)` at line 196, which disables parallelization when any input array is out-of-core (the algorithm then runs serially rather than concurrently accessing the not-thread-safe OOC stores). OOC inputs are still processed correctly — just single-threaded. +Both implementations parallelize over the outer cell loop. Legacy uses `tbb::parallel_for` over a single dimension after marshalling; SIMPLNX uses `ParallelData3DAlgorithm` with a 3D `Range3D`. Both make concurrent reads of the shared input `DataArray`s (FeatureIds, CellPhases, Quats, CrystalStructures). Per the SIMPLNX project policy (`CLAUDE.md`), DataArray subscript access is not formally thread-safe for concurrent reads, but in practice this works for read-only access on contiguous in-memory DataStores. The algorithm has been stable under parallel execution on shipping pipelines; no thread-safety issue surfaced during the V&V cycle's test suite. Out-of-core (OOC) DataStore variants would need explicit testing, but this filter explicitly calls `parallelAlgorithm.requireArraysInMemory(algArrays)` at line 205 (pre-cleanup: line 196), which disables parallelization when any input array is out-of-core (the algorithm then runs serially rather than concurrently accessing the not-thread-safe OOC stores). OOC inputs are still processed correctly — just single-threaded. **Since the review-driven cleanup (commit `7f9cddc7d`), `FindKernelAvgMisorientationsImpl` also builds its `LaueOps` list once in its constructor (a worker member, `.cpp:29,166`) rather than once per `convert()` call — the object is constructed once per `parallelAlgorithm.execute(...)` call and its `m_OrientationOps` member is read-only for the remainder of execution, then shared by const-reference across all parallel `convert()` invocations. This follows the same constructor-built/shared-across-parallel-ranges precedent as `ComputeFeatureFaceMisorientationPerTriangleImpl`, and `calculateMisorientation` remains a const, stateless call on the shared `LaueOps::Pointer` objects — no new thread-safety exposure. Confirmed by the unchanged 9-test suite passing on the out-of-core build (gate 2).** diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md index f6ef5c13d1..1f107a9de1 100644 --- a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md @@ -16,21 +16,27 @@ The test data is **inlined** in the test source — there is no separate tar.gz | | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D x-axis Gradient` | | | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D z-axis Gradient (3D path)` | | | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Multi-Feature Multi-Voxel + Background` | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Per-Voxel Mode (use_feature_ids = false)` *(new 2026-07-15, #1613)* | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Per-Voxel Mode Two-Phase Gates` *(new 2026-07-15, #1613)* | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Mode Equivalence on Single Feature` *(new 2026-07-15, #1613)* | | | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Invariants` (3 SECTIONs) | -| **Generated by** | Claude (Opus 4.7, Anthropic) under direction of Michael Jackson | -| **Generated on** | 2026-06-03 | +| **Generated by** | Claude (Anthropic) under direction of Michael Jackson | +| **Generated on** | 2026-06-03 (Class 1/4 base fixtures); 2026-07-15 (per-voxel + mode-equivalence fixtures added for issue #1613 on branch `topic/kam_ignore_feature_ids`) | ## How it was generated -The dataset is a hand-rolled in-memory `DataStructure` designed as a **Class 1 (Analytical) oracle** with a paired **Class 4 (Invariant)** check. It systematically covers the five algorithmic paths in `ComputeKernelAvgMisorientations::FindKernelAvgMisorientationsImpl::convert()`: +The dataset is a hand-rolled in-memory `DataStructure` designed as a **Class 1 (Analytical) oracle** with a paired **Class 4 (Invariant)** check. As of the 2026-07-15 reopen (issue #1613) it systematically covers the eight algorithmic paths in `ComputeKernelAvgMisorientations::FindKernelAvgMisorientationsImpl::convert()` — see the V&V report's Code path coverage table for the full enumeration and per-test mapping: 1. **Focal-valid gate** (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter the kernel. -2. **Inner kernel cell in-bounds + feature-id match** → accumulate misorientation + increment divisor. -3. **Inner kernel cell in-bounds + feature-id mismatch** → skip (no accumulate). -4. **Inner kernel cell out-of-bounds** (boundary clamp `col+l > xPoints-1` etc.) → `continue`. -5. **Focal-invalid** (`featureIds[point] == 0 || cellPhases[point] == 0`) → KAM = 0 directly. +2. **Kernel cell out-of-bounds** (signed boundary clamp on `zIdx`/`yIdx`/`xIdx`) → `continue`. +3. **Per-grain** (`use_feature_ids=true`) in-bounds + feature-id match → accumulate. +4. **Per-grain** in-bounds + feature-id mismatch → skip. +5. **Per-voxel** (`use_feature_ids=false`) in-bounds + `featureId>0` + phase match → accumulate. +6. **Per-voxel** neighbor `featureId==0` → skip. +7. **Per-voxel** neighbor phase mismatch → skip. +8. **Focal-invalid** (`featureIds[point] == 0 || cellPhases[point] == 0`, reached via `else` since commit `7f9cddc7d`) → KAM = 0 directly. -The 6th algorithmic path (`numVoxel == 0` fallback at line 131) is dead code in practice and is not exercised by design. +A 9th algorithmic path (`numVoxel == 0` fallback) existed at the time these fixtures were designed (2026-06-03/2026-07-15) and was dead code in practice (the focal voxel always self-contributes in both modes) — not exercised by design. The review-driven cleanup pass (commit `7f9cddc7d`, 2026-07-16) removed that fallback from SIMPLNX as unreachable dead code, so the enumeration above is now the complete, current path list (8 of 8, all exercised); no fixture changes were needed since no fixture ever targeted the removed path. ### Scaffold structure @@ -102,7 +108,36 @@ The `QuatFromPhi1Deg(phi1)` helper produces `{0, 0, sin(phi1/2 rad), cos(phi1/2 - cell 5 (F1, φ1=30°): kernel `{x=4 (F0 - SKIP), x=5 self}`. Same-feat misos `{|30-30|} = {0}`. sum=0, div=1 → KAM = 0.000°. - Tests: multi-voxel within-feature averaging (cells 0–3), multi-feature mismatch skip (cells 1, 2, 5 see different-feature in-bounds neighbors), background skip path (cell 4 — also asserted with `REQUIRE(kam[4] == 0.0f)` exact-zero check), isolated single-cell feature (cell 5). -#### Fixture 5 — `Class 4 - Invariants` (3 sub-sections) +#### Fixture 5 — `Class 1 - Per-Voxel Mode` (new 2026-07-15, issue #1613) + +- Same 6×1×1 layout as Fixture 4 (featureIds `[1,1,2,2,0,1]`, phases `[1,1,1,1,0,1]`, φ1 `[0,10,0,20,–,30]°`), but run with `use_feature_ids = false`. +- Kernel radius `{1,0,0}`. Per-voxel gate: neighbor contributes iff `featureId>0 && phase==focalPhase` (feature boundaries ignored; `featureId=0` and phase-mismatched cells excluded). +- Expected per-cell KAM: + - cell 0: `{self=0, x1(F1,P1)=|10−0|}` → 10/2 = **5.0** + - cell 1: `{x0=|0−10|, self=0, x2(F2,P1)=|0−10|}` → 20/3 ≈ **6.6667** — diagnostic cell (per-grain gives 5.0; per-voxel includes the cross-feature same-phase x2) + - cell 2: `{x1(F1,P1)=|10−0|, self=0, x3(F2,P1)=|20−0|}` → 30/3 = **10.0** + - cell 3: `{x2(F2,P1)=|0−20|, self=0}`; x4 excluded (`featureId=0`) → 20/2 = **10.0** + - cell 4: focal-invalid (`featureId=0, phase=0`) → **0.0** exactly + - cell 5: `{self=0}`; x4 excluded (`featureId=0`) → 0/1 = **0.0** +- Expected array `{5.0, 20/3, 10.0, 10.0, 0.0, 0.0}`. Exercises paths 5 (per-voxel include) and 6 (`featureId=0` exclude) plus the unchanged focal-invalid path 8. + +#### Fixture 6 — `Class 1 - Per-Voxel Mode Two-Phase Gates` (new 2026-07-15, issue #1613) + +- 5×1×1, every cell its own feature, two cubic phases (ensemble `CrystalStructures = [999, 1, 1]`). featureIds `[1,2,3,4,0]`, phases `[1,1,2,1,1]`, φ1 `[0,10,20,30,40]°`. Kernel `{1,0,0}`, `use_feature_ids = false`. +- Expected per-cell KAM: + - cell 0: `{self=0, x1(P1)=|0−10|}` → 10/2 = **5.0** (per-grain would give 0.0 — every feature is a single cell) + - cell 1: `{x0(P1)=|10−0|, self=0}`; x2 skipped (phase 2 ≠ 1) → 10/2 = **5.0** + - cell 2: `{self=0}`; x1, x3 skipped (phase 1 ≠ 2) → 0/1 = **0.0** + - cell 3: `{self=0}`; x2 skipped (phase), x4 skipped (`featureId=0`) → 0/1 = **0.0** + - cell 4: focal `featureId=0` (invalid) even though phase>0 → **0.0** exactly +- Expected array `{5.0, 5.0, 0.0, 0.0, 0.0}`. Exercises path 7 (phase-mismatch exclude), path 6 (`featureId=0` exclude), and confirms path 8's focal gate is unchanged in per-voxel mode (cell 4). + +#### Fixture 7 — `Class 4 - Mode Equivalence on Single Feature` (new 2026-07-15, issue #1613) + +- 3×3×3, single feature (id 1), single cubic phase, gradient φ1 = `2x + 3y + 4z` degrees (max 18° < 45° FZ bound). Full 3D kernel `{1,1,1}`. +- The fixture is built twice and run once with `use_feature_ids = true` and once with `false`. **Derived-truth invariant** (no hand-computed values): on single-feature single-phase data every neighbor passes both gates identically, so the two mode outputs must be bit-for-bit equal — `REQUIRE(kamPerGrain[i] == kamPerVoxel[i])` for all 27 cells, plus a guard that at least one cell is non-zero. Oracle-class-agnostic; holds regardless of the EbsdLib precision family. + +#### Fixture 8 — `Class 4 - Invariants` (3 sub-sections) Three SECTIONs each constructing a fresh fixture inline: @@ -140,3 +175,9 @@ N/A — Class 1 and Class 4 oracles only. No reference-library invocation, no pa **Yes.** The inlined analytical dataset replaces the retired exemplar-comparison test. The pre-V&V test consumed the shared `6_6_stats_test_v2.tar.gz` archive's `KernelAverageMisorientations` exemplar, which was a circular oracle: the exemplar values were generated from a SIMPLNX build using EbsdLib < 2.4.1, where every cell's KAM was inflated by the spurious `~0.001–0.03°` self-misorientation precision noise documented as D1. Comparing the post-EbsdLib-2.4.1 SIMPLNX output against those exemplars would systematically fail by ~0.01–0.05° per cell (varies by focal-cell quaternion), which is what surfaced during the EbsdLib precision-cycle V&V work. The inlined Class 1 + Class 4 fixtures use derived-truth oracles independent of any pre-existing SIMPLNX output, eliminating the circular oracle pattern. The shared archive `6_6_stats_test_v2.tar.gz` remains downloaded for the 3 other filter tests that still consume it (`AlignSectionsMutualInformation`, `ComputeShapes`, `ComputeSchmids`); only F#5's consumption line in `ComputeKernelAvgMisorientationsTest.cpp` was removed during this V&V cycle. + +## Provenance log + +- **2026-06-03** — Class 1 / Class 4 base fixtures (1–4, 8) authored; legacy exemplar test retired (circular-oracle fix). +- **2026-07-15** — Fixtures 5–7 (per-voxel mode, per-voxel two-phase gates, mode-equivalence invariant) added for issue #1613 on branch `topic/kam_ignore_feature_ids`; code paths re-enumerated 6→9 for the per-mode neighbor gate; runtime legacy A/B performed. +- **2026-07-16** — Review-driven cleanup pass, commits `7f9cddc7d` (REV) and the paired DOC commit on branch `topic/kam_ignore_feature_ids`: `getDataRefAs` for the parameter-validated `ImageGeom` (2 sites); kernel boundary clamps made signed with the neighbor index computed directly from the clamped `zIdx`/`yIdx`/`xIdx` (removing the vacuous unsigned checks and the separate `neighbor<0` guard); `LaueOps` list hoisted to a worker member built once per run in the constructor (shared read-only across parallel ranges, matching the `ComputeFeatureFaceMisorientationPerTriangleImpl` precedent); cancellation check moved from per-plane to per-row; progress increment floored at 1; the dead `numVoxel==0` fallback (former path 9) removed, folding the invalid-cell reset into an `else` branch. No fixture changes were required — all 9 existing TEST_CASEs pass unchanged on both in-core and out-of-core builds, confirming the cleanup is behavior-preserving. Code path enumeration updated from 9 (8 exercised) to 8 (8 exercised).