From 9eb0d2a8b467a2b5a2e14ac60e8eb6e154004ceb Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 2 Jun 2026 13:50:11 -0400 Subject: [PATCH] VV: Compute Feature Reference Misorientations fully V&V'ed --- ...teFeatureReferenceMisorientationsFilter.md | 19 +- ...ComputeFeatureReferenceMisorientations.cpp | 36 +- .../OrientationAnalysis/test/CMakeLists.txt | 6 +- ...uteFeatureReferenceMisorientationsTest.cpp | 816 ++++++++++++++---- ...teFeatureReferenceMisorientationsFilter.md | 138 +++ ...teFeatureReferenceMisorientationsFilter.md | 70 ++ ...teFeatureReferenceMisorientationsFilter.md | 117 +++ 7 files changed, 1033 insertions(+), 169 deletions(-) create mode 100644 src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceMisorientationsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureReferenceMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureReferenceMisorientationsFilter.md index 1d9b11307f..4332b76d13 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureReferenceMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureReferenceMisorientationsFilter.md @@ -22,7 +22,15 @@ not have rotated and thus serve as a better *reference orientation*. The *Reference Orientation* parameter provides the following choices: - **Average Feature Orientation [0]**: Uses the average orientation of the **Feature** as the reference orientation for misorientation calculations. -- **Orientation Farthest from Feature Boundary [1]**: Uses the orientation of the **Cell** that is furthest from the boundary of the **Feature** (nearest to its Euclidean center) as the reference orientation. +- **Orientation Farthest from Feature Boundary [1]**: Uses the orientation of the **Cell** that is furthest from the boundary of the **Feature** (nearest to its Euclidean center) as the reference orientation. Requires a `Boundary Euclidean Distances` array as input; that array is typically produced by the [Compute Euclidean Distance Map](../SimplnxCore/ComputeEuclideanDistMapFilter.md) filter upstream of this one. + +### Output Units + +The misorientation values in both output arrays (`Cell Reference Misorientations` and `Feature Average Misorientations`) are expressed in **degrees**, not radians. + +### Mode 1 — Raster-order tie-break for the "farthest from boundary" voxel + +When two or more voxels within a single feature share the same maximum `Boundary Euclidean Distances` value, the algorithm selects the voxel with the **latest linear (raster) index** as the feature's reference. This matches the legacy DREAM3D 6.5.171 behavior. In practice, ties are rare on real EBSD data and the choice between tied voxels has no qualitative impact on the resulting misorientation field; for synthetic inputs that deliberately tie distances, the reader should be aware that re-ordering the voxel layout would change which voxel is selected as the reference. ## IPF Colors <001> Direction @@ -49,7 +57,14 @@ feature boundary, and use that voxel's orientation as the **reference orientatio ## Example Pipelines -+ (05) SmallIN100 Crystallographic Statistics ++ (04) Small IN100 Crystallographic Statistics + +## Related Filters + +- [Compute Feature Reference C-Axis Misorientations](ComputeFeatureReferenceCAxisMisorientationsFilter.md) — the C-axis variant of this filter, used for hexagonal-phase reconstructions. +- [Compute Kernel Average Misorientations](ComputeKernelAvgMisorientationsFilter.md) — computes a per-voxel kernel average misorientation; complementary to this filter for grain-boundary characterization. +- [Compute Feature Neighbor Misorientations](ComputeFeatureNeighborMisorientationsFilter.md) — computes pairwise feature-to-neighbor misorientations. +- [Compute Euclidean Distance Map](../SimplnxCore/ComputeEuclideanDistMapFilter.md) — typical upstream filter that produces the `Boundary Euclidean Distances` array required by Mode 1. ## License & Copyright diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceMisorientations.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceMisorientations.cpp index abaa13f08f..f67d45b726 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceMisorientations.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceMisorientations.cpp @@ -33,6 +33,10 @@ const std::atomic_bool& ComputeFeatureReferenceMisorientations::getCancel() // ----------------------------------------------------------------------------- Result<> ComputeFeatureReferenceMisorientations::operator()() { + // The ImageGeom owning this filter's cell data lives two parents above the Cell Phases array + // (geometry -> CellData attribute matrix -> Phases array). This derivation matches the standard + // ImageGeom layout produced by the SIMPLNX data structure; if the user has restructured the data + // tree, this preflight-validated path may not hold. DataPath imageGeomPath = m_InputValues->CellPhasesArrayPath.getParent().getParent(); const ImageGeom& imageGeom = m_DataStructure.getDataRefAs(imageGeomPath); @@ -59,11 +63,13 @@ Result<> ComputeFeatureReferenceMisorientations::operator()() return validateNumFeatResult; } - std::vector m_OrientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); + std::vector orientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); const size_t totalVoxels = featureIds.getNumberOfTuples(); - // Get the total features from the appropriate source.. + // Get the total features from the appropriate source.. Mode 0 prefers the avgQuats array's tuple + // count; Mode 1 falls back to the feature attribute matrix's shape. Either resolves the same total + // for any consistent input data; the dual-source check tolerates either parameter set being unset. size_t totalFeatures = 0; if(featureAttrMatPtr != nullptr) { @@ -78,14 +84,14 @@ Result<> ComputeFeatureReferenceMisorientations::operator()() return MakeErrorResult(-34900, "Total features was zero. The filter cannot proceed. Check either the feature attribute matrix or the average quaternions for proper size"); } - // Create local storage for the centers and center distances - std::vector m_Centers(totalFeatures, 0); - std::vector m_CenterDistances(totalFeatures, 0.0f); + // Create local storage for the centers and center distances (sized to feature count, not voxel count). + std::vector centers(totalFeatures, 0); + std::vector centerDistances(totalFeatures, 0.0f); // If the user selected "Misorientation from Feature Centers" if(m_InputValues->ReferenceOrientation == 1) { - const auto& m_GBEuclideanDistances = m_DataStructure.getDataRefAs(m_InputValues->GBEuclideanDistancesArrayPath); + const auto& gbEuclideanDistances = m_DataStructure.getDataRefAs(m_InputValues->GBEuclideanDistancesArrayPath); for(size_t voxelIdx = 0; voxelIdx < totalVoxels; voxelIdx++) { if(m_ShouldCancel) @@ -94,11 +100,15 @@ Result<> ComputeFeatureReferenceMisorientations::operator()() } int32_t featureId = featureIds[voxelIdx]; - float32 distance = m_GBEuclideanDistances[voxelIdx]; - if(distance >= m_CenterDistances[featureId]) + float32 distance = gbEuclideanDistances[voxelIdx]; + // Tie-break: '>=' means later voxels with the same distance overwrite earlier ones. The + // selection is therefore raster-order dependent — different DataStructure layouts that + // expose the same logical voxels in a different iteration order would yield different + // centers[]. This matches the legacy DREAM3D 6.5.171 behavior intentionally. + if(distance >= centerDistances[featureId]) { - m_CenterDistances[featureId] = distance; // Save the GB Distance value - m_Centers[featureId] = voxelIdx; // Save the voxel index for that value + centerDistances[featureId] = distance; // Save the GB Distance value + centers[featureId] = voxelIdx; // Save the voxel index for that value } } @@ -106,7 +116,7 @@ Result<> ComputeFeatureReferenceMisorientations::operator()() for(size_t i = 1; i < totalFeatures; i++) { - usize voxelIdx = m_Centers[i]; + usize voxelIdx = centers[i]; auto cellCenter = imageGeom.getCoordsf(voxelIdx); euclideanCellCenters->setTuple(i, cellCenter.data()); } @@ -136,12 +146,12 @@ Result<> ComputeFeatureReferenceMisorientations::operator()() else if(m_InputValues->ReferenceOrientation == 1) // Use the voxel's orientation that is the farthest from the grain boundary { auto featureId = static_cast(featureIds[voxelIdx]); - size_t centerVoxelIdx = m_Centers[featureId]; + size_t centerVoxelIdx = centers[featureId]; q2 = ebsdlib::QuatD(quats[centerVoxelIdx * 4 + 0], quats[centerVoxelIdx * 4 + 1], quats[centerVoxelIdx * 4 + 2], quats[centerVoxelIdx * 4 + 3]); } uint32 laueClass1 = crystalStructures[cellPhases[voxelIdx]]; - ebsdlib::AxisAngleDType axisAngle = m_OrientationOps[laueClass1]->calculateMisorientation(q1, q2); + ebsdlib::AxisAngleDType axisAngle = orientationOps[laueClass1]->calculateMisorientation(q1, q2); // Extract the misorientation, convert it to degrees, and store if for this voxel featureReferenceMisorientations[voxelIdx] = static_cast(Constants::k_RadToDegD * axisAngle[3]); // convert to degrees diff --git a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt index ec156df973..bf2ded776a 100644 --- a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt +++ b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt @@ -157,7 +157,11 @@ if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME so3_cubic_high_ipf_001.tar.gz SHA512 dfe4598cd4406e8b83f244302dc4fe0d4367527835c5ddd6567fe8d8ab3484d5b10ba24a8bb31db269256ec0b5272daa4340eedb5a8b397755541b32dd616b85) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME write_stats_gen_odf_angle_file.tar.gz SHA512 be3f663aae1f78e5b789200421534ed9fe293187ec3514796ac8177128b34ded18bb9a98b8e838bb283f9818ac30dc4b19ec379bdd581b1a98eb36d967cdd319) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME 6_5_MergeTwins.tar.gz SHA512 756da6b9a2fdc6c7f1cf611243b889b8da0bdc172c1cd184f81672c3cdf651f1f450aecff2e2e0c9b1fa367735ca1df26436d88fa342cea1825b4e5665aa7dfd) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_feature_reference_misorientation.tar.gz SHA512 6ea9c04ca5b0c0439573b5a14bda63592181c6badb4dd325b542fb97ff2a5d492e83d2bac1bf5999612cbdb7697ec48e321549427470f1f23ccd37921c6a95f1) + # `compute_feature_reference_misorientation.tar.gz` retired 2026-06-01 (V&V cycle for + # ComputeFeatureReferenceMisorientationsFilter). The exemplar arrays in that archive were a + # circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output). Replaced by inline + # Class 1 + Class 4 toy fixtures in ComputeFeatureReferenceMisorientationsTest.cpp. See + # src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md. endif() diff --git a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp index 6b5bcbd9e5..82152faf99 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp @@ -2,14 +2,19 @@ #include "OrientationAnalysis/Filters/ComputeFeatureReferenceMisorientationsFilter.hpp" #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" +#include "simplnx/Common/Numbers.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include #include #include @@ -18,184 +23,227 @@ using namespace nx::core; using namespace nx::core::Constants; using namespace nx::core::UnitTest; -namespace +// ============================================================================= +// V&V Class 1 (Analytical) + Class 4 (Invariant) oracle support — added 2026-06-01. +// +// These fixtures replace the regression-against-archive pattern used by the two pre-existing +// exemplar tests. The inputs are built inline as tiny ImageGeoms with hand-derived expected +// outputs computable in closed form from pure phi1 rotations (Bunge ZXZ Euler `(phi1, 0, 0)`). +// Class 4 invariants are asserted alongside the Class 1 values. +// +// Reference: src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md +// ============================================================================= + +namespace ToyFixtures { -const std::string k_GBEuclideanDistancesArrayName("GBManhattanDistances"); - -const std::string k_CellAvgQuatsMisorientationArrayName("AvgQuats Misorientation"); -const std::string k_ComputedCellAvgQuatsMisorientationArrayName("AvgQuats Misorientation Computed"); - -const std::string k_FeatureAverageMisorientationArrayName("Average Quats Misorientation"); -const std::string k_ComputedFeatureAverageMisorientationArrayName("Average Quats Misorientation Computed"); - -const std::string k_ComputedCellEuclideanDistancesArrayName("Euclidean Misorientation Computed"); -const std::string k_CellEuclideanDistancesArrayName("Euclidean Misorientation"); - -const std::string k_ComputedFeatureEuclideanCentersArrayName("Average Euclidean Misorientation Computed"); -const std::string k_FeatureEuclideanCentersArrayName("Average Euclidean Misorientation Computed"); - -const std::string k_ComputedEuclideanCentersArrayName("Euclidean Centers Computed"); -const std::string k_EuclideanCentersArrayName("Euclidean Centers Computed"); - -} // namespace - -/** - * @brief This version of the test uses the Average Orientation for each grain as the `Reference Orientation` to use - */ -TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter_AverageMisorientation", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +// Test-side default paths (kept consistent with the existing `Small_IN100`-style fixtures). +const std::string k_GeomName = "DataContainer"; +const DataPath k_ImageGeomPath = DataPath({k_GeomName}); +const DataPath k_CellDataPath = k_ImageGeomPath.createChildPath(k_CellData); +const DataPath k_CellFeatureDataPath = k_ImageGeomPath.createChildPath(k_CellFeatureData); +const DataPath k_CellEnsembleDataPath = k_ImageGeomPath.createChildPath(k_CellEnsembleData); + +const std::string k_FeatureIdsName = "FeatureIds"; +const std::string k_PhasesName = "Phases"; +const std::string k_QuatsName = "Quats"; +const std::string k_AvgQuatsName = "AvgQuats"; +const std::string k_GBEuclideanName = "GBEuclideanDistances"; +const std::string k_CrystalStructuresName = "CrystalStructures"; +const std::string k_CellMisorientationsOutName = "Cell Reference Misorientations"; +const std::string k_FeatureAvgMisorientationsOutName = "Feature Avg Misorientations"; +const std::string k_FeatureEuclideanCentersOutName = "Feature Euclidean Centers"; + +// Build a quaternion representing a pure phi1 rotation about z (Bunge ZXZ Euler `(phi1, 0, 0)`). +// Returned as a 4-component vector in (x, y, z, w) layout matching the simplnx convention. +inline std::array QuatFromPhi1Deg(float32 phi1Deg) { - UnitTest::LoadPlugins(); - - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "compute_feature_reference_misorientation.tar.gz", "compute_feature_reference_misorientation"); - - // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/compute_feature_reference_misorientation/compute_feature_reference_misorientation.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); - DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); - DataPath cellDataPath = smallIn100Group.createChildPath(nx::core::Constants::k_CellData); - - DataPath cellFeatureDataPath({k_DataContainer, k_CellFeatureData}); - DataPath avgQuatsDataPath = cellFeatureDataPath.createChildPath(k_AvgQuats); - DataPath featurePhasesDataPath = cellFeatureDataPath.createChildPath(k_Phases); - - // Instantiate the filter and an Arguments Object - { - ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_ReferenceOrientation_Key, std::make_any(0)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_FeatureIdsArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_QuatsArrayPath)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_AvgQuatsArrayPath_Key, std::make_any(avgQuatsDataPath)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - - // output Cell data - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellMisorientationsArrayName_Key, - std::make_any(k_ComputedCellAvgQuatsMisorientationArrayName)); - // output feature data - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_FeatureAvgMisorientationsArrayName_Key, - std::make_any(k_ComputedFeatureAverageMisorientationArrayName)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } - -#ifdef SIMPLNX_WRITE_TEST_OUTPUT - WriteTestDataStructure(dataStructure, fs::path(fmt::format("{}/find_feature_reference_misorientations_0.dream3d", unit_test::k_BinaryTestOutputDir))); -#endif + const float32 halfAngleRad = (phi1Deg * 0.5f) * nx::core::numbers::pi_v / 180.0f; + return {0.0f, 0.0f, std::sin(halfAngleRad), std::cos(halfAngleRad)}; +} - // Compare the Output Cell Data +// Build a minimal DataStructure scaffold with ImageGeom + Cell Data AM + Cell Feature Data AM +// + Cell Ensemble AM and the input arrays this filter needs. Caller is responsible for populating +// the array values. Crystal structure ensemble is [UnknownCrystalStructure=999, Cubic_High=1]. +struct ToyData +{ + DataStructure ds; + ImageGeom* imageGeom = nullptr; + AttributeMatrix* cellDataAM = nullptr; + AttributeMatrix* cellFeatureDataAM = nullptr; + AttributeMatrix* cellEnsembleAM = nullptr; + Int32Array* featureIds = nullptr; + Int32Array* cellPhases = nullptr; + Float32Array* quats = nullptr; + Float32Array* avgQuats = nullptr; // sized for numFeatures; populated only for Mode 0 + Float32Array* gbEuclideanDistances = nullptr; // only populated for Mode 1 + UInt32Array* crystalStructures = nullptr; +}; + +inline ToyData CreateScaffold(const usize dimX, const usize dimY, const usize dimZ, const usize numFeatures) +{ + ToyData td; + td.imageGeom = ImageGeom::Create(td.ds, k_GeomName); + td.imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + td.imageGeom->setOrigin({0.0f, 0.0f, 0.0f}); + td.imageGeom->setDimensions({dimX, dimY, dimZ}); + const ShapeType cellTupleShape{dimZ, dimY, dimX}; + const usize totalVoxels = dimX * dimY * dimZ; + + td.cellDataAM = AttributeMatrix::Create(td.ds, k_CellData, cellTupleShape, td.imageGeom->getId()); + td.imageGeom->setCellData(*td.cellDataAM); + + td.cellFeatureDataAM = AttributeMatrix::Create(td.ds, k_CellFeatureData, ShapeType{numFeatures}, td.imageGeom->getId()); + td.cellEnsembleAM = AttributeMatrix::Create(td.ds, k_CellEnsembleData, ShapeType{2}, td.imageGeom->getId()); + + td.featureIds = CreateTestDataArray(td.ds, k_FeatureIdsName, cellTupleShape, {1}, td.cellDataAM->getId()); + td.cellPhases = CreateTestDataArray(td.ds, k_PhasesName, cellTupleShape, {1}, td.cellDataAM->getId()); + td.quats = CreateTestDataArray(td.ds, k_QuatsName, cellTupleShape, {4}, td.cellDataAM->getId()); + td.gbEuclideanDistances = CreateTestDataArray(td.ds, k_GBEuclideanName, cellTupleShape, {1}, td.cellDataAM->getId()); + td.avgQuats = CreateTestDataArray(td.ds, k_AvgQuatsName, {numFeatures}, {4}, td.cellFeatureDataAM->getId()); + + td.crystalStructures = CreateTestDataArray(td.ds, k_CrystalStructuresName, {2}, {1}, td.cellEnsembleAM->getId()); + (*td.crystalStructures)[0] = 999u; // UnknownCrystalStructure sentinel + (*td.crystalStructures)[1] = 1u; // Cubic_High (EbsdLib LaueOps index 1) + + // Initialize default zero values + for(usize i = 0; i < totalVoxels; ++i) { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellData, k_ComputedCellAvgQuatsMisorientationArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellData, k_CellAvgQuatsMisorientationArrayName}); - - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + (*td.featureIds)[i] = 0; + (*td.cellPhases)[i] = 0; + (*td.gbEuclideanDistances)[i] = 0.0f; + (*td.quats)[i * 4 + 0] = 0.0f; + (*td.quats)[i * 4 + 1] = 0.0f; + (*td.quats)[i * 4 + 2] = 0.0f; + (*td.quats)[i * 4 + 3] = 1.0f; // identity by default } - - // Compare the Output Feature Data + for(usize f = 0; f < numFeatures; ++f) { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellFeatureData, k_ComputedFeatureAverageMisorientationArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellFeatureData, k_FeatureAverageMisorientationArrayName}); - - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + (*td.avgQuats)[f * 4 + 0] = 0.0f; + (*td.avgQuats)[f * 4 + 1] = 0.0f; + (*td.avgQuats)[f * 4 + 2] = 0.0f; + (*td.avgQuats)[f * 4 + 3] = 1.0f; } - UnitTest::CheckArraysInheritTupleDims(dataStructure); + return td; } -/** - * @brief This version of the test uses the Average Orientation for each grain as the `Reference Orientation` to use - */ -TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter_EuclideanDistance", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +// Build standard Arguments for the filter from a ToyData scaffold. +inline Arguments BuildArgs(int32 referenceOrientation) { - UnitTest::LoadPlugins(); - - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "compute_feature_reference_misorientation.tar.gz", "compute_feature_reference_misorientation"); - - // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/compute_feature_reference_misorientation/compute_feature_reference_misorientation.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); - DataPath dataContainerPath({nx::core::Constants::k_DataContainer}); - DataPath cellDataPath = dataContainerPath.createChildPath(nx::core::Constants::k_CellData); - - DataPath cellFeatureDataPath({k_DataContainer, k_CellFeatureData}); - DataPath avgQuatsDataPath = cellFeatureDataPath.createChildPath(k_AvgQuats); - DataPath cellGbEuclideanPath = cellDataPath.createChildPath(k_GBEuclideanDistancesArrayName); + Arguments args; + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_ReferenceOrientation_Key, std::make_any(referenceOrientation)); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_FeatureIdsName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_PhasesName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_QuatsName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CellEnsembleDataPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_AvgQuatsArrayPath_Key, std::make_any(k_CellFeatureDataPath.createChildPath(k_AvgQuatsName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_GBEuclideanDistancesArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_GBEuclideanName))); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellFeatureAttributeMatrixPath_Key, std::make_any(k_CellFeatureDataPath)); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellMisorientationsArrayName_Key, std::make_any(k_CellMisorientationsOutName)); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_FeatureAvgMisorientationsArrayName_Key, std::make_any(k_FeatureAvgMisorientationsOutName)); + args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_FeatureEuclideanCenterArrayName_Key, std::make_any(k_FeatureEuclideanCentersOutName)); + return args; +} - // Instantiate the filter and an Arguments Object +// Helper: assert per-voxel FRM matches expected value within tolerance. +inline void RequireFRMClose(const DataStructure& ds, usize voxelIdx, float32 expectedDeg, float32 tolDeg = 1e-3f) +{ + const auto& frm = ds.getDataRefAs(k_CellDataPath.createChildPath(k_CellMisorientationsOutName)); + const float32 actual = frm[voxelIdx]; + const float32 diff = std::abs(actual - expectedDeg); + if(diff > tolDeg) { - ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_ReferenceOrientation_Key, std::make_any(1)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_FeatureIdsArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_QuatsArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_GBEuclideanDistancesArrayPath_Key, std::make_any(cellGbEuclideanPath)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellFeatureAttributeMatrixPath_Key, std::make_any(cellFeatureDataPath)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - - // output cell data - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_CellMisorientationsArrayName_Key, std::make_any(k_ComputedCellEuclideanDistancesArrayName)); - - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_FeatureAvgMisorientationsArrayName_Key, - std::make_any(k_ComputedFeatureEuclideanCentersArrayName)); - args.insertOrAssign(ComputeFeatureReferenceMisorientationsFilter::k_FeatureEuclideanCenterArrayName_Key, std::make_any(k_ComputedEuclideanCentersArrayName)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + CAPTURE(voxelIdx); + CAPTURE(expectedDeg); + CAPTURE(actual); + CAPTURE(diff); + REQUIRE(diff <= tolDeg); } +} - // #ifdef SIMPLNX_WRITE_TEST_OUTPUT - WriteTestDataStructure(dataStructure, fs::path(fmt::format("{}/find_feature_reference_misorientations_1.dream3d", unit_test::k_BinaryTestOutputDir))); - // #endif - - // Compare the Output Cell Data +inline void RequireAvgClose(const DataStructure& ds, usize featureIdx, float32 expectedDeg, float32 tolDeg = 1e-3f, bool isMode1 = false) +{ + const std::string& avgName = k_FeatureAvgMisorientationsOutName; + const auto avgPath = k_CellFeatureDataPath.createChildPath(avgName); + const auto& avg = ds.getDataRefAs(avgPath); + const float32 actual = avg[featureIdx]; + const float32 diff = std::abs(actual - expectedDeg); + if(diff > tolDeg) { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellData, k_ComputedCellEuclideanDistancesArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellData, k_CellEuclideanDistancesArrayName}); - - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + CAPTURE(featureIdx); + CAPTURE(expectedDeg); + CAPTURE(actual); + CAPTURE(diff); + REQUIRE(diff <= tolDeg); } +} - // Compare the Output Feature Data +// Class 4 invariant predicates — assert every invariant on the filter's output. +inline void AssertClass4Invariants(const DataStructure& ds, bool isMode1) +{ + const auto& featureIds = ds.getDataRefAs(k_CellDataPath.createChildPath(k_FeatureIdsName)); + const auto& cellPhases = ds.getDataRefAs(k_CellDataPath.createChildPath(k_PhasesName)); + const auto& frm = ds.getDataRefAs(k_CellDataPath.createChildPath(k_CellMisorientationsOutName)); + const auto& avg = ds.getDataRefAs(k_CellFeatureDataPath.createChildPath(k_FeatureAvgMisorientationsOutName)); + + const usize totalVoxels = featureIds.getNumberOfTuples(); + const usize numFeatures = avg.getNumberOfTuples(); + + // Invariant 1: FRM[i] >= 0 for all voxels + // Invariant 2: FRM[i] <= 62.8 deg (cubic max symmetry-reduced misorientation) + // Invariant 3: FRM[i] == 0 when featureIds[i] == 0 OR cellPhases[i] == 0 (skip path) + for(usize i = 0; i < totalVoxels; ++i) { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellFeatureData, k_ComputedFeatureEuclideanCentersArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellFeatureData, k_FeatureEuclideanCentersArrayName}); - - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + REQUIRE(frm[i] >= 0.0f); + REQUIRE(frm[i] <= 62.8f); + if(featureIds[i] == 0 || cellPhases[i] == 0) + { + REQUIRE(frm[i] == Approx(0.0f).margin(1e-6f)); + } } - // Compare the Output Feature Data - { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellFeatureData, k_ComputedEuclideanCentersArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellFeatureData, k_EuclideanCentersArrayName}); + // Invariant 4: avg[0] == 0 (background feature) + REQUIRE(avg[0] == Approx(0.0f).margin(1e-6f)); - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + // Invariant 5: avg[fid] = sum(FRM[v in feature fid, phase>0]) / count(v in feature fid, phase>0) + // avg[fid] = 0 when count == 0 + for(usize fid = 1; fid < numFeatures; ++fid) + { + float64 sum = 0.0; + usize count = 0; + for(usize i = 0; i < totalVoxels; ++i) + { + if(static_cast(featureIds[i]) == fid && cellPhases[i] > 0) + { + sum += static_cast(frm[i]); + count++; + } + } + const float32 expectedAvg = (count == 0) ? 0.0f : static_cast(sum / static_cast(count)); + const float32 diff = std::abs(avg[fid] - expectedAvg); + if(diff > 1e-4f) + { + CAPTURE(fid); + CAPTURE(expectedAvg); + CAPTURE(avg[fid]); + CAPTURE(diff); + REQUIRE(diff <= 1e-4f); + } } - - UnitTest::CheckArraysInheritTupleDims(dataStructure); } +} // namespace ToyFixtures + +// Retired 2026-06-01 (V&V cycle): the legacy anonymous namespace of array name constants and +// the two TEST_CASEs `_AverageMisorientation` and `_EuclideanDistance` that consumed the +// `compute_feature_reference_misorientation.tar.gz` Small-IN100 exemplar archive were removed. +// The exemplar arrays in the archive were generated from pre-EbsdLib-2.4.1 SIMPLNX output; +// the EbsdLib 2.4.1 CubicOps precision fix shifted the per-feature averages by 2x to 10x the +// 1e-4 epsilon used in the regression check, surfacing the underlying circular-oracle pattern. +// The Class 1 + Class 4 toy fixtures below replace this regression-against-archive coverage +// with hand-derived analytical assertions. See +// `vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md` for the retirement details. + TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: SIMPL Backwards Compatibility", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter][BackwardsCompatibility]") { @@ -243,3 +291,465 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: SI } } } + +// ============================================================================= +// V&V Class 1 (Analytical) + Class 4 (Invariant) toy fixtures — added 2026-06-01. +// ============================================================================= + +// Fixture A: Mode 0, single 2x2x2 grain, all identity quats. Expected: FRM = 0, avg = 0. +// Covers code paths: 1 (Mode 0), 4 (compute branch), 8 (avg finalize, non-zero count). +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 SingleGrainIdentity", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 2, 2, 2); + + // 8 voxels, all featureId=1, all phase=1, all quats = identity (already initialized). + for(usize i = 0; i < 8; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + } + // AvgQuats[1] = identity (already initialized). + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(0); + 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); + + for(usize i = 0; i < 8; ++i) + { + ToyFixtures::RequireFRMClose(td.ds, i, 0.0f); + } + ToyFixtures::RequireAvgClose(td.ds, 1, 0.0f); + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); +} + +// Fixture B: Mode 0, single 2x2x2 grain, all quats identical (5 deg about z), AvgQuats[1] = identity. +// Expected: FRM = 5 deg for all voxels (cubic 4-fold about z does not reduce 5 deg), avg = 5 deg. +// Covers paths: 1, 4, 8 (verifies magnitude with known non-zero misorientation). +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 KnownAngle5deg", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 2, 2, 2); + + const auto qVoxel = ToyFixtures::QuatFromPhi1Deg(5.0f); + for(usize i = 0; i < 8; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + (*td.quats)[i * 4 + 0] = qVoxel[0]; + (*td.quats)[i * 4 + 1] = qVoxel[1]; + (*td.quats)[i * 4 + 2] = qVoxel[2]; + (*td.quats)[i * 4 + 3] = qVoxel[3]; + } + // AvgQuats[1] = identity (already initialized). + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(0); + 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); + + for(usize i = 0; i < 8; ++i) + { + ToyFixtures::RequireFRMClose(td.ds, i, 5.0f); + } + ToyFixtures::RequireAvgClose(td.ds, 1, 5.0f); + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); +} + +// Fixture C: Mode 0, 4x3x1 image with 5 features. Tests: +// - Background voxel (featureId == 0) -> FRM = 0 (skip path 5) +// - Un-phased voxel (phase == 0) within a valid feature -> FRM = 0 (skip path 5) +// - Feature with non-zero misorientation -> path 4 + 8 +// - Feature with all un-phased voxels (count == 0) -> avg = 0 (path 7) +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 MultiGrain EdgeCases", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(4, 3, 1, 5); + + const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + const auto q10 = ToyFixtures::QuatFromPhi1Deg(10.0f); + auto setQuat = [&](usize voxelIdx, const std::array& q) { + (*td.quats)[voxelIdx * 4 + 0] = q[0]; + (*td.quats)[voxelIdx * 4 + 1] = q[1]; + (*td.quats)[voxelIdx * 4 + 2] = q[2]; + (*td.quats)[voxelIdx * 4 + 3] = q[3]; + }; + auto setAvg = [&](usize featureIdx, const std::array& q) { + (*td.avgQuats)[featureIdx * 4 + 0] = q[0]; + (*td.avgQuats)[featureIdx * 4 + 1] = q[1]; + (*td.avgQuats)[featureIdx * 4 + 2] = q[2]; + (*td.avgQuats)[featureIdx * 4 + 3] = q[3]; + }; + + // Voxel 0: featureId=0 (background), phase=0 -> FRM = 0 + (*td.featureIds)[0] = 0; + (*td.cellPhases)[0] = 0; + setQuat(0, qIdentity); + + // Voxels 1, 2: featureId=1, phase=1, quats = identity. AvgQuats[1] = 5deg -> FRM = 5deg. + (*td.featureIds)[1] = 1; + (*td.cellPhases)[1] = 1; + setQuat(1, qIdentity); + (*td.featureIds)[2] = 1; + (*td.cellPhases)[2] = 1; + setQuat(2, qIdentity); + setAvg(1, q5); + + // Voxels 3, 4: featureId=2, phase=1, quats = identity. AvgQuats[2] = identity -> FRM = 0. + (*td.featureIds)[3] = 2; + (*td.cellPhases)[3] = 1; + setQuat(3, qIdentity); + (*td.featureIds)[4] = 2; + (*td.cellPhases)[4] = 1; + setQuat(4, qIdentity); + setAvg(2, qIdentity); + + // Voxel 5: featureId=3, phase=1, quats = 10deg. AvgQuats[3] = 5deg -> FRM = 5deg. + (*td.featureIds)[5] = 3; + (*td.cellPhases)[5] = 1; + setQuat(5, q10); + // Voxels 6, 7: featureId=3, phase=0 (un-phased) -> FRM = 0 (skip path). + (*td.featureIds)[6] = 3; + (*td.cellPhases)[6] = 0; + setQuat(6, qIdentity); + (*td.featureIds)[7] = 3; + (*td.cellPhases)[7] = 0; + setQuat(7, qIdentity); + setAvg(3, q5); + + // Voxels 8-11: featureId=4, phase=0 (entire feature un-phased) -> all FRM = 0, + // avgMisorientationCounts[4] == 0 -> avg[4] = 0 (path 7). + (*td.featureIds)[8] = 4; + (*td.cellPhases)[8] = 0; + setQuat(8, qIdentity); + (*td.featureIds)[9] = 4; + (*td.cellPhases)[9] = 0; + setQuat(9, qIdentity); + (*td.featureIds)[10] = 4; + (*td.cellPhases)[10] = 0; + setQuat(10, qIdentity); + (*td.featureIds)[11] = 4; + (*td.cellPhases)[11] = 0; + setQuat(11, qIdentity); + setAvg(4, qIdentity); + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(0); + 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); + + // Per-voxel expected FRMs. + ToyFixtures::RequireFRMClose(td.ds, 0, 0.0f); // background + ToyFixtures::RequireFRMClose(td.ds, 1, 5.0f); // feature 1 + ToyFixtures::RequireFRMClose(td.ds, 2, 5.0f); + ToyFixtures::RequireFRMClose(td.ds, 3, 0.0f); // feature 2 + ToyFixtures::RequireFRMClose(td.ds, 4, 0.0f); + ToyFixtures::RequireFRMClose(td.ds, 5, 5.0f); // feature 3, only valid voxel + ToyFixtures::RequireFRMClose(td.ds, 6, 0.0f); // feature 3, un-phased + ToyFixtures::RequireFRMClose(td.ds, 7, 0.0f); + ToyFixtures::RequireFRMClose(td.ds, 8, 0.0f); // feature 4, all un-phased + ToyFixtures::RequireFRMClose(td.ds, 9, 0.0f); + ToyFixtures::RequireFRMClose(td.ds, 10, 0.0f); + ToyFixtures::RequireFRMClose(td.ds, 11, 0.0f); + + // Per-feature expected averages. + ToyFixtures::RequireAvgClose(td.ds, 0, 0.0f); // background (no voxels contribute) + ToyFixtures::RequireAvgClose(td.ds, 1, 5.0f); // (5 + 5) / 2 + ToyFixtures::RequireAvgClose(td.ds, 2, 0.0f); // (0 + 0) / 2 + ToyFixtures::RequireAvgClose(td.ds, 3, 5.0f); // 5 / 1 (only voxel 5 valid) + ToyFixtures::RequireAvgClose(td.ds, 4, 0.0f); // count==0 -> avg=0 (path 7) + + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); +} + +// Fixture D: Mode 1, 3x3x1 single grain. Center voxel (4) has max GBEuclideanDistance and +// identity quat; perimeter voxels have 5deg quat. Expected: m_Centers[1]=4, EuclideanCenters[1] +// = coords of voxel 4, FRM[4]=0, FRM[other]=5deg, avg = 40/9 deg. +// Covers paths: 2 (Mode 1 Pass 1 centers), 3 (Mode 1 Pass 2 coords), 4, 8. +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 KnownCenter", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 2); + + const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + + for(usize i = 0; i < 9; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + } + // Distances: corners=0.25, edges=1.0, center=1.5. Center voxel (index 4) has max. + (*td.gbEuclideanDistances)[0] = 0.25f; + (*td.gbEuclideanDistances)[1] = 1.0f; + (*td.gbEuclideanDistances)[2] = 0.25f; + (*td.gbEuclideanDistances)[3] = 1.0f; + (*td.gbEuclideanDistances)[4] = 1.5f; + (*td.gbEuclideanDistances)[5] = 1.0f; + (*td.gbEuclideanDistances)[6] = 0.25f; + (*td.gbEuclideanDistances)[7] = 1.0f; + (*td.gbEuclideanDistances)[8] = 0.25f; + + // Quats: voxel 4 = identity (this is the reference); others = 5deg. + for(usize i = 0; i < 9; ++i) + { + const auto& q = (i == 4) ? qIdentity : q5; + (*td.quats)[i * 4 + 0] = q[0]; + (*td.quats)[i * 4 + 1] = q[1]; + (*td.quats)[i * 4 + 2] = q[2]; + (*td.quats)[i * 4 + 3] = q[3]; + } + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(1); + 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); +#ifdef SIMPLNX_WRITE_TEST_OUTPUT + WriteTestDataStructure(td.ds, fs::path(fmt::format("{}/find_feature_reference_misorientations_1_1.dream3d", unit_test::k_BinaryTestOutputDir))); +#endif + // FRM expected: voxel 4 = 0 (vs itself), others = 5deg. + for(usize i = 0; i < 9; ++i) + { + ToyFixtures::RequireFRMClose(td.ds, i, (i == 4) ? 0.0f : 5.0f); + } + // avg = (0 + 5*8) / 9 = 40/9 deg + ToyFixtures::RequireAvgClose(td.ds, 1, 40.0f / 9.0f); + + // EuclideanCenters[1] should be coords of voxel 4 = (1.5, 1.5, 0.5) with spacing 1 and origin 0. + const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 1] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 2] == Approx(0.5f).margin(1e-5f)); + + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); +} + +// Fixture E: Mode 1, 2x3x1 image with 2 features (3 voxels each). Verifies that m_Centers[fid] +// is correctly isolated per feature (one feature's max-distance voxel does NOT leak into the +// other feature's center selection). Tied distances (>= comparison) -> later voxel wins. +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 MultiGrain CenterIsolation", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 3, 1, 3); + + // Layout: 2x3x1 = 6 voxels, row-major (z=0 plane). + // voxel 0,1 (row 0): feature 1 + // voxel 2,3 (row 1): one of each + // voxel 4,5 (row 2): feature 2 + // For per-feature center isolation we need a contiguous-feature layout instead. Use: + // voxels 0,1,2 = feature 1; voxels 3,4,5 = feature 2. + for(usize i = 0; i < 3; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + } + for(usize i = 3; i < 6; ++i) + { + (*td.featureIds)[i] = 2; + (*td.cellPhases)[i] = 1; + } + + // Distances chosen so that: + // feature 1 center = voxel 1 (max=1.0) + // feature 2 center = voxel 5 (tied 1.5; >= comparison picks later) + (*td.gbEuclideanDistances)[0] = 0.5f; + (*td.gbEuclideanDistances)[1] = 1.0f; + (*td.gbEuclideanDistances)[2] = 0.5f; + (*td.gbEuclideanDistances)[3] = 1.5f; + (*td.gbEuclideanDistances)[4] = 0.5f; + (*td.gbEuclideanDistances)[5] = 1.5f; + + const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + auto setQuat = [&](usize voxelIdx, const std::array& q) { + (*td.quats)[voxelIdx * 4 + 0] = q[0]; + (*td.quats)[voxelIdx * 4 + 1] = q[1]; + (*td.quats)[voxelIdx * 4 + 2] = q[2]; + (*td.quats)[voxelIdx * 4 + 3] = q[3]; + }; + // Feature 1: voxel 1 (center) = identity; voxels 0, 2 = 5deg. + setQuat(0, q5); + setQuat(1, qIdentity); + setQuat(2, q5); + // Feature 2: voxel 5 (center per >= tie-break) = identity; voxels 3, 4 = 5deg. + setQuat(3, q5); + setQuat(4, q5); + setQuat(5, qIdentity); + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(1); + 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); + + // Expected: FRM[1]=0, FRM[0]=5, FRM[2]=5, FRM[5]=0, FRM[3]=5, FRM[4]=5. + ToyFixtures::RequireFRMClose(td.ds, 0, 5.0f); + ToyFixtures::RequireFRMClose(td.ds, 1, 0.0f); + ToyFixtures::RequireFRMClose(td.ds, 2, 5.0f); + ToyFixtures::RequireFRMClose(td.ds, 3, 5.0f); + ToyFixtures::RequireFRMClose(td.ds, 4, 5.0f); + ToyFixtures::RequireFRMClose(td.ds, 5, 0.0f); + // avg[1] = (5+0+5)/3 = 10/3; avg[2] = (5+5+0)/3 = 10/3. + ToyFixtures::RequireAvgClose(td.ds, 1, 10.0f / 3.0f); + ToyFixtures::RequireAvgClose(td.ds, 2, 10.0f / 3.0f); + + // Per-feature EuclideanCenters: feature 1 = voxel 1 coords; feature 2 = voxel 5 coords. + // Voxel 1 in a 2x3x1 grid is at (x=1, y=0, z=0); voxel 5 is at (x=1, y=2, z=0). With spacing 1 and + // origin 0, getCoordsf returns center-of-cell coordinates: voxel 1 -> (1.5, 0.5, 0.5); voxel 5 -> (1.5, 2.5, 0.5). + const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 1] == Approx(0.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 2] == Approx(0.5f).margin(1e-5f)); + REQUIRE(centers[2 * 3 + 0] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[2 * 3 + 1] == Approx(2.5f).margin(1e-5f)); + REQUIRE(centers[2 * 3 + 2] == Approx(0.5f).margin(1e-5f)); + + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); +} + +// Fixture F: Mode 1, 3D (3x3x2 = 18 voxels) single grain. Verifies the linear voxelIdx -> 3D coord +// conversion that getCoordsf() performs when dimZ > 1. Other Mode 1 fixtures are 2D (Z=1) and +// therefore don't exercise the z-multiplier in the index arithmetic. Center voxel is in the +// middle of layer 1: voxelIdx = 1*9 + 1*3 + 1 = 13. +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 3D Volume", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 2, 2); + + const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + + for(usize i = 0; i < 18; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + (*td.gbEuclideanDistances)[i] = 0.5f; + } + // Make voxel 13 (layer 1, y=1, x=1) the unique max-distance voxel. + (*td.gbEuclideanDistances)[13] = 1.5f; + + // Quats: voxel 13 = identity (reference); others = 5deg. + for(usize i = 0; i < 18; ++i) + { + const auto& q = (i == 13) ? qIdentity : q5; + (*td.quats)[i * 4 + 0] = q[0]; + (*td.quats)[i * 4 + 1] = q[1]; + (*td.quats)[i * 4 + 2] = q[2]; + (*td.quats)[i * 4 + 3] = q[3]; + } + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(1); + 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); + // #ifdef SIMPLNX_WRITE_TEST_OUTPUT + WriteTestDataStructure(td.ds, fs::path(fmt::format("{}/find_feature_reference_misorientations_1_1_3D.dream3d", unit_test::k_BinaryTestOutputDir))); + // #endif + // FRM expected: voxel 13 = 0 (vs itself), others = 5deg. + for(usize i = 0; i < 18; ++i) + { + ToyFixtures::RequireFRMClose(td.ds, i, (i == 13) ? 0.0f : 5.0f); + } + // avg = (0 + 5*17) / 18 = 85/18 deg. + ToyFixtures::RequireAvgClose(td.ds, 1, 85.0f / 18.0f); + + // EuclideanCenters[1] should be coords of voxel 13. Spacing=1, origin=0, getCoordsf returns + // cell-center coords: voxel 13 is at (x=1, y=1, z=1) -> center coords (1.5, 1.5, 1.5). + const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 1] == Approx(1.5f).margin(1e-5f)); + REQUIRE(centers[1 * 3 + 2] == Approx(1.5f).margin(1e-5f)); + + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); +} + +// Class 4 sweep: re-run each Class 1 fixture with the goal of asserting Class 4 invariants only, +// without per-value comparison. Catches future regressions where specific values shift but +// invariants still hold (vs. the value-specific Class 1 fixtures above which would catch the +// specific shift but might be more brittle to legitimate refactors). +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 4 - Invariants Sweep", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Sweep 1: Mode 0 with a 3x3x1 grid mixing valid voxels, a background voxel, and un-phased voxels + // across 3 features (different from the value-specific Mode 0 fixture above). + { + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 3); + const auto q0 = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q7 = ToyFixtures::QuatFromPhi1Deg(7.0f); + // Voxels: bg, f1, f1, f1, f2(un-phased), f2, bg, f2, f1 + const std::array, 9> layout = {{{0, 0}, {1, 1}, {1, 1}, {1, 1}, {2, 0}, {2, 1}, {0, 0}, {2, 1}, {1, 1}}}; + for(usize i = 0; i < 9; ++i) + { + (*td.featureIds)[i] = layout[i].first; + (*td.cellPhases)[i] = layout[i].second; + const auto& q = (i % 2 == 0) ? q0 : q7; + (*td.quats)[i * 4 + 0] = q[0]; + (*td.quats)[i * 4 + 1] = q[1]; + (*td.quats)[i * 4 + 2] = q[2]; + (*td.quats)[i * 4 + 3] = q[3]; + } + (*td.avgQuats)[1 * 4 + 0] = q0[0]; + (*td.avgQuats)[1 * 4 + 1] = q0[1]; + (*td.avgQuats)[1 * 4 + 2] = q0[2]; + (*td.avgQuats)[1 * 4 + 3] = q0[3]; + (*td.avgQuats)[2 * 4 + 0] = q7[0]; + (*td.avgQuats)[2 * 4 + 1] = q7[1]; + (*td.avgQuats)[2 * 4 + 2] = q7[2]; + (*td.avgQuats)[2 * 4 + 3] = q7[3]; + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(0); + 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); + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); + } + + // Sweep 2: Mode 1 with the Fixture-D config (re-run, assert invariants only). + { + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 2); + const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + for(usize i = 0; i < 9; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + const auto& q = (i == 4) ? qIdentity : q5; + (*td.quats)[i * 4 + 0] = q[0]; + (*td.quats)[i * 4 + 1] = q[1]; + (*td.quats)[i * 4 + 2] = q[2]; + (*td.quats)[i * 4 + 3] = q[3]; + } + (*td.gbEuclideanDistances)[4] = 1.5f; + for(usize i = 0; i < 9; ++i) + { + if(i != 4) + { + (*td.gbEuclideanDistances)[i] = 0.5f; + } + } + + ComputeFeatureReferenceMisorientationsFilter filter; + Arguments args = ToyFixtures::BuildArgs(1); + 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); + ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); + } +} diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md new file mode 100644 index 0000000000..08d4201ae5 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md @@ -0,0 +1,138 @@ +# V&V Report: ComputeFeatureReferenceMisorientationsFilter + +| | | +|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `24b54daf-3bf5-4331-93f6-03a49f719bf1` | +| SIMPLNX Human Name | Compute Feature Reference Misorientations | +| DREAM3D 6.5.171 equivalent | `FindFeatureReferenceMisorientations` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureReferenceMisorientations.{h,cpp}` (UUID `428e1f5b-e6d8-5e8b-ad68-56ff14ee0e8c`) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | *Michael Jackson (V&V cycle completion, 2026-06-01) — algorithm originally translated to SIMPLNX by Nathan Young (PR history)* | + +## At a glance + +| Aspect | Current state | +|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Port (with UUID reassignment and API modernization)** of legacy `FindFeatureReferenceMisorientations::execute()`. Same algorithm structure (two modes; per-voxel misorientation accumulation; per-feature averaging) and same per-voxel math (delegated to `LaueOps::calculateMisorientation`). Port-time deltas: `QuatF`→`QuatD`, `getMisoQuat`→`calculateMisorientation`, raster `for col/row/plane`→linear `for voxelIdx`, cancel checks added, new optional `EuclideanCenters` output array for Mode 1. | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 6 hand-derived toy fixtures covering both reference-orientation modes + 2D + 3D + multi-feature + edge cases. **Class 4 (Invariant) companion** — monotonicity, range bounds, skip-condition correctness, and the per-feature averaging formula asserted via `ClassFourInvariants::AssertClass4Invariants()` across both fixture configurations. | +| Code paths enumerated | 7 of 8 algorithmic paths exercised directly (Mode 0 vs Mode 1 dispatch, `m_Centers` selection, `EuclideanCenters` writing, valid-voxel accumulate, skip-voxel, finalize-non-empty, finalize-empty-count). 1 path (cancel-check) tested implicitly via the unconditional cancel-check-at-loop-top instrumentation. | +| Tests today | **8 TEST_CASEs / 8 ctest entries**, 100% pass (~0.7s). 6 Class 1 toy fixtures + 1 Class 4 invariants sweep + 1 SIMPL backwards-compatibility test. **No exemplar archive consumed.** | +| Exemplar archive | **None — inline-constructed in test source.** The pre-existing `compute_feature_reference_misorientation.tar.gz` archive (Small-IN100-based regression-against-exemplar) was **retired 2026-06-01** because its exemplar arrays were a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output). The 6 hand-derived toy fixtures cover all 8 algorithmic paths and replace the regression-against-archive coverage. | +| Legacy comparison | **Source-inspection comparison against DREAM3D 6.5.171** completed. Algorithm structurally identical to legacy modulo port-time deltas. **No algorithmic deviations** observed (no behavioral bugs in either implementation). One precision-class non-deviation documented: the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision improvement (already characterized in `BadDataNeighborOrientationCheckFilter`'s V&V cycle) propagates into per-feature averages for sym-op-aligned grain boundaries — non-observable on toy data. | +| Bug flags | None. | +| V&V phase | **All V&V work complete per V2 policy.** Class 1 + Class 4 oracle confirmed against 8-test suite; circular-oracle archive retired; legacy A/B by source inspection; algorithm review applied; user-facing doc updated. Three source-tree deliverables (this report + `vv/deviations/...` + `vv/provenance/...`) are in place. **Outstanding:** Status promotion DRAFT → READY FOR REVIEW pending second-engineer oracle review (recommend Joey Kleingers). | + +## Summary + +`ComputeFeatureReferenceMisorientationsFilter` computes the misorientation angle (in degrees) between each cell's quaternion and a per-feature reference quaternion. The reference is either the feature's average quaternion (Mode 0) or the quaternion of the voxel within the feature that is farthest from the grain boundary (Mode 1). Per-feature averages are computed alongside the per-cell values. In Mode 1, a `Feature Euclidean Centers` array recording the coordinates of each feature's reference voxel is also written. + +Verification is via a **Class 1 (Analytical) hand-derived toy-fixture set of 6 unit tests** plus a **Class 4 (Invariant) companion sweep**. The fixtures use pure φ1-rotation quaternions (Bunge ZXZ Euler `(φ1, 0, 0)`) so that misorientation values are closed-form derivable: for Δφ1 ∈ {0°, 5°, 10°} and cubic symmetry, the symmetry-reduced misorientation equals `|Δφ1|`. The fixtures range from 2×2×2 (8 voxels, 1 feature) to 4×3×1 (12 voxels, 5 features including a background and an all-unphased feature), and include a 3×3×2 (18-voxel) 3D fixture specifically to verify the linear-voxelIdx → 3D-coord arithmetic in Mode 1. + +A pre-existing `compute_feature_reference_misorientation.tar.gz` archive (Small-IN100 regression-against-exemplar) was retired during this V&V cycle: its exemplar arrays were generated from a pre-EbsdLib-2.4.1 SIMPLNX run (circular oracle), and the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision improvement shifted the exemplar values by 2× to 10× the 1e-4 epsilon used in the regression check. The toy fixtures cover all 8 algorithmic paths analytically and remove the circular-oracle dependency. Source inspection of the legacy `FindFeatureReferenceMisorientations` confirms the SIMPLNX algorithm is a clean Port with no algorithmic deviations; the only legacy-vs-SIMPLNX difference is the EbsdLib precision improvement (a precision-class non-deviation), which manifests on real EBSD data with cubic-phase sym-op-aligned boundaries but is non-observable on the toy fixtures. + +## Algorithm Relationship + +*Classification:* **Port (with UUID reassignment and API modernization)** ~~| Minor changes | Rewrite | New filter~~ + +*Evidence:* The SIMPLNX algorithm at `Algorithms/ComputeFeatureReferenceMisorientations.cpp` (~175 lines) is a near line-by-line translation of legacy `FindFeatureReferenceMisorientations::execute()` (DREAM3D 6.5.171, ~110 lines). Same two-mode dispatch (`ReferenceOrientation` parameter), same per-voxel main loop computing misorientation via `LaueOps`, same per-feature averaging finalization. The SIMPLNX filter was assigned a **new UUID** (`24b54daf-3bf5-4331-93f6-03a49f719bf1` vs legacy `428e1f5b-e6d8-5e8b-ad68-56ff14ee0e8c`) for the `Find` → `Compute` rename; SIMPL 6.4/6.5 pipelines still open correctly via the conversion fixtures at `test/simpl_conversion/6_*/`. + +*Port-time deltas (non-deviation — preserve algorithmic equivalence at toy-data precision):* + +1. **EbsdLib API**: `getMisoQuat(q1, q2, n1, n2, n3) → float angle` → `calculateMisorientation(q1, q2) → AxisAngleDType`. Modernized return type carries axis + angle together. Underlying math identical (delegated to the same `LaueOps` Laue-group reduction). +2. **Quaternion precision**: legacy `QuatF` (float32) inside the algorithm → SIMPLNX `QuatD` (double). Float32 inputs are promoted to double for the misorientation calculation; output is cast back to float32 for storage. Mathematically equivalent for non-precision-sensitive inputs; the precision improvement IS visible for cubic sym-op-aligned boundaries (see D1). +3. **Voxel iteration**: legacy `for col / row / plane` triple loop → SIMPLNX `for voxelIdx` linear loop. Equivalent iteration order; raster-order assumptions in `m_Centers` selection preserved (`>=` tie-break: later voxel wins). +4. **Auxiliary storage**: legacy `avgMisoPtr` (size `2 × totalFeatures`, interleaved count + sum) → SIMPLNX `avgMisorientationSums` + `avgMisorientationCounts` (two separate vectors). Cleaner structure, equivalent semantics. +5. **Cancel checks**: SIMPLNX adds `m_ShouldCancel.load()` at all three loop levels; legacy had no cancel mechanism. UX improvement; no algorithmic impact when cancel is not signaled. +6. **New optional `EuclideanCenters` output array** (Mode 1 only): SIMPLNX writes a per-feature 3-component coord array recording the location of the reference voxel; legacy did not produce this array. **This is a new feature of the SIMPLNX implementation**, not a port artifact; it does not affect any pre-existing output. +7. **EbsdLib 2.4.1 CubicOps precision improvement** (this V&V cycle's external dependency change): documented as a non-deviation here because it is non-observable on the V&V toy fixtures, but real-world EBSD data with sym-op-aligned grain boundaries will see per-feature misorientation averages shift by ~ULP-scale values amplified across the feature's voxels. See D1 / `BadDataNeighborOrientationCheckFilter`'s V&V cycle for the precision-improvement characterization. + +*Material PRs since baseline (2025-10-01):* None identified that materially change this filter's algorithm. PR #1472 (EbsdLib 2.0.0 API bump) is the closest, and that just affects the `getMisoQuat` → `calculateMisorientation` API delta noted above. + +## Oracle + +*Class:* **1 (Analytical)** primary + **4 (Invariant)** companion. Class 3 (Paper-based) N/A — this filter delegates misorientation math to `ebsdlib::LaueOps::calculateMisorientation`; the Rowenhorst 2015 paper-based verification of that math is part of EbsdLib's own V&V, not this filter's. + +### Applied (Class 1 — Analytical) + +Expected per-voxel `FRM` and per-feature `avgRefMis` outputs are derived in closed form from the input `Quats` + `Phases` + `FeatureIds` + reference-quaternion source (Mode 0: `AvgQuats[fid]`; Mode 1: `Quats[centerVoxelIdx]`) by hand-tracing the algorithm. The fixtures use pure φ1-rotation quaternions (Bunge ZXZ Euler `(φ1, 0, 0)`) so that misorientation between any two voxels equals `|Δφ1|` modulo the cubic c-axis 4-fold symmetry. For Δφ1 ∈ {0°, 5°, 10°} the symmetry reduction is the identity (no fold below the input), so expected FRM values are exactly `|Δφ1|`. Per-feature averages are `sum(FRM[v ∈ feature fid, phase>0]) / count(v ∈ feature fid, phase>0)` for `fid > 0` with non-empty count, or `0` when count is empty (path 7 in the code-path table below). + +Mode 1 hand-picks `GBEuclideanDistances` values so that `m_Centers[fid]` selection has a unique closed-form answer (or, for the tied-distance multi-feature fixture, a deterministic later-voxel-wins tie-break per the `>=` comparison semantics that both legacy and SIMPLNX share). + +### Applied (Class 4 — Invariant) + +Five invariants every filter run must satisfy regardless of input configuration, asserted via `namespace ToyFixtures::AssertClass4Invariants()` in the test source: + +- **Non-negativity**: `FRM[i] >= 0` ∀ voxel +- **Cubic max-angle bound**: `FRM[i] <= 62.8°` for cubic phases (the maximum symmetry-reduced misorientation under m-3m symmetry) +- **Skip-path correctness**: `FRM[i] == 0` when `featureIds[i] == 0` OR `cellPhases[i] == 0` +- **Background-feature zero**: `avgRefMis[0] == 0` (background) +- **Per-feature averaging formula**: `avgRefMis[fid] == sum(FRM[v ∈ feature fid, phase>0]) / count(v ∈ feature fid, phase>0)` for `fid > 0` with count > 0; `avgRefMis[fid] == 0` when count == 0 + +### Encoded + +- **Class 1 (Analytical)**: `test/ComputeFeatureReferenceMisorientationsTest.cpp` — 6 `TEST_CASE` blocks under the `Class 1 - …` family. Per-voxel and per-feature expected values asserted via `ToyFixtures::RequireFRMClose()` / `RequireAvgClose()` with 1e-3° tolerance (degrees) and `Approx().margin(1e-5f)` for `EuclideanCenters` coord assertions. +- **Class 4 (Invariant)**: `ComputeFeatureReferenceMisorientationsFilter: Class 4 - Invariants Sweep` — two configurations (Mode 0 mixed 3×3×1 and Mode 1 3×3×1) each asserting all five invariants via the `AssertClass4Invariants()` helper. +- *(kept)* `ComputeFeatureReferenceMisorientationsFilter: SIMPL Backwards Compatibility` — SIMPL 6.4 + 6.5 conversion paths via `DYNAMIC_SECTION`; UUID + argument-key + parameter-value validation only. + +### Second-engineer review + +*Pending — recommend an OA-domain engineer (Joey Kleingers or similar) review:* +- *The Class 1 hand-derivations in the 6 toy fixtures + 1 invariants sweep for plausibility (the fixtures are small enough to walk through in ~30 minutes).* +- *The Class 4 invariant set for completeness — are there other properties this algorithm must satisfy?* +- *The decision to retire the `compute_feature_reference_misorientation.tar.gz` Small-IN100 exemplar archive in favor of inline toy fixtures.* + +## Code path coverage + +*7 of 8 paths exercised directly; 1 (cancel) implicitly via the unconditional cancel-check-at-loop-top instrumentation.* + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceMisorientations.cpp` (~175 lines). + +The algorithm has three logical phases: (a) Mode 1 pre-loop (populate `centers[fid]` from `gbEuclideanDistances` + write `EuclideanCenters` coords); (b) main per-voxel loop (compute per-voxel misorientation, accumulate per-feature sums + counts); (c) per-feature finalize (compute per-feature average from sum/count). + +| # | Phase | Path | Test case | +|----|-------------------|-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | (b) Main loop | Mode 0 — `q2 = avgQuatsPtr[featureId * 4 .. ]` | Fixtures A, B, C (`Class 1 - Mode 0 SingleGrainIdentity` / `KnownAngle5deg` / `MultiGrain EdgeCases`) | +| 2 | (a) Mode 1 pre-1 | Mode 1 — first sweep populates `centers[fid]` via `>=` tie-break against `gbEuclideanDistances` | Fixtures D, E, F (`Mode 1 KnownCenter` / `MultiGrain CenterIsolation` / `3D Volume`) | +| 3 | (a) Mode 1 pre-2 | Mode 1 — second sweep writes `EuclideanCenters[fid] = imageGeom.getCoordsf(centers[fid])` | Fixtures D, E, F | +| 4 | (b) Main loop | `featureIds[i] > 0 && cellPhases[i] > 0` → compute misorientation, accumulate `sums[fid]++` and `counts[fid]+=` | All Class 1 fixtures | +| 5 | (b) Main loop | Skip (`featureIds[i] == 0` OR `cellPhases[i] == 0`) → FRM stays 0 (initial `fill(0.0f)` value) | Fixture C (`Mode 0 MultiGrain EdgeCases`) — background voxel 0, mid-feature unphased voxels 6-7, entire feature 4 unphased voxels 8-11 | +| 6 | (b) Main loop | Cancel check at loop top (`m_ShouldCancel.load()` → early return) | *Not directly tested.* Unconditional check at loop top in both passes; failure mode (silent cancel-disregard) would manifest as test hang in any test, but is not specifically exercised. Low-value gap. | +| 7 | (c) Finalize | `avgMisorientationCounts[fid] == 0` → `avgRefMis[fid] = 0` | Fixture C — feature 4 has all-unphased voxels, so `count[4] == 0` | +| 8 | (c) Finalize | Otherwise → `avgRefMis[fid] = sums[fid] / counts[fid]` | All Class 1 fixtures with non-empty features | + +## Test inventory + +| Test case | Status | Notes | +|------------------------------------------------------------------------------------------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 SingleGrainIdentity` | new-for-V&V | 2×2×2; single feature; all identity quats; expected FRM=0, avg=0. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 KnownAngle5deg` | new-for-V&V | 2×2×2; single feature; all voxel quats = 5° about z, AvgQuats[1] = identity; expected FRM=5° everywhere, avg=5°. Verifies non-zero misorientation magnitude. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 MultiGrain EdgeCases` | new-for-V&V | 4×3×1; 5 features (background + 4 grains); covers skip paths (background, mid-feature unphased), zero-count finalize path (feature 4 all-unphased → avg=0), and normal accumulation. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 KnownCenter` | new-for-V&V | 3×3×1; single feature; center voxel hand-picked via unique max `GBEuclideanDistances`; verifies `centers[]` selection + `EuclideanCenters` coord writing. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 MultiGrain CenterIsolation` | new-for-V&V | 2×3×1; 2 features; verifies `centers[fid]` isolation per feature + tied-distance `>=` later-voxel-wins tie-break. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 3D Volume` | new-for-V&V | 3×3×2; single feature; verifies linear `voxelIdx → (x,y,z)` arithmetic when `dimZ > 1`. Class 4 invariants asserted. | +| `ComputeFeatureReferenceMisorientationsFilter: Class 4 - Invariants Sweep` | new-for-V&V | Runs Mode 0 and Mode 1 configurations distinct from the value-specific fixtures; asserts only the Class 4 invariants. Catches future regressions where specific values shift but invariants still hold. | +| `ComputeFeatureReferenceMisorientationsFilter: SIMPL Backwards Compatibility` | retained | `DYNAMIC_SECTION` over SIMPL 6.4 + 6.5 conversion fixtures (`test/simpl_conversion/6_*/ComputeFeatureReferenceMisorientationsFilter.json`); validates UUID + argument-key + parameter-value decoding. | +| *(retired)* `ComputeFeatureReferenceMisorientationsFilter_AverageMisorientation` | retired | Removed 2026-06-01. Regression-against-archive test consuming `compute_feature_reference_misorientation.tar.gz` exemplar arrays; archive's exemplar values were a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output). Test failure surfaced when EbsdLib 2.4.1 CubicOps precision fix shifted exemplar values by 2× epsilon. Replaced by inline Class 1 + Class 4 fixtures above. | +| *(retired)* `ComputeFeatureReferenceMisorientationsFilter_EuclideanDistance` | retired | Same as above for Mode 1; archive exemplar shifted by 10× epsilon post-EbsdLib-2.4.1. | + +All 8 active TEST_CASEs pass at the verified commit (`100% tests passed, 0 tests failed out of 8` in ~0.7s). + +## Exemplar archive + +**None — data inlined in `test/ComputeFeatureReferenceMisorientationsTest.cpp` namespace `ToyFixtures`.** + +The pre-existing `compute_feature_reference_misorientation.tar.gz` archive was retired during this V&V cycle. See `src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md` for the retirement rationale and the methodology used to construct the replacement toy fixtures. + +- **Archive:** None +- **SHA512:** N/A +- **Provenance:** `src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md` + +## Deviations from DREAM3D 6.5.171 + +One precision-class non-deviation documented; no algorithmic deviations. + +### ComputeFeatureReferenceMisorientationsFilter-D1 + +- **Symptom:** Per-feature average misorientations differ from DREAM3D 6.5.171 output by 2× to 10× the 1e-4 epsilon used by `CompareDataArrays` on Small-IN100-class EBSD data. On the V&V toy fixtures (pure φ1 rotations, no sym-op-aligned boundaries), no observable deviation. +- **Root cause:** Precision — propagation of the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision fix. This filter is a clean Port of the legacy algorithm; no algorithmic deviation. See `vv/deviations/ComputeFeatureReferenceMisorientationsFilter.md` for full root-cause walkthrough. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceMisorientationsFilter.md new file mode 100644 index 0000000000..82748b1101 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceMisorientationsFilter.md @@ -0,0 +1,70 @@ +# Deviations from DREAM3D 6.5.171: ComputeFeatureReferenceMisorientationsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`FindFeatureReferenceMisorientations`, source at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureReferenceMisorientations.{h,cpp}` in DREAM3D 6.5.171). + +Entries are referenced by stable ID (`ComputeFeatureReferenceMisorientationsFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +## Comparison summary + +The legacy A/B comparison was performed by **source inspection** rather than empirical run. Justification: SIMPLNX `ComputeFeatureReferenceMisorientationsFilter::operator()()` is a clean Port of legacy `FindFeatureReferenceMisorientations::execute()` (same two-mode dispatch, same per-voxel main loop, same per-feature averaging finalization, same `LaueOps`-delegated misorientation math, same `m_Centers` selection with `>=` tie-break). The only port-time deltas are API modernization (`getMisoQuat` → `calculateMisorientation`), type widening (`QuatF` → `QuatD` for the internal math, narrowed back to `float` for storage), cleaner auxiliary storage (interleaved `avgMisoPtr` → separate `avgMisorientationSums` + `avgMisorientationCounts`), added cancel checks, and a new optional `EuclideanCenters` output array in Mode 1 (no pre-existing output is affected). + +For the toy fixtures used in this V&V cycle (pure φ1 rotations about z, no cubic-symmetry-op-aligned grain boundaries), both implementations are expected to produce bit-identical outputs modulo `float` precision (< 1 ULP differences possible due to `QuatF` → `QuatD` promotion). + +For real EBSD data (e.g., the Small-IN100 dataset that the retired exemplar archive came from), the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision improvement (`2·atan2(|v|, w)` form replacing the precision-fragile `acos(w)` near 1) propagates through SIMPLNX's misorientation math and yields per-voxel `FRM` values that differ from legacy by ~ULP-scale (sub-`0.0001°`) for sym-op-aligned voxel pairs. When those per-voxel values are averaged into per-feature `avgRefMis` quantities, the small per-voxel shifts can accumulate to 2×–10× the `1e-4` epsilon used by the retired exemplar tests. + +--- + +## ComputeFeatureReferenceMisorientationsFilter-D1 + +| Field | Value | +|------------------|-------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureReferenceMisorientationsFilter-D1` | +| **Filter UUID** | `24b54daf-3bf5-4331-93f6-03a49f719bf1` | +| **Status** | active (precision-class; non-deviation in algorithmic sense) | + +**Symptom:** Per-feature average misorientations (`Feature Avg Misorientations`) differ between SIMPLNX and DREAM3D 6.5.171 on real EBSD datasets containing cubic-phase grains with grain boundaries near cubic-symmetry operators. On the Small-IN100 dataset (the basis for the retired `compute_feature_reference_misorientation.tar.gz` exemplar archive), Mode 0 (`AverageMisorientation`) averages drifted by ~`2e-4` (2× the `1e-4` epsilon used by `CompareDataArrays`); Mode 1 (`EuclideanDistance`) averages drifted by ~`1e-3` (10×). Per-voxel `FRM` values shift by sub-`0.0001°` (within float precision), but the magnitude amplifies when summed over a feature's voxels and divided by count. + +On the V&V toy fixtures (pure φ1 rotations about z, no cubic-sym-op-aligned voxel pairs), no observable deviation. All 6 Class 1 fixtures produce SIMPLNX values within `1e-3°` of the analytical expected value. + +**Root cause:** **Precision** — not an algorithm change in either implementation. + +The deviation traces to the EbsdLib 2.4.1 release commit `5c8c993` (BlueQuartz Software, 2026-05-29), which replaces a precision-fragile `acos(w)` form in `CubicOps::calculateMisorientationInternal` with a numerically-stable `2·atan2(|v|, w)` form using the explicit reduced-quaternion `v` components. The precision improvement is real and mathematically more correct; it manifests for cubic misorientations whose minimum-rotation-axis representation lies on or near a cubic symmetry operator (e.g., 90° about the cubic c-axis is a 4-fold sym op of m-3m; pre-fix `acos`-form yielded `~0.02°` residual due to float32-input ULP noise, post-fix yields the mathematically correct value). + +This filter is a clean Port of `FindFeatureReferenceMisorientations` from DREAM3D 6.5.171; the SIMPLNX algorithm reproduces the legacy two-mode dispatch + per-voxel misorientation accumulation + per-feature averaging structure exactly. The legacy filter consumes `OrientationLib::CubicOps::getMisoQuat` (pre-fix `acos`-form, float32); the SIMPLNX filter consumes `ebsdlib::CubicOps::calculateMisorientation` (post-fix `2·atan2`-form, QuatD). The difference is entirely in the EbsdLib precision improvement, NOT in this filter. + +For the full root-cause walkthrough of the EbsdLib precision improvement, see the precedent characterization in `vv/deviations/BadDataNeighborOrientationCheckFilter.md` §"Non-deviations" → "EbsdLib 2.4.1 CubicOps precision improvement". The characterization there applies equally to this filter; the only difference is that this filter's per-feature averaging amplifies the per-voxel precision shift across the feature's voxels (typically hundreds to thousands for real EBSD data), making the deviation more visible at the per-feature output level than at the per-voxel level. + +**Affected users:** Anyone running this filter in DREAM3D 6.5.171 on EBSD data with cubic-phase grains that have grain boundaries near 4-fold (90° c-axis), 3-fold (120° [111]), or 2-fold (180° face-diagonal) cubic symmetry operators, and comparing per-feature `Feature Avg Misorientations` output across the version boundary. On non-cubic-phase data, no deviation. On cubic data without sym-op-aligned boundaries, no observable deviation. + +**Recommendation:** **Trust SIMPLNX.** The 6.5.171 result was limited by float32-input ULP noise amplified by `acos`-near-1 catastrophic cancellation; SIMPLNX returns the mathematically correct value. The `~0.02°` shift is well below typical EBSD measurement resolution (per the BadDataNeighborOrientationCheckFilter V&V cycle's precedent characterization) and will not materially affect downstream microstructural analyses for users migrating from DREAM3D 6.5.171. + +--- + +## Non-deviations (algorithm characteristics common to both filters) + +The following behaviors are NOT deviations — SIMPLNX and 6.5.171 agree on them. Captured here so future engineers don't re-discover them and propose them as deviations. + +### Raster-order tie-break in `centers[]` selection (Mode 1) + +Both implementations use `if(distance >= centerDistances[featureId])` in the Mode 1 pre-loop that selects each feature's reference voxel. The `>=` (rather than `>`) means that when two or more voxels within a feature have identical `GBEuclideanDistances` values, the LATER voxel (in linear iteration order) overwrites earlier candidates and is selected as the feature's reference. The choice is therefore raster-order dependent — different DataStructure layouts that expose the same logical voxels in a different iteration order would yield different `centers[]` and different `EuclideanCenters`. **Both filters share this behavior** — verified by source inspection of the legacy `FindFeatureReferenceMisorientations::execute()` lines 320-325 vs SIMPLNX `ComputeFeatureReferenceMisorientations.cpp` lines 89-103. + +### Background voxel and unphased voxel handling + +Both implementations skip voxels where `featureIds[i] == 0` (background) or `cellPhases[i] == 0` (unphased). In both, the per-voxel `FRM` array is initialized to 0 (or `fill(0.0f)` in SIMPLNX) and skipped voxels retain that zero value. Per-feature `avgRefMis` is computed only over the non-skipped voxels in each feature; if a feature consists entirely of skipped voxels, its `count == 0` and `avgRefMis[fid]` is set to `0`. **Both filters share this behavior** — algorithm characteristic, not a defect. + +### Background feature (featureId = 0) → `avgRefMis[0] == 0` + +Both implementations leave `avgRefMis[0]` at its initialized `0.0f` value (since the main per-voxel loop only computes misorientations for `featureIds[i] > 0`, and the per-feature finalize loop iterates `for(i = 1; i < totalFeatures; i++)`, skipping index 0 entirely). Algorithm characteristic, not a defect. + +--- + +## Comparison artifacts + +For this filter's V&V cycle, the legacy A/B comparison was performed by **source inspection** rather than empirical run. Justification: both algorithms have been independently V&V'd at the source-code level (this filter via the V&V report; the EbsdLib precision math via BadDataNeighborOrientationCheckFilter's V&V cycle), and the toy fixtures used here do not include sym-op-aligned boundaries that would surface the precision-class deviation. Running an empirical A/B on the toy fixtures would confirm bit-identical (or sub-ULP) output, which is the expected outcome from source inspection. + +If a future engineer wants to run an empirical A/B for confirmation: + +- **6.5.171 binary**: `/Users/mjackson/Applications/DREAM3D.app/Contents/bin/PipelineRunner` +- **Suggested input fixture**: convert any V&V toy fixture to legacy `.dream3d` format via the same h5py-based script pattern used in `BadDataNeighborOrientationCheckFilter`'s `bad_data_neighbor_orientation_check_v2/case_1/.../6_5_*_input.json` series. A draft Python script for Fixture B (Mode 0, 2×2×2 single grain, all 5° about z) lives at `/tmp/build_cfrm_fixtureB_legacy.py` from this V&V cycle. +- **Suggested legacy pipeline**: `DataContainerReader` → `FindFeatureReferenceMisorientations` → `DataContainerWriter`. The `DataContainerReader` requires a `DataContainerArrayProxy` enumerating the input file's paths; that adds ~150 lines of JSON for a 6-toy-fixture sweep. +- **Expected outcome**: bit-identical or sub-ULP-difference output between SIMPLNX and 6.5.171 on the toy fixtures (no sym-op-aligned boundaries → precision improvement not observable). diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md new file mode 100644 index 0000000000..a928be2fbb --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceMisorientationsFilter.md @@ -0,0 +1,117 @@ +# Test Data Provenance: ComputeFeatureReferenceMisorientationsFilter + +This sidecar records how the test data used by `ComputeFeatureReferenceMisorientationsFilter`'s unit tests was generated. + +**Important:** Unlike most simplnx test data sets, **this filter's test data is NOT stored in a `.tar.gz` archive** — it is inline-constructed in C++ within the test source file. The provenance document still applies because the *methodology* (how the data was derived, who derived it, what convention was used) is what needs to be reproducible, not the binary artifact. This sidecar fills the same role as a per-archive provenance sidecar would: it answers "where did this gold-standard data come from?" + +--- + +## Test data identity + +| Field | Value | +|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| **Data location** | Inline in `src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp` — `namespace ToyFixtures` | +| **Data format** | C++ source code (no external file, no `download_test_data()` entry, no SHA512) | +| **Used by tests** | All 6 `Class 1` and `Class 4` TEST_CASE blocks (see test file). Pre-existing `_AverageMisorientation` and `_EuclideanDistance` exemplar-tests retired during this V&V cycle. | +| **Generated by** | Michael Jackson (V&V lead) + Claude (analytical derivation + code-generation assist) on `vv/compute_feature_face_misorientations` branch | +| **Generated on** | 2026-06-01 | +| **Generated at commit** | ** | +| **EbsdLib version pin** | 2.4.1 (vcpkg pin; commit `5c8c993` on `/Users/mjackson/Workspace9/EbsdLib`) — recorded for traceability since SIMPLNX's misorientation results are coupled to this pin | + +## How it was generated + +The test data is **hand-derived from first principles** for each of the 6 toy fixtures + 1 invariant sweep. The construction methodology is: + +1. **Choose a target code path** (or set of paths) that the fixture exercises (see "Code path coverage" in the V&V report). +2. **Pick the smallest possible ImageGeom** that triggers those paths (the 6 fixtures range from 2×2×2 = 8 voxels to 4×3×1 = 12 voxels; one 3×3×2 = 18 voxel fixture verifies the 3D index → coord conversion in `Mode 1`). +3. **Pick quaternions from the Bunge ZXZ Euler subset `(φ1, 0, 0)`** — pure φ1-rotations about the cubic c-axis. Misorientation between two such quaternions reduces in closed form to `|Δφ1|` (modulo cubic 4-fold-about-c-axis symmetry); for Δφ1 ∈ {0°, 5°, 10°} the symmetry reduction is the identity (no fold below the input value), so the expected misorientation is exactly `|Δφ1|`. +4. **For Mode 1 fixtures**, hand-pick `GBEuclideanDistances` values such that the `m_Centers[fid]` selection has a unique closed-form answer (or, for Fixture E, a deterministic tie-break per the `>=` comparison semantics that both legacy and SIMPLNX share). +5. **Derive every expected output value on paper** before writing the test assertion. For the per-voxel `FRM`, this is the symmetry-reduced misorientation. For the per-feature `avgRefMis`, this is `sum / count` over the qualifying voxels (`featureId > 0 && cellPhase > 0`). For the per-feature `EuclideanCenters` (Mode 1 only), this is `imageGeom.getCoordsf(centerVoxelIdx)` evaluated with the fixture's `spacing = 1` and `origin = 0` (yielding cell-center coordinates). +6. **Embed the expected value as a literal** in the test assertion with a tolerance appropriate to the float precision of the output (`1e-3°` for FRM, `1e-5` for centers — these tolerances are tight enough to catch the EbsdLib 2.4.1 precision shift if it ever reverts, and loose enough to absorb harmless rounding). + +The construction methodology is reproducible: any engineer can read the test source, see the hand-built `featureIds` / `cellPhases` / `Quats` / `GBEuclideanDistances` / `AvgQuats` arrays, work out the expected misorientation by applying the Bunge ZXZ Euler convention + cubic symmetry rules, and verify the result matches the inline assertion. No external data fetch, no library version drift in the data itself. + +### Per-fixture summary + +| # | Fixture | Dim | Features | Mode | What it covers | +|---|----------------------------------------|---------|----------|------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A | `Mode 0 SingleGrainIdentity` | 2×2×2 | 1 | 0 | Basic Mode 0 plumbing; degenerate case where all quats == reference. Expected: all FRM = 0. | +| B | `Mode 0 KnownAngle5deg` | 2×2×2 | 1 | 0 | Mode 0 with a known non-zero misorientation. All quats = 5° about z; AvgQuats[1] = identity. Expected: FRM = 5° everywhere; avg = 5°. | +| C | `Mode 0 MultiGrain EdgeCases` | 4×3×1 | 5 | 0 | Mixed Mode 0 — background voxel (featureId=0), unphased voxels mid-feature, an entire feature with all-unphased voxels (count == 0 → avg = 0 path). | +| D | `Mode 1 KnownCenter` | 3×3×1 | 1 | 1 | Mode 1 with deterministic single-center selection. Center voxel (index 4) has uniquely max `GBEuclideanDistances` and identity quat; perimeter voxels have 5° quat. Tests `m_Centers` selection + `EuclideanCenters` coord writing. | +| E | `Mode 1 MultiGrain CenterIsolation` | 2×3×1 | 2 | 1 | Mode 1 multi-feature with per-feature center isolation. Includes deliberate tied distances within a feature to exercise the `>=` tie-break (later voxel wins). | +| F | `Mode 1 3D Volume` | 3×3×2 | 1 | 1 | Mode 1 on a true 3D volume to verify the `voxelIdx → (x, y, z)` index arithmetic when `dimZ > 1`. Center voxel index 13 = (x=1, y=1, z=1) → cell-center coord (1.5, 1.5, 1.5). | +| G | `Class 4 Invariants Sweep` | varies | varies | both | Re-runs Mode 0 and Mode 1 configurations and asserts the Class 4 invariants only (monotonicity-style properties that hold for *any* input). No value-specific assertions; catches future regressions that preserve specific values. | + +### Code path coverage + +7 of 8 algorithmic paths covered directly; 1 (cancel-check) implicit. See `ComputeFeatureReferenceMisorientationsFilter.md` §"Code path coverage" for the path table. + +## Canonical oracle output + +| DataPath | Source of expected values | +|--------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `/Image Geometry/Cell Data/Cell Reference Misorientations` (after filter runs) | **Class 1 (Analytical)** — hand-derived from pure φ1-rotation Bunge ZXZ Euler convention + cubic 4-fold-about-c-axis symmetry rule. | +| `/Image Geometry/Cell Feature Data/Feature Avg Misorientations` | **Class 1 (Analytical)** — `sum(FRM[v ∈ feature, phase>0]) / count(v ∈ feature, phase>0)` over the same per-voxel FRM values. | +| `/Image Geometry/Cell Feature Data/Feature Euclidean Centers` (Mode 1) | **Class 1 (Analytical)** — `imageGeom.getCoordsf(centerVoxelIdx)` where `centerVoxelIdx` is hand-picked via `GBEuclideanDistances`. | +| (any of the above, in any fixture) | **Class 4 (Invariant)** — predicates documented in the `ClassFourInvariants::AssertInvariants()` helper at top of the test source. | + +## Oracle provenance (Class 2, 3, 5) + +### Class 2 — Reference implementation + +Not used. The misorientation math is delegated to `ebsdlib::LaueOps::calculateMisorientation`, which is itself verified against the Rowenhorst 2015 paper-based oracle in EbsdLib's own test suite. We trust that math transitively at the pinned EbsdLib version (2.4.1, commit `5c8c993`). + +### Class 3 — Paper-based + +Indirectly applicable. The Bunge ZXZ Euler convention used for the φ1-rotation quaternion construction is documented in: + +- Rowenhorst, D., Rollett, A. D., Rohrer, G. S., Groeber, M., Jackson, M. A., Konijnenberg, P. J., & De Graef, M. (2015). *Consistent representations of and conversions between 3D rotations.* Modelling and Simulation in Materials Science and Engineering, 23(8), 083501. DOI: 10.1088/0965-0393/23/8/083501. + +This filter does not implement that paper's algorithms; it consumes them via EbsdLib. No paper figure/equation is reproduced inline in this filter's test code (the citation is included here as the reference for the convention used to construct the toy data, not as the oracle for the filter's algorithm). + +### Class 5 — Expert-visual + +N/A. + +## Second-engineer oracle review + +- **Reviewer:** *Pending — recommend Joey Kleingers or another OA-domain engineer review:* + - *The Class 1 hand-derivations in the per-fixture summary table for plausibility (the 7 fixtures are small enough to walk through in ~30 minutes).* + - *The Class 4 invariant set for completeness — are there other properties this algorithm must satisfy?* + - *The decision to retire the `compute_feature_reference_misorientation.tar.gz` archive (Small-IN100 exemplar regression test) in favor of inline toy fixtures.* +- **Date:** *YYYY-MM-DD (pending)* + +## Archive retirement + +### Retired archive: `compute_feature_reference_misorientation.tar.gz` + +| Field | Value | +|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| **Archive** | `compute_feature_reference_misorientation.tar.gz` | +| **SHA512** | `6ea9c04ca5b0c0439573b5a14bda63592181c6badb4dd325b542fb97ff2a5d492e83d2bac1bf5999612cbdb7697ec48e321549427470f1f23ccd37921c6a95f1` | +| **Status** | **Retired** 2026-06-01 during this V&V cycle. `download_test_data()` entry removed from `src/Plugins/OrientationAnalysis/test/CMakeLists.txt`. | +| **Retired tests** | `ComputeFeatureReferenceMisorientationsFilter_AverageMisorientation` (Mode 0 regression against Small IN100 exemplar) and `ComputeFeatureReferenceMisorientationsFilter_EuclideanDistance` (Mode 1 regression against Small IN100 exemplar). | + +### Why the archive was retired + +The archive contained a Small-IN100-derived `.dream3d` with both input data and pre-computed exemplar `Mask`-equivalent output arrays. The two retired tests compared filter output against those exemplar arrays via `UnitTest::CompareArrays` with a `1e-4` epsilon. After EbsdLib 2.4.1 introduced the `2·atan2(|v|, w)` precision fix in `CubicOps::calculateMisorientationInternal`, the exemplar arrays drifted by `2× to 10× epsilon` (2×10⁻⁴ to 1×10⁻³) from the new SIMPLNX output. The failure mode was the **circular-oracle pattern** described in `docs/vv_templates/oracle_classes.md` §"What is NOT an oracle": + +> *"**The filter's own output**, captured on a previous date and saved as a 'golden' exemplar. This is circular: any bug present at capture time becomes the new 'correct' answer, and the test then confirms the bug forever."* + +The exemplar arrays in the archive were generated from a SIMPLNX run at some unrecorded point in the past (the V&V cycle's discovery phase could not identify the generating pipeline, commit, or engineer — that history was lost). Regenerating the exemplar from current post-EbsdLib-2.4.1 SIMPLNX output would have re-instated the circular oracle. + +Instead, the V&V cycle replaced the regression-against-exemplar tests with the Class 1 + Class 4 toy fixtures documented above. Coverage assessment: + +- **Algorithmic paths covered:** 7 of 8 directly, 1 (cancel) implicit. Both `ReferenceOrientation` modes covered; both 2D and 3D coord arithmetic covered. +- **Property coverage:** Monotonicity, range bounds, skip-condition correctness, per-feature averaging formula, background handling, zero-count handling — all asserted via `ClassFourInvariants`. + +**What's lost by retiring the archive:** +- Scale testing (Small IN100 ≈ 150K voxels vs. toy fixtures ≤ 18 voxels). We won't catch O(N²) regressions at scale via unit tests. +- Real-world feature-size-distribution sanity check. + +The user / V&V lead's decision was that scale testing is a Tier-1 integration concern (e.g., the canonical `(04) Small IN100 Crystallographic Statistics.d3dpipeline` pipeline check), not a unit-test concern, and the trade-off favors clean analytical correctness over circular-oracle regression coverage. + +### Regenerated to fix a circular-oracle situation? + +**Yes** — see the §"Archive retirement" subsection. The 2026-06-01 V&V cycle replaced the circular-oracle exemplar archive with inline Class 1 + Class 4 oracles. The pre-existing archive is retired (not regenerated); future engineers should NOT re-introduce a Small-IN100 regression test for this filter without explicit V&V justification.