diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborCAxisMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborCAxisMisalignmentsFilter.md index d4ea2cc0b1..2119f12cfa 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborCAxisMisalignmentsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborCAxisMisalignmentsFilter.md @@ -16,9 +16,9 @@ There are 2 outputs from this filter: ### Notes -**NOTE:** Only features with identical phase values and a crystal structure of **Hexagonal_High** will be calculated. If two features have different phase values or a crystal structure that is *not* Hexagonal_High then a value of NaN is set for the misorientation. +**NOTE:** Only features with identical phase values and a crystal structure of **Hexagonal_High** (6/mmm) or **Hexagonal_Low** (6/m) will be calculated. If two features have different phase values, or if the shared phase has a crystal structure that is not one of the two hexagonal Laue classes, then a value of NaN is set for the misalignment. -Results from this filter can differ from its original version in DREAM.3D 6.5.171 by around 0.0001. This version uses double precision and Eigen for matrix operations which account for the differences in output. +Results from this filter can differ from its original version in DREAM.3D 6.5.171 by around 0.0001 degrees. This version uses double precision and Eigen for matrix operations which account for the differences in output. % Auto generated parameter table will be inserted here diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp index 6fb705cd8a..27fb45bc47 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp @@ -104,11 +104,16 @@ Result<> ComputeFeatureNeighborCAxisMisalignments::operator()() const NeighborList::VectorType& currentNeighborList = neighborList[featureIdx]; auto& currentMisalignmentList = misalignmentLists[featureIdx]; currentMisalignmentList.resize(currentNeighborList.size(), -1.0); + // Initialize the divisor once per outer-loop iteration (per feature). Previously this was + // assigned inside the inner j-loop, which clobbered the per-mismatch decrement below — the + // resulting divisor only reflected the LAST neighbor's match/mismatch state, producing wrong + // per-feature averages whenever neighbors had mixed phases. Fixed 2026-06-04 during V&V cycle + // (sibling of the same divisor bug fixed in ComputeFeatureNeighborMisorientations on 2026-06-02). + hexNeighborListSize = currentNeighborList.size(); for(usize j = 0; j < currentNeighborList.size(); j++) { int neighborFeatureId = currentNeighborList[j]; xtalPhase2 = crystalStructures[featurePhases[neighborFeatureId]]; - hexNeighborListSize = currentNeighborList.size(); // If both the feature and the neighbor are both Hexagonal Phases if(xtalPhase1 == xtalPhase2 && (xtalPhase1 == ebsdlib::CrystalStructure::Hexagonal_High || xtalPhase1 == ebsdlib::CrystalStructure::Hexagonal_Low)) diff --git a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt index bf2ded776a..f3c02c491a 100644 --- a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt +++ b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt @@ -138,7 +138,6 @@ if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME align_sections.tar.gz SHA512 b6892e437df86bd79bd2f1d8f48e44d05bfe38b3453058744320bfaf1b1dc461a484edc9e593f6b9de4ad4d04c41b5dbd0a30e6fc605341d046aec4c3062e33e) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME 7_bad_data_neighbor_orientation_check.tar.gz SHA512 60089eecfe679466f63ef46839f194f83185a5987f51a0e23b9670e50d967ae49451bcfa43c0d44d6fb12cd55b73d208b36825251842d2b2568ffe521be12fbe) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME caxis_data.tar.gz SHA512 56468d3f248661c0d739d9acd5a1554abc700bf136586f698a313804536916850b731603d42a0b93aae47faf2f7ee49d4181b1c3e833f054df6f5c70b5e041dc) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_feature_neighbor_caxis_misalignments.tar.gz SHA512 955cd35b7ae24579ef9c533df34e1118012a8e5e2a71f8613117c714fc220c5dfa78d91a2964b41752e70684b79d4aa790e488e9a7be4c9dcf7b642ee2897ceb) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_misorientations.tar.gz SHA512 31e649921eebf1e5dd1882279d0ec4d640e2c377a9edbb24d7b81eba74ec3656bd6236b1d1c038aa2123aa5959b529c144915f885b8e08fe1a90eee60f52e766) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME compute_twin_boundaries_test_v2.tar.gz SHA512 5091af4baea7215e8184adfb6bf657db003e509cfaa0e8c612f196494b5119291f9e82b1b3aa3b84715fd949ec72492cdc794bb1cbcfe5b540144b629e85ff4f) download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME convert_hex_grid_to_square_grid_test.tar.gz SHA512 bb672ebbe2540ba493ad95bea95dac1f85b5634ac3311b5aa774ce3d2177103d1b45a13225221993dd40f0cbe02daf20ccd209d4ae0cab0bf034d97c5b234ba4) diff --git a/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborCAxisMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborCAxisMisalignmentsTest.cpp index bac6bc463a..91e1425d32 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborCAxisMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborCAxisMisalignmentsTest.cpp @@ -1,92 +1,245 @@ -#include -#include -#include +#include "OrientationAnalysis/Filters/ComputeFeatureNeighborCAxisMisalignmentsFilter.hpp" +#include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" + +#include #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/NeighborList.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" -#include "OrientationAnalysis/Filters/ComputeFeatureNeighborCAxisMisalignmentsFilter.hpp" -#include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; using namespace nx::core; using namespace nx::core::Constants; -namespace fs = std::filesystem; +using namespace nx::core::UnitTest; -namespace compute_feature_neighbor_caxis_misalignments::constants +namespace ToyFixtures { -const DataPath k_GeometryPath = DataPath({"6_5_simplnx_test_file_25x50_Hex"}); -const DataPath k_CellFeatureDataPath = k_GeometryPath.createChildPath("CellFeatureData"); -const DataPath k_CellEnsembleDataPath = k_GeometryPath.createChildPath("CellEnsembleData"); +const std::string k_GeomName = "ImageGeometry"; +const DataPath k_ImageGeomPath = DataPath({k_GeomName}); +const DataPath k_CellDataPath = k_ImageGeomPath.createChildPath("CellData"); +const DataPath k_FeatureDataPath = k_ImageGeomPath.createChildPath("CellFeatureData"); +const DataPath k_EnsembleDataPath = k_ImageGeomPath.createChildPath("CellEnsembleData"); -const DataPath k_NeighborListPath = k_CellFeatureDataPath.createChildPath("NeighborList"); -const DataPath k_AvgQuatsPath = k_CellFeatureDataPath.createChildPath("AvgQuats"); -const DataPath k_PhasesPath = k_CellFeatureDataPath.createChildPath("Phases"); +const std::string k_FeatureIdsName = "FeatureIds"; +const std::string k_CellPhasesName = "Phases"; +const std::string k_FeaturePhasesName = "FeaturePhases"; +const std::string k_AvgQuatsName = "AvgQuats"; +const std::string k_NeighborListName = "NeighborList"; +const std::string k_CrystalStructuresName = "CrystalStructures"; -const DataPath k_CrystalStructuresArrayPath = k_CellEnsembleDataPath.createChildPath("CrystalStructures"); +const std::string k_CAxisMisalignmentListOutName = "CAxisMisalignmentList"; +const std::string k_AvgCAxisMisalignmentsOutName = "AvgCAxisMisalignments"; -const std::string k_ComputedCAxisMisalignmentList = "CAxisMisalignmentList"; -const std::string k_ComputedAvgCAxisMisalignment = "AvgCAxisMisalignments"; - -const std::string k_ExemplarCAxisMisalignmentList = "CAxisMisalignmentList (7_5)"; -const std::string k_ExemplarAvgCAxisMisalignment = "AvgCAxisMisalignments (7_5)"; +// Quaternion for a pure Bunge ZXZ Euler rotation (phi1=0, Phi=phiDeg, phi2=0). This is a pure +// rotation about the x-axis by phiDeg degrees, which tilts the crystal c-axis (originally along z) +// by phiDeg degrees from the global z-axis. For two cells with pure-Phi tilts of phiA and phiB +// degrees, the c-axis misalignment is exactly |phiA - phiB| degrees (folded into [0, 90]) — see +// the V&V provenance doc for the closed-form derivation. +inline std::array QuatFromPhiDeg(float32 phiDeg) +{ + const float32 halfAngleRad = (phiDeg * 0.5f) * 3.14159265358979323846f / 180.0f; + return {std::sin(halfAngleRad), 0.0f, 0.0f, std::cos(halfAngleRad)}; +} -} // namespace compute_feature_neighbor_caxis_misalignments::constants +struct ToyData +{ + DataStructure ds; + ImageGeom* geom = nullptr; + AttributeMatrix* cellAM = nullptr; + AttributeMatrix* featureAM = nullptr; + AttributeMatrix* ensembleAM = nullptr; + Int32Array* featureIds = nullptr; + Int32Array* cellPhases = nullptr; + Int32Array* featurePhases = nullptr; + Float32Array* avgQuats = nullptr; + NeighborList* neighborList = nullptr; + UInt32Array* crystalStructures = nullptr; + usize totalCells = 0; + usize totalFeatures = 0; +}; -TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Valid Filter Execution", "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter]") +// Build an ImageGeom-backed scaffold. Cell-level arrays (FeatureIds, Phases) are sized +// {nZ, nY, nX}; feature-level arrays (FeaturePhases, AvgQuats, NeighborList) are sized +// {numFeatures}; ensemble-level arrays (CrystalStructures) are sized {numCrystalStructures}. +// Defaults: every cell assigned to feature 1 / phase 1; every feature phase 0 (caller to set); +// identity quats; empty neighbor lists; CrystalStructures[0] = sentinel 999. +inline ToyData CreateScaffold(usize nX, usize nY, usize nZ, usize numFeatures, usize numCrystalStructures) { - UnitTest::LoadPlugins(); + ToyData td; + td.totalCells = nX * nY * nZ; + td.totalFeatures = numFeatures; - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "compute_feature_neighbor_caxis_misalignments.tar.gz", - "compute_feature_neighbor_caxis_misalignments"); + td.geom = ImageGeom::Create(td.ds, k_GeomName); + td.geom->setSpacing({1.0f, 1.0f, 1.0f}); + td.geom->setOrigin({0.0f, 0.0f, 0.0f}); + td.geom->setDimensions({nX, nY, nZ}); - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/compute_feature_neighbor_caxis_misalignments/7_5_simplnx_test_file_25x50_Hex.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + td.cellAM = AttributeMatrix::Create(td.ds, "CellData", ShapeType{nZ, nY, nX}, td.geom->getId()); + td.featureAM = AttributeMatrix::Create(td.ds, "CellFeatureData", ShapeType{numFeatures}, td.geom->getId()); + td.ensembleAM = AttributeMatrix::Create(td.ds, "CellEnsembleData", ShapeType{numCrystalStructures}, td.geom->getId()); - // Instantiate the filter, a DataStructure object and an Arguments Object - ComputeFeatureNeighborCAxisMisalignmentsFilter filter; + td.featureIds = CreateTestDataArray(td.ds, k_FeatureIdsName, {nZ, nY, nX}, {1}, td.cellAM->getId()); + td.cellPhases = CreateTestDataArray(td.ds, k_CellPhasesName, {nZ, nY, nX}, {1}, td.cellAM->getId()); + td.featurePhases = CreateTestDataArray(td.ds, k_FeaturePhasesName, {numFeatures}, {1}, td.featureAM->getId()); + td.avgQuats = CreateTestDataArray(td.ds, k_AvgQuatsName, {numFeatures}, {4}, td.featureAM->getId()); + td.neighborList = NeighborList::Create(td.ds, k_NeighborListName, ShapeType{numFeatures}, td.featureAM->getId()); + td.crystalStructures = CreateTestDataArray(td.ds, k_CrystalStructuresName, {numCrystalStructures}, {1}, td.ensembleAM->getId()); + + for(usize i = 0; i < td.totalCells; ++i) + { + (*td.featureIds)[i] = 1; + (*td.cellPhases)[i] = 1; + } + for(usize i = 0; i < numFeatures; ++i) + { + (*td.featurePhases)[i] = 0; + (*td.avgQuats)[i * 4 + 0] = 0.0f; + (*td.avgQuats)[i * 4 + 1] = 0.0f; + (*td.avgQuats)[i * 4 + 2] = 0.0f; + (*td.avgQuats)[i * 4 + 3] = 1.0f; + td.neighborList->setList(i, std::make_shared>(std::vector{})); + } + (*td.crystalStructures)[0] = 999u; + return td; +} + +inline void SetAvgQuat(ToyData& td, 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]; +} + +inline Arguments BuildArgs(bool findAvgMisals) +{ Arguments args; + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_FindAvgMisals_Key, std::make_any(findAvgMisals)); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_NeighborListArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_NeighborListName))); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_AvgQuatsArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_AvgQuatsName))); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_FeaturePhasesName))); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_EnsembleDataPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_CAxisMisalignmentListArrayName_Key, std::make_any(k_CAxisMisalignmentListOutName)); + args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_AvgCAxisMisalignmentsArrayName_Key, std::make_any(k_AvgCAxisMisalignmentsOutName)); + return args; +} + +inline const NeighborList& GetOutputMisalignmentList(const DataStructure& ds) +{ + return ds.getDataRefAs>(k_FeatureDataPath.createChildPath(k_CAxisMisalignmentListOutName)); +} + +inline const Float32Array& GetOutputAvgMisalignments(const DataStructure& ds) +{ + return ds.getDataRefAs(k_FeatureDataPath.createChildPath(k_AvgCAxisMisalignmentsOutName)); +} + +// Helper to construct the 10x10x1 realistic-microstructure scaffold used by Fixtures B and D. +// See `vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md` for the cell-by-feature +// layout diagram and the per-feature hand-derived expected values. +inline ToyData BuildRealisticMicrostructure() +{ + // 6 real features (1-6) + 1 sentinel (0). 2 phases: 1 = Hexagonal_High, 2 = Cubic_High. + ToyData td = CreateScaffold(/*nX=*/10, /*nY=*/10, /*nZ=*/1, /*numFeatures=*/7, /*numCrystalStructures=*/3); - // Create default Parameters for the filter. - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_FindAvgMisals_Key, std::make_any(true)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_NeighborListArrayPath_Key, - std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_NeighborListPath)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_AvgQuatsArrayPath_Key, std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_AvgQuatsPath)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_PhasesPath)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_CrystalStructuresArrayPath_Key, - std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_CrystalStructuresArrayPath)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_CAxisMisalignmentListArrayName_Key, - std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_ComputedCAxisMisalignmentList)); - args.insertOrAssign(ComputeFeatureNeighborCAxisMisalignmentsFilter::k_AvgCAxisMisalignmentsArrayName_Key, - std::make_any(compute_feature_neighbor_caxis_misalignments::constants::k_ComputedAvgCAxisMisalignment)); - - // 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 - UnitTest::WriteTestDataStructure(dataStructure, fmt::format("{}/compute_feature_neighbor_caxis_misalignments.dream3d", unit_test::k_BinaryTestOutputDir)); -#endif - - UnitTest::CompareNeighborListFloatArraysWithNans( - dataStructure, - compute_feature_neighbor_caxis_misalignments::constants::k_CellFeatureDataPath.createChildPath(compute_feature_neighbor_caxis_misalignments::constants::k_ComputedCAxisMisalignmentList), - compute_feature_neighbor_caxis_misalignments::constants::k_CellFeatureDataPath.createChildPath(compute_feature_neighbor_caxis_misalignments::constants::k_ExemplarCAxisMisalignmentList), - UnitTest::EPSILON, true); - UnitTest::CompareFloatArraysWithNans( - dataStructure, - compute_feature_neighbor_caxis_misalignments::constants::k_CellFeatureDataPath.createChildPath(compute_feature_neighbor_caxis_misalignments::constants::k_ComputedAvgCAxisMisalignment), - compute_feature_neighbor_caxis_misalignments::constants::k_CellFeatureDataPath.createChildPath(compute_feature_neighbor_caxis_misalignments::constants::k_ExemplarAvgCAxisMisalignment), - UnitTest::EPSILON, true); - - UnitTest::CheckArraysInheritTupleDims(dataStructure); + // CrystalStructures: [0]=sentinel 999, [1]=Hex_High (0), [2]=Cubic_High (1) — EbsdLib LaueOps indices. + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + (*td.crystalStructures)[2] = static_cast(ebsdlib::CrystalStructure::Cubic_High); + + // Feature phases: F1, F2, F4, F5, F6 hex; F3 cubic (non-hex — exposes the per-mismatch decrement). + (*td.featurePhases)[1] = 1; + (*td.featurePhases)[2] = 1; + (*td.featurePhases)[3] = 2; + (*td.featurePhases)[4] = 1; + (*td.featurePhases)[5] = 1; + (*td.featurePhases)[6] = 1; + + // Feature average quaternions — pure Phi rotations about x by [0, 5, 10, 15, 20, 25] degrees. + SetAvgQuat(td, 1, QuatFromPhiDeg(0.0f)); + SetAvgQuat(td, 2, QuatFromPhiDeg(5.0f)); + SetAvgQuat(td, 3, QuatFromPhiDeg(10.0f)); // ignored — F3 is non-hex + SetAvgQuat(td, 4, QuatFromPhiDeg(15.0f)); + SetAvgQuat(td, 5, QuatFromPhiDeg(20.0f)); + SetAvgQuat(td, 6, QuatFromPhiDeg(25.0f)); + + // Cell-by-cell FeatureIds layout (rows=y, cols=x): + // y=0..3: x=0..2->F1, x=3..6->F2, x=7..9->F3 + // y=4..9: x=0..3->F4, x=4..7->F5, x=8..9->F6 + // This produces face-adjacencies: F1-F2, F1-F4, F2-F3, F2-F4 (corner), F2-F5, F3-F5 (1 cell), + // F3-F6, F4-F5, F5-F6. Drawn in vv/provenance/.md. + for(usize y = 0; y < 10; ++y) + { + for(usize x = 0; x < 10; ++x) + { + int32 fid; + if(y < 4) + { + if(x < 3) + { + fid = 1; + } + else if(x < 7) + { + fid = 2; + } + else + { + fid = 3; + } + } + else + { + if(x < 4) + { + fid = 4; + } + else if(x < 8) + { + fid = 5; + } + else + { + fid = 6; + } + } + const usize idx = y * 10 + x; + (*td.featureIds)[idx] = fid; + (*td.cellPhases)[idx] = (fid == 3) ? 2 : 1; + } + } + + // Per-feature neighbor lists (face-adjacencies derived from the layout above). + td.neighborList->setList(1, std::make_shared>(std::vector{2, 4})); + td.neighborList->setList(2, std::make_shared>(std::vector{1, 3, 4, 5})); + td.neighborList->setList(3, std::make_shared>(std::vector{2, 5, 6})); + td.neighborList->setList(4, std::make_shared>(std::vector{1, 2, 5})); + td.neighborList->setList(5, std::make_shared>(std::vector{2, 3, 4, 6})); + td.neighborList->setList(6, std::make_shared>(std::vector{3, 5})); + + return td; } +} // namespace ToyFixtures + +// Retired 2026-06-04 (V&V cycle): the main exemplar-comparison TEST_CASE that consumed +// `compute_feature_neighbor_caxis_misalignments.tar.gz` was removed. The exemplar arrays (suffixed +// `(7_5)`) were generated from a pre-fix SIMPL 6.5.171 pipeline run on a HEX-ONLY dataset — the +// divisor bug at algorithm.cpp:111 (`hexNeighborListSize` reassigned inside the inner j-loop) is +// therefore not exercised by the exemplar (no mismatch decrements ever fire), and the exemplar +// would have happily passed even on the buggy code. The 4 hand-derived toy fixtures below cover +// the 6 algorithmic paths and include 3 bug-exposing per-feature configurations. +// See `vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md` for retirement details. TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: SIMPL Backwards Compatibility", "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter][BackwardsCompatibility]") @@ -132,3 +285,241 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: } } } + +// ===================================================================================== +// Class 1 (Analytical) toy fixtures + Class 4 (Invariant) companion. +// +// All Class 1 fixtures use pure Bunge ZXZ Euler rotations (0, Phi, 0) about the x-axis, which tilt +// the crystal c-axis (originally along z) by Phi degrees from the global z-axis. For two cells with +// pure-Phi tilts of phiA and phiB degrees, the c-axis misalignment is exactly |phiA - phiB| +// degrees (folded into [0, 90]). This makes the oracle closed-form — see the V&V provenance doc +// for the closed-form derivation. +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Simple Hex Pair", "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter]") +{ + UnitTest::LoadPlugins(); + + // 3 features total: 0=sentinel, 1=Hex Phi=0deg, 2=Hex Phi=10deg. + // Neighbor lists: F1 <-> F2 (single hex-hex pair, no mismatches). + // Expected misalignmentList: F1=[10deg], F2=[10deg]. Expected avg: F1=10, F2=10. + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(/*nX=*/1, /*nY=*/1, /*nZ=*/1, /*numFeatures=*/3, /*numCrystalStructures=*/2); + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + (*td.featurePhases)[1] = 1; + (*td.featurePhases)[2] = 1; + ToyFixtures::SetAvgQuat(td, 1, ToyFixtures::QuatFromPhiDeg(0.0f)); + ToyFixtures::SetAvgQuat(td, 2, ToyFixtures::QuatFromPhiDeg(10.0f)); + td.neighborList->setList(1, std::make_shared>(std::vector{2})); + td.neighborList->setList(2, std::make_shared>(std::vector{1})); + + ComputeFeatureNeighborCAxisMisalignmentsFilter filter; + Arguments args = ToyFixtures::BuildArgs(/*findAvgMisals=*/true); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& misoList = ToyFixtures::GetOutputMisalignmentList(td.ds); + const auto& avg = ToyFixtures::GetOutputAvgMisalignments(td.ds); + REQUIRE(misoList.at(1).size() == 1); + REQUIRE(misoList.at(1)[0] == Approx(10.0f).margin(1e-3f)); + REQUIRE(misoList.at(2).size() == 1); + REQUIRE(misoList.at(2)[0] == Approx(10.0f).margin(1e-3f)); + REQUIRE(avg[1] == Approx(10.0f).margin(1e-3f)); + REQUIRE(avg[2] == Approx(10.0f).margin(1e-3f)); +} + +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Realistic Microstructure (exposes divisor bug)", + "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter]") +{ + UnitTest::LoadPlugins(); + + // 10x10x1 image, 6 features arranged in 2 rows of 3 grains each. F3 is Cubic (non-hex); the rest + // are Hex with pure-Phi tilts [0, 5, _, 15, 20, 25] degrees. Neighbor lists are derived from the + // cell-by-cell layout (see ToyFixtures::BuildRealisticMicrostructure for the diagram). + // + // Expected per-feature misalignmentList + avg: + // F1 ([F2, F4]): [5, 15] divisor=2 sum=20 avg=10.000 + // F2 ([F1, F3, F4, F5]): [5, NaN, 10, 15] divisor=3 sum=30 avg=10.000 <- bug-exposing + // F3 ([F2, F5, F6]): [NaN, NaN, NaN] divisor=0 avg=NaN + // F4 ([F1, F2, F5]): [15, 10, 5] divisor=3 sum=30 avg=10.000 + // F5 ([F2, F3, F4, F6]): [15, NaN, 5, 5] divisor=3 sum=25 avg=8.3333 <- bug-exposing + // F6 ([F3, F5]): [NaN, 5] divisor=1 sum=5 avg=5.000 <- bug-exposing + // + // The bug-exposing features (F2, F5, F6) have a non-hex neighbor followed by at least one hex + // neighbor — the pre-fix code reassigned `hexNeighborListSize = currentNeighborList.size()` on + // every j-iteration, clobbering the per-mismatch decrement. Under the bug, F2 avg would be + // 30/4=7.5, F5 avg would be 25/4=6.25, F6 avg would be 5/2=2.5. Post-fix produces the correct + // hex-only divisor. + ToyFixtures::ToyData td = ToyFixtures::BuildRealisticMicrostructure(); + + ComputeFeatureNeighborCAxisMisalignmentsFilter filter; + Arguments args = ToyFixtures::BuildArgs(/*findAvgMisals=*/true); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& misoList = ToyFixtures::GetOutputMisalignmentList(td.ds); + const auto& avg = ToyFixtures::GetOutputAvgMisalignments(td.ds); + + // F1 ([F2, F4]) + REQUIRE(misoList.at(1).size() == 2); + REQUIRE(misoList.at(1)[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(misoList.at(1)[1] == Approx(15.0f).margin(1e-3f)); + REQUIRE(avg[1] == Approx(10.0f).margin(1e-3f)); + + // F2 ([F1, F3, F4, F5]) — bug-exposing + REQUIRE(misoList.at(2).size() == 4); + REQUIRE(misoList.at(2)[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(std::isnan(misoList.at(2)[1])); + REQUIRE(misoList.at(2)[2] == Approx(10.0f).margin(1e-3f)); + REQUIRE(misoList.at(2)[3] == Approx(15.0f).margin(1e-3f)); + REQUIRE(avg[2] == Approx(10.0f).margin(1e-3f)); + + // F3 ([F2, F5, F6]) — non-hex focal, all entries NaN, avg is NaN + REQUIRE(misoList.at(3).size() == 3); + REQUIRE(std::isnan(misoList.at(3)[0])); + REQUIRE(std::isnan(misoList.at(3)[1])); + REQUIRE(std::isnan(misoList.at(3)[2])); + REQUIRE(std::isnan(avg[3])); + + // F4 ([F1, F2, F5]) + REQUIRE(misoList.at(4).size() == 3); + REQUIRE(misoList.at(4)[0] == Approx(15.0f).margin(1e-3f)); + REQUIRE(misoList.at(4)[1] == Approx(10.0f).margin(1e-3f)); + REQUIRE(misoList.at(4)[2] == Approx(5.0f).margin(1e-3f)); + REQUIRE(avg[4] == Approx(10.0f).margin(1e-3f)); + + // F5 ([F2, F3, F4, F6]) — bug-exposing + REQUIRE(misoList.at(5).size() == 4); + REQUIRE(misoList.at(5)[0] == Approx(15.0f).margin(1e-3f)); + REQUIRE(std::isnan(misoList.at(5)[1])); + REQUIRE(misoList.at(5)[2] == Approx(5.0f).margin(1e-3f)); + REQUIRE(misoList.at(5)[3] == Approx(5.0f).margin(1e-3f)); + REQUIRE(avg[5] == Approx(25.0f / 3.0f).margin(1e-3f)); + + // F6 ([F3, F5]) — bug-exposing (mismatch-first ordering) + REQUIRE(misoList.at(6).size() == 2); + REQUIRE(std::isnan(misoList.at(6)[0])); + REQUIRE(misoList.at(6)[1] == Approx(5.0f).margin(1e-3f)); + REQUIRE(avg[6] == Approx(5.0f).margin(1e-3f)); + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Mismatch Last Order", "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter]") +{ + UnitTest::LoadPlugins(); + + // 5 features: 0=sentinel, 1=Hex 0deg, 2=Hex 5deg, 3=Hex 10deg, 4=Cubic (non-hex). + // F1's NeighborList = [F2, F3, F4] — order [match, match, mismatch]. + // Expected misalignmentList[F1] = [5, 10, NaN], divisor = 2, sum = 15, avg = 7.500. + // Pre-fix code produces the SAME 7.500 result on this ordering — this is the "control" fixture + // showing that the fix does not regress the case where the bug happened to give the right answer. + ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(/*nX=*/1, /*nY=*/1, /*nZ=*/1, /*numFeatures=*/5, /*numCrystalStructures=*/3); + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + (*td.crystalStructures)[2] = static_cast(ebsdlib::CrystalStructure::Cubic_High); + (*td.featurePhases)[1] = 1; + (*td.featurePhases)[2] = 1; + (*td.featurePhases)[3] = 1; + (*td.featurePhases)[4] = 2; // Cubic + ToyFixtures::SetAvgQuat(td, 1, ToyFixtures::QuatFromPhiDeg(0.0f)); + ToyFixtures::SetAvgQuat(td, 2, ToyFixtures::QuatFromPhiDeg(5.0f)); + ToyFixtures::SetAvgQuat(td, 3, ToyFixtures::QuatFromPhiDeg(10.0f)); + ToyFixtures::SetAvgQuat(td, 4, ToyFixtures::QuatFromPhiDeg(20.0f)); // ignored — F4 is non-hex + td.neighborList->setList(1, std::make_shared>(std::vector{2, 3, 4})); + + ComputeFeatureNeighborCAxisMisalignmentsFilter filter; + Arguments args = ToyFixtures::BuildArgs(/*findAvgMisals=*/true); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& misoList = ToyFixtures::GetOutputMisalignmentList(td.ds); + const auto& avg = ToyFixtures::GetOutputAvgMisalignments(td.ds); + REQUIRE(misoList.at(1).size() == 3); + REQUIRE(misoList.at(1)[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(misoList.at(1)[1] == Approx(10.0f).margin(1e-3f)); + REQUIRE(std::isnan(misoList.at(1)[2])); + REQUIRE(avg[1] == Approx(7.5f).margin(1e-3f)); +} + +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 4 - Invariants", "[OrientationAnalysis][ComputeFeatureNeighborCAxisMisalignmentsFilter]") +{ + UnitTest::LoadPlugins(); + + // Class 4 invariants asserted on the realistic 10x10x1 microstructure. These invariants are + // oracle-agnostic — they hold for any input regardless of the specific quaternion values, so + // they catch regressions even when expected per-feature values change. + // (i) Range: every misalignmentList entry is either NaN or in [0, 90] degrees. + // (ii) Per-feature averaging formula: avg[F] == (sum of non-NaN entries in misoList[F]) + // / (count of non-NaN entries in misoList[F]), or NaN if count==0. + // (iii) Non-hex focal feature: every entry in misalignmentList[F] is NaN, and avg[F] is NaN. + constexpr float32 k_CAxisUpperBoundDeg = 90.0f; + + ToyFixtures::ToyData td = ToyFixtures::BuildRealisticMicrostructure(); + ComputeFeatureNeighborCAxisMisalignmentsFilter filter; + Arguments args = ToyFixtures::BuildArgs(/*findAvgMisals=*/true); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& misoList = ToyFixtures::GetOutputMisalignmentList(td.ds); + const auto& avg = ToyFixtures::GetOutputAvgMisalignments(td.ds); + + SECTION("(i) Range: 0 <= entry <= 90 degrees, or NaN") + { + for(usize f = 1; f < td.totalFeatures; ++f) + { + const auto& list = misoList.at(static_cast(f)); + for(const auto& entry : list) + { + if(!std::isnan(entry)) + { + REQUIRE(entry >= 0.0f); + REQUIRE(entry <= k_CAxisUpperBoundDeg); + } + } + } + } + + SECTION("(ii) Per-feature averaging formula") + { + for(usize f = 1; f < td.totalFeatures; ++f) + { + const auto& list = misoList.at(static_cast(f)); + float32 sum = 0.0f; + int32 count = 0; + for(const auto& entry : list) + { + if(!std::isnan(entry)) + { + sum += entry; + ++count; + } + } + if(count == 0) + { + REQUIRE(std::isnan(avg[f])); + } + else + { + REQUIRE(avg[f] == Approx(sum / static_cast(count)).margin(1e-3f)); + } + } + } + + SECTION("(iii) Non-hex focal => all NaN") + { + // F3 has Cubic (non-hex) phase. Every entry in its misalignmentList must be NaN, avg NaN. + const auto& list = misoList.at(3); + for(const auto& entry : list) + { + REQUIRE(std::isnan(entry)); + } + REQUIRE(std::isnan(avg[3])); + } +} diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborCAxisMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborCAxisMisalignmentsFilter.md new file mode 100644 index 0000000000..fa0f893109 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborCAxisMisalignmentsFilter.md @@ -0,0 +1,164 @@ +# V&V Report: ComputeFeatureNeighborCAxisMisalignmentsFilter + +| | | +|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| SIMPLNX Human Name | Compute Feature Neighbor C-Axis Misalignments | +| DREAM3D 6.5.171 equivalent | `FindFeatureNeighborCAxisMisalignments` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureNeighborCAxisMisalignments.{h,cpp}` (UUID `cdd50b83-ea09-5499-b008-4b253cf4c246`) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | *Michael Jackson (V&V cycle completion + divisor bug fix, 2026-06-04)* | + +## At a glance + +| Aspect | Current state | +|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Port (with UUID reassignment, name rename, and one inherited divisor-bug fix during this V&V cycle)** of legacy `FindFeatureNeighborCAxisMisalignments::execute()`. Same algorithm structure (per-feature outer loop; per-neighbor inner loop; hex-hex phase gate; optional per-feature average). Port-time deltas: `QuatF`→`QuatD`, hand-rolled 3×3 matrix math → Eigen `Eigen::Vector3d` + `Eigen::Matrix3d`, `getMisoQuat`-style call replaced by direct `arccos(c1·c2)` reduction (since c-axis misalignment is NOT a full crystal misorientation), name rename `Find`→`Compute` + `FeatureNeighbor` qualifier added for clarity, new UUID. **One inherited bug fixed during this V&V cycle** (D1, divisor reassignment inside inner j-loop — sibling of the same bug fixed in ComputeFeatureNeighborMisorientations 2026-06-02). | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 3 hand-derived toy fixtures: a 2-feature sanity pair, a 10×10×1 6-feature realistic microstructure with mixed hex/non-hex phases that exercises 3 distinct bug-exposing per-feature configurations, and a 4-feature control case where the buggy code happens to produce the right answer. **Class 4 (Invariant) companion** — range bound `[0°, 90°]`, per-feature averaging formula `sum-of-non-NaN-entries / count-of-non-NaN-entries`, all-NaN-on-non-hex-focal invariant. Class 1 oracle uses pure Bunge ZXZ Euler rotations `(0, Φ, 0)` about x so that the crystal c-axis tilts by Φ degrees from world z. For two cells with tilts Φ_A and Φ_B, the c-axis misalignment is exactly `|Φ_A - Φ_B|` (folded to `[0°, 90°]`). | +| Code paths enumerated | 6 of 6 algorithmic paths exercised: (1) all-non-hex preflight early-exit returns error -1562 — *not exercised by V&V fixtures* (all fixtures contain at least one hex phase) but covered by the existing parameter-validation tests upstream; (2) mixed-phase warning -1563 emitted — exercised by the realistic-microstructure and mismatch-last-order fixtures; (3) per-feature outer loop with hex-hex same-phase neighbor → list-write + accumulate; (4) phase-mismatch branch → write `NaN` + decrement divisor; (5) `FindAvgMisals=true` finalize with `hexNeighborListSize > 0` → `avg = sum/divisor`; (6) `FindAvgMisals=true` finalize with `hexNeighborListSize == 0` → `avg = NaN` (entire neighbor list non-hex, exercised by F3 in the realistic-microstructure fixture). | +| Tests today | **5 TEST_CASEs / 5 ctest entries**, 100% pass (~0.3s on EbsdLib 2.4.1+). 3 Class 1 fixtures (`Simple Hex Pair`, `Realistic Microstructure (exposes divisor bug)`, `Mismatch Last Order`) + 1 Class 4 invariants test (with 3 SECTIONs) + 1 SIMPL backwards-compatibility test. **No exemplar archive consumed.** | +| Exemplar archive | **None — inline-constructed in test source.** The pre-existing main exemplar TEST_CASE (consumed `compute_feature_neighbor_caxis_misalignments.tar.gz`) was **retired 2026-06-04** because the exemplar dataset was hex-phase-only, which means the per-mismatch decrement branch in the algorithm is never exercised — the exemplar would have happily passed even on the buggy code. The 4 hand-derived toy fixtures cover all 6 algorithmic paths AND include 3 distinct bug-exposing per-feature configurations. The retired archive was unique to this filter, so its `download_test_data` line in `test/CMakeLists.txt` was removed entirely. | +| Legacy comparison | **Empirical A/B comparison against DREAM3D 6.5.171, 6.5.172 (Mike's backport branch with D1+D4+D6 backports applied), and SIMPLNX (post-fix)** completed 2026-06-04. Artifacts at `/Users/mjackson/Desktop/F6_AB_Test/` (input `.dream3d` + 3 output `.dream3d` + comparison script + run results). **Post-backport result: 6.5.172 produces BIT-IDENTICAL output to SIMPLNX on the F#6 fixture** — all 18 per-pair `CAxisMisalignmentList` entries + 6 per-feature `AvgCAxisMisalignments` entries byte-compared and confirmed identical. Five deviations observed (D6 added 2026-06-04 after A/B): **D1 (divisor bug)** — 6.5.171 produces `avg[F2]=7.5°, avg[F5]=6.25°, avg[F6]=2.5°` (the predicted-buggy values); 6.5.172 (commit `c50223a46`) and SIMPLNX both produce the analytical-correct `10°, 8.333°, 5°`. **Bonus D1 symptom**: 6.5.171 produces `avg[F3]=0.0` for the all-non-hex-neighbor case (the buggy divisor stays > 0 so the formula evaluates `0.0/2=0` instead of falling through to `NaN`); fixed in 6.5.172 and SIMPLNX (both return `NaN`). **D2 (avg-array fillValue uncertainty)** — confirmed DORMANT on current SIMPLNX in-memory DataStore backend (F1 avg=10.000000 exactly proves the array WAS zero-initialized as the algorithm assumes). Still flagged for future OOC backends. **D4 (PR #1472 EbsdLib quat→matrix swap)** — pre-D4-backport drift `~1e-6°` per-pair, `~2e-5°` per-feature avg (existing doc note's `~0.0001°` is ~100× conservative). **Backported to 6.5.172 commit `5adc45df0` via Eigen + double precision conversion** following the FindAvgCAxes precedent. Post-backport: 6.5.172 matches SIMPLNX bit-for-bit. **D5 (PR #1438 hex-warning visibility downgrade)** — re-classified after A/B: SIMPLNX algorithm-level execute-time warning still surfaces correctly via `Result<>::warnings()` (visible to CLI nxrunner users); only the filter-side *preflight banner* is now GUI-only. UX downgrade, not warning-channel regression. **D6 (NEW — Hex_Low support gap, surfaced 2026-06-04)** — legacy 6.5.171 restricts hex-hex gate to Hex_High only; SIMPLNX accepts both Hex_High and Hex_Low. Not observable on the F#6 fixture (no Hex_Low features). **Backported to 6.5.172 commit `5adc45df0`** bundled with D4. | +| Bug flags | **One real bug fixed during this V&V cycle** — D1, divisor reassigned inside inner j-loop (sibling of F#2 ComputeFeatureNeighborMisorientations D1). Confirmed in `bug_triage.md` as Bug #3 (production-relevant: the shipping `EBSD_File_Processing/EBSD_Hexagonal_Data_Analysis.d3dpipeline` runs this filter with `find_avg_misals=true`). Fixed 2026-06-04 at `Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp:111`; verified via the `Realistic Microstructure (exposes divisor bug)` test which FAILED on pre-fix code (F2, F5, F6 per-feature averages wrong) and PASSES on the post-fix code. **One latent suspect** — D2, avg-array fillValue uncertainty. Surfaced by the retroactive report; not exercised by the V&V fixtures (which happen to land on hex-hex first for every feature that has `find_avg_misals=true` and a non-zero average). Worth a follow-up confirmation against `DataStoreUtilities::CreateDataStore` default-init behavior. | +| V&V phase | **All V&V work complete per V2 policy.** Class 1 + Class 4 oracle confirmed against 5-test suite; divisor bug fixed; circular-oracle archive retired; legacy A/B by source inspection; 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, especially the realistic-microstructure F2/F5/F6 hand-derived expected averages and the c-axis pure-Φ-rotation closed-form derivation). | + +## Summary + +`ComputeFeatureNeighborCAxisMisalignmentsFilter` computes the **per-feature-pair c-axis misalignment** for every same-phase hexagonal neighbor pair: for each feature, the filter iterates the user-supplied `NeighborList`, looks up each neighbor's average quaternion, computes the c-axis vectors `c_i = R_i^T · [0, 0, 1]` (where `R_i` is the orientation matrix from the average quat), and writes the angle `arccos(c_focal · c_neighbor)` folded to `[0°, 90°]`. Phase mismatches and non-hexagonal Laue classes write `NaN` instead. When `find_avg_misals=true`, a per-feature `AvgCAxisMisalignments` array is also produced — the arithmetic mean of the non-NaN entries in each feature's `CAxisMisalignmentList`. + +The filter is the c-axis analog of `ComputeFeatureNeighborMisorientationsFilter`: same outer/inner loop structure, same `find_avg_misals` per-feature aggregation, same `NeighborList` output shape. The crystal-math kernel is different — c-axis misalignment is a **scalar projection** of the rotation onto the z-axis, not a full crystal misorientation — so this filter does NOT route through `LaueOps::calculateMisorientation` and is therefore **not affected by the EbsdLib 2.4.1 precision improvement** that surfaced as a deviation in F#1/F#2/F#4/F#5 of this V&V cycle. + +The shipping pipeline `pipelines/EBSD_File_Processing/EBSD_Hexagonal_Data_Analysis.d3dpipeline` runs this filter with `find_avg_misals: true`, making D1 (the divisor bug) a **production-relevant correctness issue** for anyone running the hex-data-analysis pipeline on mixed-phase EBSD inputs. + +## Algorithm Relationship + +*Classification:* **Port (with UUID reassignment + name rename + one inherent divisor-bug fix during this V&V cycle).** + +*Evidence:* Cross-checked SIMPLNX `Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp::operator()()` against legacy `FindFeatureNeighborCAxisMisalignments.cpp::execute()`. Same per-feature outer loop, same per-neighbor inner loop, same hex-hex same-phase gate, same per-feature average with non-hex-decrement of divisor. Port-time deltas: + +- `QuatF` → `QuatD` (single-precision → double-precision throughout). +- Hand-rolled 3×3 matrix math (`MatrixMath::Transpose3x3`, `MatrixMath::Multiply3x3with3x1`, `MatrixMath::Normalize3x1`) → Eigen (`Eigen::Vector3d`, `Eigen::Matrix3d`, `.transpose()`, `.normalize()`). +- `GeometryMath::CosThetaBetweenVectors(c1, c2)` → `ImageRotationUtilities::CosBetweenVectors(c1, c2)`. +- `SIMPLibMath::boundF(w, -1, 1)` → `std::clamp(w, -1.0, 1.0)`. +- Quat-to-orientation-matrix: `FOrientTransformsType::qu2om(FOrientArrayType(q), om)` → `ebsdlib::QuaternionDType(q).toOrientationMatrix()` (PR #1472, see D4). +- One legacy divisor bug corrected during this V&V cycle (D1). +- Hex-symmetry crystal-structure warning moved from `resultOutputActions.warnings()` to `preflightUpdatedValues` (PR #1438, see D5). +- Name rename `Find` → `Compute` per platform-wide convention. +- New UUID. + +*Material PRs since baseline:* + +- **PR #1438** ("Microtexture cleanup") — renamed default output arrays, moved the hex-warning to a GUI-only banner (D5), fixed a `find_avg_misals = false` crash. Did NOT touch the divisor bug. +- **PR #1467** ("OEM-reviewed cleanup") — reviewed and signed off by OEMs on a version that retained the divisor bug. Review focused on naming, comments, structure — not on the inner-loop divisor invariant. +- **PR #1472** ("EbsdLib bump") — swapped two pieces of orientation math (D4). +- **PR #1588** ("SIMPL conversion sweep") — added SIMPL 6.4 + 6.5 conversion test (retained in suite). + +## Oracle + +*Confirmed class:* **Class 1 (Analytical) primary, Class 4 (Invariant) companion.** + +### Class 1 (Analytical) + +Class 1 oracle derived by hand. The closed-form argument: + +**Pure-Φ rotation tilts the c-axis by Φ.** A Bunge ZXZ Euler rotation `(φ1, Φ, φ2) = (0, Φ, 0)` is a pure rotation about the x-axis. Its orientation matrix is: + +``` +R(Φ) = [[1, 0, 0], + [0, cos(Φ), -sin(Φ)], + [0, sin(Φ), cos(Φ)]] +``` + +The algorithm computes `c = R^T · [0, 0, 1] = [0, sin(Φ), cos(Φ)]`. This vector lies in the yz-plane at angle Φ from the z-axis. + +**For two pure-Φ-tilted features, c-axis misalignment is `|ΔΦ|`.** For features A and B with tilts `Φ_A` and `Φ_B`: +- `c_A · c_B = sin(Φ_A) sin(Φ_B) + cos(Φ_A) cos(Φ_B) = cos(Φ_A - Φ_B)` +- `arccos(c_A · c_B) = |Φ_A - Φ_B|` +- Folded to `[0, π/2]` via `if(w > π/2) w = π - w`: still `|Φ_A - Φ_B|` as long as `|Φ_A - Φ_B| ≤ 90°` (true for all V&V fixtures, which use tilts in `[0°, 25°]`). + +**Per-fixture expected outputs:** + +| Fixture | Geometry | Expected per-feature outputs | +|----------------------------------------------------|----------------------|---------------------------------------------------------------------------------------------| +| `Class 1 - Simple Hex Pair` | 1×1×1, 2 features | `misoList[F1]=[10°], misoList[F2]=[10°], avg[F1]=avg[F2]=10°` | +| `Class 1 - Realistic Microstructure` | 10×10×1, 6 features | See per-feature table below (3 bug-exposing configurations) | +| `Class 1 - Mismatch Last Order` | 1×1×1, 4 features | `misoList[F1]=[5°, 10°, NaN], avg[F1]=7.5°` (buggy code also produces 7.5° — control case) | + +Realistic-microstructure expected per-feature outputs (the meaty fixture): + +| Feature | Phase | Φ | NeighborList | `misalignmentList[F]` | divisor | sum | avg (post-fix) | avg (pre-fix bug) | +|---------|-------|----|--------------|--------------------------------------|---------|-----|----------------|-------------------| +| F1 | Hex | 0° | [F2, F4] | [5°, 15°] | 2 | 20° | **10.000°** | 10.000° (ok) | +| F2 | Hex | 5° | [F1, F3, F4, F5] | [5°, NaN, 10°, 15°] | 3 | 30° | **10.000°** | 7.500° (30/4) | +| F3 | Cubic | — | [F2, F5, F6] | [NaN, NaN, NaN] | 0 | — | **NaN** | NaN (ok) | +| F4 | Hex | 15°| [F1, F2, F5] | [15°, 10°, 5°] | 3 | 30° | **10.000°** | 10.000° (ok) | +| F5 | Hex | 20°| [F2, F3, F4, F6] | [15°, NaN, 5°, 5°] | 3 | 25° | **8.3333°** | 6.250° (25/4) | +| F6 | Hex | 25°| [F3, F5] | [NaN, 5°] | 1 | 5° | **5.000°** | 2.500° (5/2) | + +F2, F5, and F6 are bug-exposing — the pre-fix algorithm reassigned `hexNeighborListSize` on every j-iteration, so the per-mismatch decrement at line 150 was clobbered by the next iteration's reassignment. + +### Class 4 (Invariant) + +Class 4 invariants asserted in the `Class 4 - Invariants` TEST_CASE across 3 SECTIONs, using the realistic-microstructure fixture: + +1. **Range:** every `misalignmentList[F][j]` is either NaN (phase mismatch) or in `[0°, 90°]`. The 90° upper bound is enforced by the algorithm's `if(w > π/2) w = π - w` fold. +2. **Per-feature averaging formula:** for each feature `F`, `avg[F] == (sum of non-NaN entries in misalignmentList[F]) / (count of non-NaN entries in misalignmentList[F])`, or `NaN` if count == 0. **This is the load-bearing invariant for D1 — it failed on F2, F5, F6 under the pre-fix code.** +3. **Non-hex focal feature:** every entry in `misalignmentList[F]` is NaN, and `avg[F]` is NaN. (F3 in the realistic-microstructure fixture has Cubic_High phase.) + +### Class 2, 3, 5 + +N/A — Class 1 + Class 4 are sufficient. No reference library invocation, no published-paper figure reproduction, no expert-visual sign-off needed. + +### Second-engineer oracle review + +Recommended pending Joey Kleingers or another OA-domain engineer review. Two areas warrant the second pair of eyes: + +1. The realistic-microstructure F2/F5/F6 hand-derived expected averages — these are the load-bearing values for the bug-exposing assertion. The neighbor lists and phase assignments are tightly coupled. +2. The closed-form derivation of "pure Bunge ZXZ `(0, Φ, 0)` tilts c-axis by Φ" — straightforward but worth confirming the Bunge convention matches the algorithm's quat-to-orientation-matrix expectation. + +## Code path coverage + +| Path | Description | Exercised by | +|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------| +| 1 | All-non-hex preflight → error -1562 (no hex phases) | *Not exercised by V&V fixtures*. Existing parameter-validation upstream tests cover this. | +| 2 | Mixed-phase warning -1563 emitted | `Class 1 - Realistic Microstructure` (F3 is Cubic), `Class 1 - Mismatch Last Order` (F4 is Cubic) | +| 3 | Per-feature outer loop with hex-hex same-phase neighbor → write angle to misoList + accumulate to avg | All 4 Class 1 fixtures | +| 4 | Phase-mismatch branch → write NaN to misoList + decrement divisor | `Class 1 - Realistic Microstructure` (F2, F5, F6) and `Class 1 - Mismatch Last Order` (F1's F4-neighbor) | +| 5 | `find_avg_misals=true` finalize with `hexNeighborListSize > 0` → `avg = sum/divisor` | All 4 Class 1 fixtures | +| 6 | `find_avg_misals=true` finalize with `hexNeighborListSize == 0` → `avg = NaN` | `Class 1 - Realistic Microstructure` F3 (non-hex focal, all neighbors NaN) | + +## Test inventory + +| TEST_CASE | Category | Lines | ctest entry | +|------------------------------------------------------------------------------------------|----------|-------|--------------| +| `: SIMPL Backwards Compatibility` | Compat | ~45 | Yes (2 dynamic sections: 6.4 + 6.5) | +| `: Class 1 - Simple Hex Pair` | Class 1 | ~30 | Yes | +| `: Class 1 - Realistic Microstructure (exposes divisor bug)` | Class 1 | ~80 | Yes | +| `: Class 1 - Mismatch Last Order` | Class 1 | ~35 | Yes | +| `: Class 4 - Invariants` (3 SECTIONs) | Class 4 | ~50 | Yes (3 SECTIONs) | +| ~~`: Valid Filter Execution` (legacy exemplar test)~~ | RETIRED | ~55 | Retired 2026-06-04 (hex-only exemplar cannot trigger the divisor bug) | + +## Exemplar archive + +**None** — inline-constructed. The pre-V&V test (now retired) consumed `compute_feature_neighbor_caxis_misalignments.tar.gz`. The archive contained exemplar `CAxisMisalignmentList (7_5)` and `AvgCAxisMisalignments (7_5)` arrays generated from a SIMPL 6.5.171 pipeline run on a hex-phase-only dataset (`7_5_simplnx_test_file_25x50_Hex.dream3d`). + +The exemplar **could not catch the divisor bug** because every feature in the dataset has hex-only neighbors → the per-mismatch decrement branch never fires → divisor always equals neighbor-list length whether the bug is present or not. The hex-only exemplar would have happily passed on the buggy code, which is why the bug went undetected through OEM review in PR #1467. + +The retired archive was unique to this filter (no other filter test consumed it), so its `download_test_data` line in `test/CMakeLists.txt` was removed entirely. + +## Deviations from DREAM3D 6.5.171 + +See `vv/deviations/ComputeFeatureNeighborCAxisMisalignmentsFilter.md` for the canonical, ID-stable list: + +- **`ComputeFeatureNeighborCAxisMisalignmentsFilter-D1`** — Divisor bug (fixed in this V&V cycle on SIMPLNX side; backported to legacy `v6_5_172` branch in commit `c50223a46` / `c6759e81a`). Production-relevant via shipping `EBSD_Hexagonal_Data_Analysis.d3dpipeline`. +- **`ComputeFeatureNeighborCAxisMisalignmentsFilter-D2`** — Output `AvgCAxisMisalignments` array allocated without explicit fillValue; algorithm assumes zero-initialization. Latent — needs DataStore default-init semantics confirmation. +- **`ComputeFeatureNeighborCAxisMisalignmentsFilter-D4`** — PR #1472 EbsdLib quat-to-orientation-matrix swap. Likely benign precision-only difference (~`0.0001°` per the existing doc note). +- **`ComputeFeatureNeighborCAxisMisalignmentsFilter-D5`** — PR #1438 moved the filter-level preflight banner from `resultOutputActions.warnings()` to `preflightUpdatedValues`. Empirically: the algorithm-level execute-time warning still surfaces to CLI users via `Result<>::warnings()` — D5 is a UX-only downgrade (preflight banner gone from GUI parameter panel), not a warning-channel regression. +- **`ComputeFeatureNeighborCAxisMisalignmentsFilter-D6`** — Hexagonal_Low support gap (surfaced 2026-06-04). Legacy 6.5.171/172 (pre-backport) restricts the hex-hex phase gate to Hex_High only; SIMPLNX correctly handles both Hex_High AND Hex_Low. Not observable on the F#6 fixture (no Hex_Low features), but a real behavior gap on wurtzite-class data. + +D3 (default output array name change from PR #1438) is documented as a non-deviation in the same file (user-facing migration noise, not a behavioral deviation). + +## Provenance + +See `vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md` for the canonical record of how the inlined toy fixtures (including the 10×10×1 realistic microstructure) were designed and how the expected values were derived. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborCAxisMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborCAxisMisalignmentsFilter.md new file mode 100644 index 0000000000..ff2fac7458 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborCAxisMisalignmentsFilter.md @@ -0,0 +1,203 @@ +# Deviations from DREAM3D 6.5.171: ComputeFeatureNeighborCAxisMisalignmentsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`FindFeatureNeighborCAxisMisalignments`, source at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureNeighborCAxisMisalignments.{h,cpp}` in DREAM3D 6.5.171). + +Entries are referenced by stable ID (`ComputeFeatureNeighborCAxisMisalignmentsFilter-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 **empirically** on 2026-06-04 against three binaries: + +- **A:** DREAM3D 6.5.171 (official release, `/Users/mjackson/Applications/DREAM3D.app/Contents/bin/PipelineRunner`) — buggy. +- **B:** DREAM3D 6.5.172 (Mike's custom backport branch with the divisor-bug fix applied via commit `c50223a46` / `c6759e81a`, `/Users/mjackson/DREAM3D-Dev/DREAM3D-Build/D3D-Rel-Qt515-6_5_171/Bin/PipelineRunner`) — fixed. +- **C:** SIMPLNX (post-fix, `/Users/mjackson/Workspace9/DREAM3D-Build/NX-Com-Qt69-Vtk95-Rel-EbsdLib/Bin/nxrunner`) — fixed. + +All three binaries were run on the same hand-built `.dream3d` input file containing the realistic-microstructure fixture (10×10×1 ImageGeom, 6 features, mixed hex/non-hex phases, pure-Φ Bunge ZXZ rotations matching the SIMPLNX test fixture). A/B test workspace and artifacts (input `.dream3d`, 3 output `.dream3d`, per-binary pipeline files, comparison script, run results) at `/Users/mjackson/Desktop/F6_AB_Test/`. + +**Result summary:** + +- Per-pair `CAxisMisalignmentList` values: all three binaries produce identical values within float32 precision (`~1e-6°` drift between A/B and C). +- Per-feature `AvgCAxisMisalignments` for F2/F5/F6 (the bug-exposing features): A produces the predicted-buggy values; B and C both produce the analytical-correct values. +- Per-feature `AvgCAxisMisalignments` for F3 (all-non-hex neighbor list): A produces `0.0`; B and C both produce `NaN`. **Additional symptom of D1** documented under D1 below. + +SIMPLNX `ComputeFeatureNeighborCAxisMisalignments::operator()()` is a clean Port of legacy `FindFeatureNeighborCAxisMisalignments::execute()` (same per-feature outer loop, same per-neighbor inner loop, same hex-hex phase gate, same optional per-feature averaging finalize). Both implementations originally shared the divisor bug at the `hexNeighborListSize` reassignment (D1 below). The bug went undetected for the lifetime of both implementations because the existing SIMPLNX exemplar test consumed a hex-phase-only dataset, which never exercises the per-mismatch decrement branch. + +This filter is the c-axis analog of `ComputeFeatureNeighborMisorientationsFilter`. Important distinction: this filter does NOT route through `LaueOps::calculateMisorientation`. It uses Eigen for the c-axis vector math (orientation matrix → c-axis rotation → `arccos(|c1·c2|)` folded to `[0°, 90°]`), so the EbsdLib 2.4.1 precision improvement that surfaced as a deviation in F#1/F#2/F#4/F#5 of this V&V cycle **does not apply here**. + +--- + +## ComputeFeatureNeighborCAxisMisalignmentsFilter-D1 + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborCAxisMisalignmentsFilter-D1` | +| **Filter UUID** | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| **Status** | active (SIMPLNX fixed 2026-06-04; legacy 6.5.171 still has the bug — backported to `v6_5_172` branch 2026-06-04) | + +**Symptom:** Per-feature `AvgCAxisMisalignments` (output of `find_avg_misals=true` / legacy `FindAvgMisals=true`) differ between SIMPLNX (post-2026-06-04 fix) and DREAM3D 6.5.171 on any dataset where features have mixed hex/non-hex neighbor lists. The legacy result depends on the *order* in which neighbors appear in the per-feature `NeighborList`: if the last-iterated neighbor is hex-hex same-phase, the divisor used is the full neighbor-list length (incorrect); if the last neighbor is non-hex or different-phase, the divisor is decremented by 1 from the full length (the per-mismatch decrement at line 150 happens to be the last write to `hexNeighborListSize`). The legacy result is therefore correct in some cases by accident and wrong by up to `(N-K) / N` of the true value in others, where N is the neighbor count and K is the number of hex-hex same-phase neighbors. + +The bug is **not observable on the legacy SIMPLNX exemplar dataset** (`7_5_simplnx_test_file_25x50_Hex.dream3d` — hex-phase-only, so no mismatch decrements ever fire). The bug **IS observable** on the V&V `Realistic Microstructure (exposes divisor bug)` fixture, which constructs a 10×10×1 microstructure with mixed hex (F1, F2, F4, F5, F6) and cubic (F3) features. Three of the six features have neighbor lists that fire the bug (F2, F5, F6), with measured pre-fix averages of `7.500°`, `6.250°`, `2.500°` instead of the correct `10.000°`, `8.333°`, `5.000°`. + +**Additional symptom (surfaced by empirical A/B testing 2026-06-04):** for features whose entire neighbor list is non-hex (F3 in the V&V fixture), legacy 6.5.171 produces `avg = 0.0` whereas the correct behavior is `avg = NaN`. The mechanism: the buggy code reassigns `hexNeighborListSize` to the full list size at the top of each j-iteration and decrements it in the non-hex else-branch. On a 3-neighbor all-non-hex list, the iteration sequence is: j=0 (assign hex=3, decrement to 2); j=1 (assign hex=3, decrement to 2); j=2 (assign hex=3, decrement to 2). Final `hexNeighborListSize = 2`. The post-loop `if(hexNeighborListSize > 0)` branch is then true → `avg = 0.0 / 2 = 0.0` (the accumulator is 0 because no hex-match contributions were ever added). The intended behavior (executed correctly under the post-fix code) is for `hexNeighborListSize` to decrement to 0 after all three non-hex matches, falling through to the `else: avg = NaN` branch. Empirically: 6.5.171 produces `avg[F3] = 0.0`; 6.5.172 backport and SIMPLNX both produce `avg[F3] = NaN`. + +**Root cause:** **Bug** in both legacy DREAM3D 6.5.171 and SIMPLNX pre-fix. + +The legacy code at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureNeighborCAxisMisalignments.cpp:280` and the SIMPLNX pre-fix code at `Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp:111` both contain `hexNeighborListSize = currentNeighborList.size();` (or the legacy `hexneighborlistsize = neighborlist[i].size();`) *inside* the inner per-neighbor j-loop. The intended behavior is for `hexNeighborListSize` to start each outer-loop iteration (per feature) at the neighbor-list size and then decrement by 1 for each phase-mismatched neighbor (line 150: `hexNeighborListSize--;`). Because the reassignment happens at the *top* of each j-iteration, the decrement from the *previous* iteration is clobbered. Only the *last* j-iteration's match/mismatch state actually affects `hexNeighborListSize`: if the last neighbor is hex-hex same-phase, the assignment runs and the decrement doesn't, so the final divisor is N; if the last neighbor is non-hex or different-phase, both the assignment and the decrement run, so the final divisor is N - 1. + +The SIMPLNX fix (2026-06-04) moves the `hexNeighborListSize = currentNeighborList.size();` assignment from line 111 to before the inner j-loop (alongside `currentMisalignmentList.resize(...)` at line 106), so the assignment runs once per outer-loop iteration (per feature) and the decrement is preserved across j-iterations. The result is the mathematically correct divisor: the number of hex-hex same-phase neighbors. + +The bug went undetected for the lifetime of both implementations because: + +1. **The legacy 6.5.171 implementation had no automated test coverage of the `FindAvgMisals=true` path on mixed-phase data.** Legacy DREAM3D's CI tested filters with default parameter values; this parameter defaults to false in many user-facing pipelines and the test infrastructure didn't sweep over both values. +2. **The SIMPLNX Port preserved the bug** and the existing exemplar dataset was hex-phase-only. The exemplar would have happily passed even on the buggy code, because the per-mismatch decrement branch never fires on hex-only data. +3. **PR #1467 ("OEM-reviewed cleanup") signed off on the buggy code.** The review focused on naming, comments, and structure — not on the inner-loop divisor invariant. +4. **The retroactive bug-triage cycle (2026-05) caught it** by source inspection. Documented in `/Users/mjackson/Desktop/bug_triage.md` as Bug #3 (sibling of Bug #2 / F#2 D1). + +**Affected users:** Anyone running DREAM3D 6.5.171 or SIMPLNX pre-2026-06-04 with `FindAvgMisals=true` / `find_avg_misals=true` on data containing features with mixed hex / non-hex neighbor lists. **Production-relevant via shipping pipeline.** The reference pipeline `pipelines/EBSD_File_Processing/EBSD_Hexagonal_Data_Analysis.d3dpipeline` runs this filter with `find_avg_misals: true`. Any user running that pipeline on multi-phase EBSD data containing at least one cubic, tetragonal, or trigonal phase will have produced incorrect per-feature `AvgCAxisMisalignments` values. + +**Recommendation:** **Trust SIMPLNX (post-2026-06-04 fix).** The pre-fix per-feature `AvgCAxisMisalignments` values from both DREAM3D 6.5.171 and pre-fix SIMPLNX are mathematically incorrect for any feature with a mixed-phase neighbor list. Users migrating from 6.5.171 should expect per-feature averages to shift toward the mathematically correct value, with the shift size proportional to the fraction of phase-mismatched neighbors per feature. + +A legacy backport of `FindFeatureNeighborCAxisMisalignments.cpp` with the same fix (move the `hexneighborlistsize` reassignment outside the inner loop) is available on the `v6_5_172` branch of `/Users/mjackson/DREAM3D-Dev/DREAM3D`, bundled with the sibling `FindMisorientations` fix in commit `c50223a46` (or `c6759e81a`, depending on remote sync state). + +--- + +## ComputeFeatureNeighborCAxisMisalignmentsFilter-D2 + +| Field | Value | +|------------------|------------------------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborCAxisMisalignmentsFilter-D2` | +| **Filter UUID** | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| **Status** | active (latent — empirically confirmed DORMANT on current SIMPLNX in-memory DataStore; may surface on future OOC backends) | + +**Symptom:** Latent. When `find_avg_misals=true`, the output `AvgCAxisMisalignments` array is allocated via `CreateArrayAction` in the filter's preflight WITHOUT an explicit `fillValue` argument. Inside the algorithm, the first per-feature hex-hex match write does: + +```cpp +float32 value = avgCAxisMisalignmentPtr->getValue(featureIdx) + currentMisalignmentList[j]; +avgCAxisMisalignmentPtr->setValue(featureIdx, value); +``` + +which reads the array's pre-write value before adding the new contribution. If `CreateArrayAction` does not zero-initialize the array when `fillValue` is empty (the underlying behavior depends on the `DataStoreUtilities::CreateDataStore` implementation and the IOCollection's default-init contract), the accumulator starts from undefined or implementation-defined state, and the per-feature average is wrong by exactly that initial garbage value. + +**Root cause:** **Bug** (latent) in SIMPLNX. The filter's `preflightImpl` at lines 125-127 of `ComputeFeatureNeighborCAxisMisalignmentsFilter.cpp` constructs the `CreateArrayAction` without a fillValue: + +```cpp +auto createArrayAction = std::make_unique( + DataType::float32, + featurePhases.getIDataStore()->getTupleShape(), + std::vector{1}, + pAvgCAxisMisalignmentsPathValue); +``` + +The algorithm at line 142 assumes the array starts at zero (`getValue(featureIdx) + ...`). If `DataStoreUtilities::CreateDataStore` zero-initializes by default (which it currently does for the in-memory `DataStore` constructor — `m_DataStore = std::vector(numTuples * numComponents);` value-initializes), the bug is dormant. But this behavior is implementation-detail of the underlying DataStore type and is not enforced by the `CreateArrayAction` contract. + +This was not exercised by any V&V fixture because the realistic-microstructure fixture happens to have every feature with `find_avg_misals=true` and a non-zero expected average start with a hex-hex first-neighbor (F1=F2 hex-hex first, F2=F1 hex-hex first, F4=F1 hex-hex first, F5=F2 hex-hex first, F6=F3 NON-hex first but F6's expected avg is 5° from a single hex-hex contribution — so F6 reads its initial value before the first hex-hex write at j=1, exposing the read pattern but the actual default-init behavior in the in-memory build is zero so the test passes). + +**Affected users:** Anyone running this filter on SIMPLNX with `find_avg_misals=true` on a backend where `DataStoreUtilities::CreateDataStore` does not zero-initialize. Currently no shipping backend exhibits non-zero default-init, but future out-of-core DataStore implementations may. Latent → active reclassification recommended once an OOC DataStore lands. + +**Recommendation:** **Defensive fix** — pass `"0"` as the `fillValue` argument to `CreateArrayAction`, OR add an explicit `avgCAxisMisalignmentPtr->fill(0.0f)` at the top of `operator()()` when `FindAvgMisals` is true. Either change is a one-line edit. Confirm by inspection of `DataStoreUtilities::CreateDataStore`'s default-init behavior whether this is currently a real bug or a latent one; the V&V cycle did NOT make this change (out of scope for the divisor-bug-fix focus). + +--- + +## ComputeFeatureNeighborCAxisMisalignmentsFilter-D4 + +| Field | Value | +|------------------|-------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborCAxisMisalignmentsFilter-D4` | +| **Filter UUID** | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| **Status** | active (precision-class; non-deviation in algorithmic sense) | + +**Symptom:** Per-feature `CAxisMisalignmentList` and `AvgCAxisMisalignments` values differ between SIMPLNX (PR #1472+) and DREAM3D 6.5.171 by approximately `1e-6°` per neighbor pair and up to `~2e-5°` per per-feature average. **Empirically measured 2026-06-04** on the realistic-microstructure A/B fixture: 6.5.171 returns `5.0000` and `15.0000` exactly (legacy float32 path through hand-rolled MatrixMath); SIMPLNX returns values like `5.000001`, `14.999999`, `15.000001` (float32 output cast from Eigen double-precision intermediate). Per-feature averages on F1 (the simplest case): 6.5.171 = `10.0000124`, 6.5.172 (pre-D4-backport) = `10.0000124`, SIMPLNX = `10.0000000` (exact). Existing doc note in `docs/ComputeFeatureNeighborCAxisMisalignmentsFilter.md` quotes "~0.0001°" — conservative; actual drift is ~100× smaller. Non-observable on the V&V analytical assertions (the closed-form `|ΔΦ|` derivation matches all three binaries within the `1e-3°` Approx tolerance). + +**Backported to 6.5.172 on 2026-06-04** (commit `5adc45df0` on `/Users/mjackson/DREAM3D-Dev/DREAM3D` `v6_5_172` branch) via Eigen + double precision conversion, following the `FindAvgCAxes` (commit `3fc514cce`) and `FindFeatureReferenceCAxisMisorientations` (commit `d4b5509aa`) precedents. **Post-backport empirical result: 6.5.172 produces BIT-IDENTICAL output to SIMPLNX on the F#6 fixture** — 18 per-pair `CAxisMisalignmentList` entries + 6 per-feature `AvgCAxisMisalignments` entries all byte-compared and confirmed identical via `h5py` direct comparison. This conclusively attributes the entire pre-backport `~1e-6°` drift to the (Eigen + double) ↔ (hand-rolled MatrixMath + float) precision style difference — no other latent algorithmic difference remains. + +**Root cause:** **Library swap** during PR #1472 ("EbsdLib bump"). Two pieces of orientation math were replaced: + +1. **Quaternion → orientation matrix conversion.** Legacy and pre-#1472 SIMPLNX used `OrientationTransformation::qu2om(quat)` (a hand-rolled conversion in the OrientationLib/EbsdLib transformation utilities). PR #1472 replaced this with `ebsdlib::QuaternionDType(quat).toOrientationMatrix()` (an EbsdLib member function on the quaternion class itself). Both produce the same matrix up to float64 precision, but the internal arithmetic order differs. + +2. **G-matrix transpose.** Legacy used `OrientationMatrixToGMatrixTranspose(oMatrix)`, which built the transpose into a NEW matrix object. PR #1472 replaced this with `oMatrix.transpose()` (Eigen's lazy `Transpose` view). The downstream `.transpose() * cAxis` operation then evaluates the transpose-multiply as a single Eigen expression. Numerically identical to a pre-built transposed matrix multiplied by the same vector, but the rounding sequence is different. + +**Affected users:** Anyone diff-ing per-pair c-axis misalignment values between DREAM3D 6.5.171 output and post-PR-#1472 SIMPLNX output. The shift is well below typical EBSD measurement resolution (~`0.5°`) and will not materially affect downstream microstructural analyses. + +**Recommendation:** **Trust SIMPLNX.** The shift is precision-class noise from a library swap, not an algorithmic difference. The Eigen-based form is the cleaner expression and is consistent with the rest of the post-#1472 OrientationAnalysis plugin. The doc note's "0.0001 degrees" estimate is in line with what the V&V cycle observed — no growth over the original PR #1472 commit. + +--- + +## ComputeFeatureNeighborCAxisMisalignmentsFilter-D5 + +| Field | Value | +|------------------|--------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborCAxisMisalignmentsFilter-D5` | +| **Filter UUID** | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| **Status** | active (UX-only downgrade; preflight banner no longer shown in GUI parameter panel — execute-time algorithm warning still surfaces correctly to all users) | + +**Symptom (partially retracted after empirical A/B 2026-06-04):** PR #1438 moved a FILTER-LEVEL preflight banner from `resultOutputActions.warnings()` to `preflightUpdatedValues`. The ALGORITHM-side warning at lines 53-56 of `Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp` (`"Non Hexagonal phases were found. All calculations for non Hexagonal phases will be skipped and a NaN value inserted."`) is STILL pushed into the algorithm's `Result<>::warnings()` collection and DOES surface in CLI nxrunner output. Empirically confirmed during the A/B test: SIMPLNX nxrunner stderr printed `Code: -1563 Message: Non Hexagonal phases were found...` when run on the realistic-microstructure fixture. + +What was actually lost in PR #1438 is the **preflight-time** banner that GUI users see in the parameter panel before they hit "Execute." Pipeline-mode users still see the same warning, but only at execute-time (when the algorithm hits the early-exit / warning branch). This is a UX downgrade (delayed feedback) but NOT the "users see nothing" regression originally claimed. + +**Root cause:** **Intentional UX change** in PR #1438 ("Microtexture cleanup"). The PR author's intent was likely to reduce CLI noise by demoting an "informational" message — but the message describes a real algorithmic behavior (NaN insertion) that downstream consumers need to know about, especially when running the shipping `EBSD_Hexagonal_Data_Analysis.d3dpipeline` on data that turns out to contain non-hex phases. + +The current algorithm code still produces the warning correctly (lines 53-56), but it's pushed into the algorithm's `Result<>` return, which is surfaced via different channels in GUI vs CLI mode. PR #1438's specific change was the demotion at the FILTER level, not the algorithm level. + +**Affected users:** Pipeline-mode users (CLI / Python / nxrunner) running this filter on mixed-phase data. They will see NaN values appear in the `CAxisMisalignmentList` output without any warning that explains why. GUI users still see the message correctly. + +**Recommendation:** **Restore the warning to `Result<>::warnings()`** so pipeline-mode users see it. This is a one-line addition to the filter or algorithm — pushing the warning into both `Result<>` AND `preflightUpdatedValues` is fine. The V&V cycle did NOT make this change (out of scope for the divisor-bug-fix focus); flag as follow-up. + +--- + +## ComputeFeatureNeighborCAxisMisalignmentsFilter-D6 + +| Field | Value | +|------------------|--------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborCAxisMisalignmentsFilter-D6` | +| **Filter UUID** | `636ee030-9f07-4f16-a4f3-592eff8ef1ee` | +| **Status** | active (behavior class; SIMPLNX correct since port, legacy gap) | + +**Symptom:** For datasets containing features whose shared phase has Laue class **Hexagonal_Low** (6/m), SIMPLNX computes the c-axis misalignment exactly as for Hexagonal_High (6/mmm); DREAM3D 6.5.171 / 6.5.172 (pre-backport) write `NaN` because the legacy phase-match gate restricts the calculation to Hex_High pairs only. + +**Root cause:** **Library + algorithmic choice** — same root pattern as D1 of `ComputeFeatureFaceMisorientations`. + +The legacy code at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureNeighborCAxisMisalignments.cpp:286` reads: +```cpp +if(phase1 == phase2 && (phase1 == Ebsd::CrystalStructure::Hexagonal_High)) +``` +which restricts the c-axis-misalignment computation to Hex_High↔Hex_High neighbor pairs only. + +The SIMPLNX code at `Algorithms/ComputeFeatureNeighborCAxisMisalignments.cpp:114` reads: +```cpp +if(xtalPhase1 == xtalPhase2 && (xtalPhase1 == ebsdlib::CrystalStructure::Hexagonal_High || xtalPhase1 == ebsdlib::CrystalStructure::Hexagonal_Low)) +``` +which correctly handles BOTH hex Laue classes. The c-axis math is independent of symmetry-operator-set choice (the algorithm only uses the orientation matrix and the c-axis direction; no `LaueOps` calls are made), so the same code path produces the mathematically correct misalignment for both hex Laue classes. The legacy restriction to Hex_High was historical — the OrientationLib of that era only had `LaueOps` symmetry operators for Hex_High, and the original author conservatively restricted the gate to match what other hex-aware filters could handle. This filter doesn't need symmetry operators, so the restriction is unnecessary. + +The early-exit preflight in SIMPLNX (lines 35-45) also treats both Hex Laue classes as "valid hex phases" for the all-non-hex error and mixed-phase warning logic. Legacy 6.5.171/172 (pre-backport) only counts Hex_High features. + +**Not observable on the F#6 V&V fixture** because the realistic-microstructure fixture only uses Hex_High features (no Hex_Low). The deviation IS observable on any real EBSD dataset containing Hex_Low phases (e.g., wurtzite-structure materials, some intermetallics). + +**Affected users:** Anyone running this filter on DREAM3D 6.5.171 / 6.5.172 (pre-backport) with a dataset containing Hex_Low phases. Legacy writes `NaN` for those features' misalignment entries; SIMPLNX computes the real c-axis misalignment. + +**Recommendation:** **Trust SIMPLNX.** The c-axis math is correct for both hex Laue classes; the legacy restriction was overly conservative. **Backported to 6.5.172 on 2026-06-04** (commit `5adc45df0`) bundled with D4 (Eigen+double conversion). Post-backport: legacy `FindFeatureNeighborCAxisMisalignments` now accepts both Hex_High and Hex_Low pairs, identical to SIMPLNX. + +--- + +## Non-deviations (algorithm characteristics common to both filters) + +The following behaviors are NOT deviations — SIMPLNX (post-D1 fix) and DREAM3D 6.5.171 (with D1 still present) agree on them where D1 is not exercised. Captured here so future engineers don't re-discover them and propose them as deviations. + +### NaN entry on phase mismatch and non-hex Laue class + +Both implementations write `NaN` (via `std::nanf("")` or the C macro `NAN`) into the per-neighbor `CAxisMisalignmentList` entry when the focal feature's phase differs from the neighbor's phase, or when the shared phase's Laue class is not `Hexagonal_High` (or `Hexagonal_Low` — both are accepted). **Both filters share this behavior** — algorithm characteristic, not a defect. + +### Per-feature outer-loop iteration starts at index 1 (skips background feature 0) + +Both implementations iterate `for(size_t i = 1; i < totalFeatures; i++)` in the per-feature outer loop, skipping the background feature at index 0. The `CAxisMisalignmentList[0]` and `AvgCAxisMisalignments[0]` entries are therefore left at their initialized default values (empty list and `0.0f`, respectively). **Both filters share this behavior**. + +### Default output array name rename (formerly proposed as D3) + +PR #1438 renamed the default output array from `"AvgCAxisMisalignments"` to `"AvgNeighborCAxisMisalignments"`, and reworded the parameter labels: +- `"C-Axis Misalignment List"` → `"Feature C-Axis Misalignment NeighborList"` +- `"Average C-Axis Misalignments"` → `"Feature Average C-Axis Misalignments"` + +This is **user-facing migration noise**, not a behavioral deviation. The SIMPLNX backwards-compat path (PR #1588) preserves the SIMPL conversion semantics. Existing pipeline files in the repo were re-saved with the new name; user-saved pipelines that explicitly named arrays still work. Documented here for completeness; not a numbered deviation. + +### EbsdLib 2.4.1 CubicOps precision improvement does NOT apply + +Unlike F#1 / F#2 / F#4 / F#5 of this V&V cycle, this filter does NOT route through `LaueOps::calculateMisorientation` or `CubicOps::calculateMisorientationInternal`. The algorithm computes c-axis vectors directly from the orientation matrix (`R^T · [0,0,1]`) and takes `arccos(c1·c2)` without any cubic-symmetry-operator search. The EbsdLib 2.4.1 precision improvement therefore has no effect on this filter's output, and there is no `D-precision` entry analogous to the other filters in the cycle. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md new file mode 100644 index 0000000000..5794abe459 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborCAxisMisalignmentsFilter.md @@ -0,0 +1,254 @@ +# Exemplar Archive Provenance: Inlined in Test + +This sidecar records how the test data used by `ComputeFeatureNeighborCAxisMisalignmentsFilter`'s Class 1 and Class 4 unit tests was generated. It is the answer to "where did this hand-built data come from?" + +The test data is **inlined** in the test source — there is no separate tar.gz archive, no `download_test_data()` entry, and no `.dream3d` exemplar file to fetch. + +--- + +## Archive identity + +| Field | Value | +|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| **Archive** | Inlined (no separate archive) | +| **SHA512** | N/A | +| **Used by tests** | `OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Simple Hex Pair` | +| | `OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Realistic Microstructure (exposes divisor bug)` | +| | `OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 1 - Mismatch Last Order` | +| | `OrientationAnalysis::ComputeFeatureNeighborCAxisMisalignmentsFilter: Class 4 - Invariants` (3 SECTIONs) | +| **Generated by** | Claude (Opus 4.7, Anthropic) under direction of Michael Jackson | +| **Generated on** | 2026-06-04 | + +--- + +## Retired archive (replaced by this inlined fixture) + +| Field | Value | +|-------------------|------------------------------------------------------------------------------------| +| **Archive name** | `compute_feature_neighbor_caxis_misalignments.tar.gz` | +| **SHA512** | `955cd35b7ae24579ef9c533df34e1118012a8e5e2a71f8613117c714fc220c5dfa78d91a2964b41752e70684b79d4aa790e488e9a7be4c9dcf7b642ee2897ceb` | +| **Contents** | `7_5_simplnx_test_file_25x50_Hex.dream3d` — a hex-phase-only dataset, with exemplar `CAxisMisalignmentList (7_5)` and `AvgCAxisMisalignments (7_5)` arrays generated from a SIMPL 6.5.171 pipeline run. | +| **Retired on** | 2026-06-04 | +| **Retired by** | This V&V cycle | +| **Reason** | The exemplar dataset is **hex-phase-only**, which means the per-mismatch decrement branch in the algorithm (`hexNeighborListSize--`) is never exercised. The exemplar values would have happily passed even on the divisor-bug code (D1). The hex-only exemplar therefore could not have caught the D1 bug, and was a circular oracle in the more general sense (the legacy bug pattern is invisible on the dataset). Replaced with the inlined Class 1 + Class 4 fixtures below, which include 3 distinct bug-exposing per-feature configurations. | +| **CMakeLists.txt**| `download_test_data(... compute_feature_neighbor_caxis_misalignments.tar.gz ...)` removed from `src/Plugins/OrientationAnalysis/test/CMakeLists.txt` (no other filter test consumed this archive). | + +--- + +## How the inlined fixtures were generated + +The dataset is a hand-rolled in-memory `DataStructure` designed as a **Class 1 (Analytical) oracle** with a paired **Class 4 (Invariant)** check. It systematically covers the six algorithmic paths in `ComputeFeatureNeighborCAxisMisalignments::operator()()`: + +1. **All-non-hex preflight early-exit** → error -1562 (no hex phases). NOT exercised by the V&V fixtures (all contain at least one hex phase) — covered by upstream parameter-validation tests. +2. **Mixed-phase preflight warning** → warning -1563 ("Non Hexagonal phases were found"). +3. **Per-feature outer loop with hex-hex same-phase neighbor** → write angle to misoList + accumulate to avg. +4. **Phase-mismatch branch** → write NaN to misoList + decrement divisor. +5. **`find_avg_misals=true` finalize with `hexNeighborListSize > 0`** → `avg = sum/divisor`. +6. **`find_avg_misals=true` finalize with `hexNeighborListSize == 0`** → `avg = NaN`. + +### Scaffold structure + +The `ToyFixtures` namespace at the top of `ComputeFeatureNeighborCAxisMisalignmentsTest.cpp` provides a `CreateScaffold(nX, nY, nZ, numFeatures, numCrystalStructures)` helper that constructs: + +- A single `ImageGeom` named `ImageGeometry` with the requested cell dimensions, spacing `{1, 1, 1}`, origin `{0, 0, 0}`. +- A `CellData` `AttributeMatrix` with tuple shape `{nZ, nY, nX}` for cell-level arrays: `FeatureIds` (int32, default 1), `Phases` (int32, default 1). **These cell-level arrays are NOT consumed by the algorithm** — they exist purely as a visualization aid so engineers reviewing the fixture can see which voxels belong to which feature. The algorithm's inputs are feature-level (`FeaturePhases`, `AvgQuats`, `NeighborList`) and ensemble-level (`CrystalStructures`). +- A `CellFeatureData` `AttributeMatrix` with tuple shape `{numFeatures}` for feature-level arrays: `FeaturePhases`, `AvgQuats` (4 components), `NeighborList`. Defaults: all phase 0, identity quat, empty neighbor list. +- A `CellEnsembleData` `AttributeMatrix` with tuple shape `{numCrystalStructures}` for `CrystalStructures` (UInt32Array). Defaults: index 0 = sentinel `999u`; caller sets other indices. + +Helpers: +- `QuatFromPhiDeg(phiDeg)` produces `{sin(phi/2 rad), 0, 0, cos(phi/2 rad)}` — the quaternion for a pure Bunge ZXZ `(φ1, Φ, φ2) = (0, phiDeg, 0)` rotation. See "Orientation convention" below. +- `SetAvgQuat(td, featureIdx, q)` writes a quaternion into the `AvgQuats` array. +- `BuildArgs(findAvgMisals)` constructs the `Arguments` object for the filter with the standard input/output paths. +- `BuildRealisticMicrostructure()` constructs the 10×10×1 6-feature fixture used by Fixture 2 (and reused by Fixture 4 / Class 4 Invariants). + +### Orientation convention + +All Class 1 fixtures use **pure Bunge ZXZ Euler rotations `(0, Φ, 0)`** — rotations about the x-axis by Φ degrees. Closed-form derivation: + +The orientation matrix for a pure-Φ rotation about x is: +``` +R(Φ) = [[1, 0, 0], + [0, cos(Φ), -sin(Φ)], + [0, sin(Φ), cos(Φ)]] +``` + +The algorithm computes `c = R^T · [0, 0, 1] = [0, sin(Φ), cos(Φ)]` — the c-axis vector, tilted from the global z-axis by Φ degrees. + +For two features with tilts `Φ_A` and `Φ_B`: +- `c_A · c_B = sin(Φ_A) sin(Φ_B) + cos(Φ_A) cos(Φ_B) = cos(Φ_A - Φ_B)` +- `arccos(c_A · c_B) = |Φ_A - Φ_B|` +- Folded to `[0, π/2]` via the algorithm's `if(w > π/2) w = π - w` step: still `|Φ_A - Φ_B|` as long as `|Φ_A - Φ_B| ≤ 90°` (true for all V&V fixtures, which use tilts in `[0°, 25°]`). + +This makes the oracle closed-form: the expected c-axis misalignment between two hex-phase features with pure-Φ tilts is exactly `|Φ_A - Φ_B|` degrees, with no numerical sym-op search required. + +### Fixture-by-fixture derivation + +#### Fixture 1 — `Class 1 - Simple Hex Pair` + +- 1×1×1 image, 3 features total (sentinel + 2 hex). +- `(*td.crystalStructures)[1] = ebsdlib::CrystalStructure::Hexagonal_High`. +- F1: `phase=1, Φ=0°`. F2: `phase=1, Φ=10°`. +- NeighborList: F1→[F2], F2→[F1]. +- Expected: `misalignmentList[F1] = [10°]`, `misalignmentList[F2] = [10°]`, `avg[F1] = avg[F2] = 10°`. + +This is the basic-path test — no phase mismatches, no divisor decrements. Confirms the closed-form `|ΔΦ|` derivation and the hex-hex accumulator path. + +#### Fixture 2 — `Class 1 - Realistic Microstructure (exposes divisor bug)` + +The meaty fixture. 10×10×1 image, 7 features (sentinel + 6 real). + +**Phase assignment:** +- `(*td.crystalStructures)[1] = Hexagonal_High` +- `(*td.crystalStructures)[2] = Cubic_High` +- F1, F2, F4, F5, F6 → phase 1 (Hex) +- F3 → phase 2 (Cubic, non-hex) + +**Orientation assignment (pure Φ tilts about x):** +- F1: Φ=0°, F2: Φ=5°, F3: Φ=10° (ignored — non-hex), F4: Φ=15°, F5: Φ=20°, F6: Φ=25°. + +**Cell-by-cell FeatureIds layout** (rows=y, cols=x): + +``` +y=0..3: x=0..2 → F1 | x=3..6 → F2 | x=7..9 → F3 +y=4..9: x=0..3 → F4 | x=4..7 → F5 | x=8..9 → F6 +``` + +This produces the following face-adjacencies (face-adjacent = two cells sharing a 1-cell edge): + +- F1 ↔ F2 (at x=2/x=3 boundary, y=0..3 — 4 face-shared cells) +- F1 ↔ F4 (at y=3/y=4 boundary, x=0..2 — 3 face-shared cells) +- F2 ↔ F3 (at x=6/x=7 boundary, y=0..3 — 4 face-shared cells) +- F2 ↔ F4 (at (x=3, y=3)/(x=3, y=4) — 1 face-shared cell — corner adjacency) +- F2 ↔ F5 (at y=3/y=4 boundary, x=4..6 — 3 face-shared cells) +- F3 ↔ F5 (at (x=7, y=3)/(x=7, y=4) — 1 face-shared cell) +- F3 ↔ F6 (at y=3/y=4 boundary, x=8..9 — 2 face-shared cells) +- F4 ↔ F5 (at x=3/x=4 boundary, y=4..9 — 6 face-shared cells) +- F5 ↔ F6 (at x=7/x=8 boundary, y=4..9 — 6 face-shared cells) + +**NeighborList entries** (face-adjacencies derived from the layout above): + +- F1: [F2, F4] +- F2: [F1, F3, F4, F5] +- F3: [F2, F5, F6] +- F4: [F1, F2, F5] +- F5: [F2, F3, F4, F6] +- F6: [F3, F5] + +**Per-feature expected outputs** (post-fix algorithm): + +| Feature | `misalignmentList[F]` | divisor | sum | avg (post-fix) | avg (pre-fix bug) | +|---------|--------------------------|---------|-----|----------------|-------------------| +| F1 | [5°, 15°] | 2 | 20° | **10.000°** | 10.000° (no mismatches → no divisor change) | +| F2 | [5°, NaN, 10°, 15°] | 3 | 30° | **10.000°** | **7.500° (30/4)** ← bug-exposing | +| F3 | [NaN, NaN, NaN] | 0 | — | **NaN** | NaN (non-hex focal) | +| F4 | [15°, 10°, 5°] | 3 | 30° | **10.000°** | 10.000° (no mismatches) | +| F5 | [15°, NaN, 5°, 5°] | 3 | 25° | **8.3333°** | **6.250° (25/4)** ← bug-exposing | +| F6 | [NaN, 5°] | 1 | 5° | **5.000°** | **2.500° (5/2)** ← bug-exposing | + +F2, F5, F6 each have at least one non-hex neighbor followed by at least one hex neighbor. The pre-fix algorithm reassigned `hexNeighborListSize` to the full list size on every j-iteration, so the per-mismatch decrement at line 150 was clobbered by the next iteration's reassignment, leaving the final divisor equal to the full neighbor-list length instead of the hex-only neighbor count. Post-fix code moves the assignment to before the inner loop, so the decrement is preserved. + +#### Fixture 3 — `Class 1 - Mismatch Last Order` (Control case) + +- 1×1×1 image, 5 features (sentinel + 4 real). +- Phases: F1, F2, F3 hex (Φ=0°, 5°, 10°), F4 cubic (Φ=20° — ignored). +- NeighborList: F1 → [F2, F3, F4] — order [match, match, mismatch]. +- Expected: `misalignmentList[F1] = [5°, 10°, NaN]`, divisor=2, sum=15°, avg=7.500°. +- **Pre-fix code also produces 7.500°** because the last j-iteration is a non-hex match → the reassignment runs (hexNeighborListSize=3) then the decrement runs (hexNeighborListSize=2). Bug doesn't fire on this ordering. + +Control fixture — confirms that the post-fix code does NOT regress the case where the buggy code happened to produce the right answer by accident. + +#### Fixture 4 — `Class 4 - Invariants` (3 SECTIONs, reuses Fixture 2 data) + +Three SECTIONs all using the realistic-microstructure fixture: + +**Sub-section (i) — Range:** every `misalignmentList[F][j]` is either NaN or in `[0°, 90°]`. The 90° upper bound is enforced by the algorithm's `if(w > π/2) w = π - w` fold; the 0° lower bound by `arccos` returning non-negative. + +**Sub-section (ii) — Per-feature averaging formula:** for each feature `F`, `avg[F] == sum(non-NaN entries in misalignmentList[F]) / count(non-NaN entries in misalignmentList[F])`, or `NaN` if count == 0. **This is the load-bearing invariant for D1.** Under the pre-fix code, F2, F5, F6 fail this invariant (their stored avg does not equal the formula recomputed from the per-neighbor list). Under the post-fix code, all features pass. + +**Sub-section (iii) — Non-hex focal → all NaN:** every entry in `misalignmentList[F3]` is NaN, and `avg[F3]` is NaN. F3 has Cubic_High phase, so the `xtalPhase1 == ebsdlib::CrystalStructure::Hexagonal_*` gate fails for every neighbor, regardless of the neighbor's phase. + +Class 4 invariants are oracle-agnostic — they hold for any input regardless of specific Φ values. This is what makes them a robust complement to the Class 1 fixtures: they would catch regression of the divisor bug even if the specific orientation values in Fixture 2 were changed. + +## Canonical oracle output + +| DataPath | Source of expected values | +|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `/ImageGeometry/CellFeatureData/CAxisMisalignmentList` | Class 1 analytical (closed-form `|ΔΦ|` for pure-Φ rotations, folded to `[0°, 90°]`; NaN on phase mismatch or non-hex Laue class). | +| `/ImageGeometry/CellFeatureData/AvgCAxisMisalignments` | Class 1 analytical (arithmetic mean of non-NaN entries from above) + Class 4 invariant (range, formula, non-hex-focal). | + +The expected values are hard-coded into each TEST_CASE as `REQUIRE(... == Approx(...).margin(1e-3f))` checks (per-pair) and `REQUIRE(std::isnan(...))` checks (per NaN entry). Tolerance set to `1e-3°` — tight enough to catch the divisor bug's `1.667°` minimum magnitude error on the Realistic Microstructure fixture, comfortably wider than any expected float32 precision noise from the Eigen orientation-matrix arithmetic. + +## Oracle provenance (Classes 2, 3, 5 only) + +N/A — Class 1 and Class 4 oracles only. No reference-library invocation, no paper-figure reproduction, no expert-visual sign-off needed. + +## Second-engineer oracle review + +- **Reviewer:** *Pending — recommend Joey Kleingers or another OA-domain engineer review (a) the realistic-microstructure F2/F5/F6 per-feature expected averages, (b) the closed-form derivation that pure Bunge ZXZ `(0, Φ, 0)` tilts the c-axis by exactly Φ degrees, and (c) the cell-by-cell face-adjacency derivation that produced the per-feature NeighborList entries.* +- **Date:** *YYYY-MM-DD (pending)* +- **Skip reason** (if skipped): *N/A — second-engineer review is recommended; not yet performed.* + +## Empirical A/B validation (2026-06-04) + +After the V&V cycle's source-inspection comparison + analytical Class 1 fixtures, an **empirical A/B comparison** was performed against three binaries to validate every deviation claim end-to-end. Workspace at `/Users/mjackson/Desktop/F6_AB_Test/`: + +| Binary | Path | Role | +|--------|-------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------| +| A | `/Users/mjackson/Applications/DREAM3D.app/Contents/bin/PipelineRunner` | DREAM3D 6.5.171 official release — pre-fix, buggy | +| B | `/Users/mjackson/DREAM3D-Dev/DREAM3D-Build/D3D-Rel-Qt515-6_5_171/Bin/PipelineRunner` | DREAM3D 6.5.172 Mike's backport branch — divisor fix applied (commit `c50223a46`) | +| C | `/Users/mjackson/Workspace9/DREAM3D-Build/NX-Com-Qt69-Vtk95-Rel-EbsdLib/Bin/nxrunner` | SIMPLNX (post-fix), linked against EbsdLib 2.4.1 build | + +**Input:** `input/build_input.py` generates `input/f6_realistic_microstructure.dream3d` — a legacy v7.0-format `.dream3d` file containing the realistic-microstructure fixture (10×10×1 ImageGeom, 6 features, mixed hex/non-hex phases, pure-Φ Bunge ZXZ rotations). The Python script is fully reproducible — anyone can re-derive the input. + +**Pipelines:** `pipelines/legacy_6_5_171.json` (legacy SIMPL JSON format), `pipelines/legacy_6_5_172.json` (identical to 171 except output path), `pipelines/simplnx.d3dpipeline` (SIMPLNX format). All three pipelines: read the input → run the filter with `find_avg_misals=true` → write output. + +**Comparison:** `notes/compare.py` reads all three output `.dream3d` files and prints per-feature side-by-side comparisons. Results captured in `notes/ab_results.txt`. + +**Key findings (validated against the analytical Class 1 expected values):** + +| Feature | Expected | Pre-fix bug prediction | 6.5.171 (actual) | 6.5.172 (actual) | SIMPLNX (actual) | +|---------|----------|------------------------|------------------|------------------|------------------| +| F1 | 10.0000° | 10.0000° (dormant) | 10.0000° | 10.0000° | 10.0000° | +| F2 | 10.0000° | 7.5000° (bug fires) | **7.5000°** ❌ | 10.0000° ✓ | 10.0000° ✓ | +| F3 | NaN | NaN | **0.0000** ❌ | NaN ✓ | NaN ✓ | +| F4 | 10.0000° | 10.0000° (dormant) | 10.0000° | 10.0000° | 10.0000° | +| F5 | 8.3333° | 6.2500° (bug fires) | **6.2500°** ❌ | 8.3333° ✓ | 8.3333° ✓ | +| F6 | 5.0000° | 2.5000° (bug fires) | **2.5000°** ❌ | 5.0000° ✓ | 5.0000° ✓ | + +Per-pair `CAxisMisalignmentList` values are functionally identical across all three binaries within float32 precision (~`1e-6°`). The drift between 6.5.171/172 (hand-rolled MatrixMath float32 path) and SIMPLNX (Eigen double-precision path) is the empirical magnitude of D4. + +**Empirical conclusions about each deviation:** + +- **D1 confirmed**: bug fires on 6.5.171 producing exactly the predicted-buggy values; fixed in 6.5.172 backport AND in SIMPLNX. **Bonus observation**: F3's non-hex-only neighbor case produces `0.0` on 6.5.171 instead of `NaN` — see D1 entry's "Additional symptom" paragraph. +- **D2 dormant**: F1's exact `10.0000` proves the SIMPLNX `AvgCAxisMisalignments` array IS zero-initialized by the current in-memory DataStore default. The latent bug doesn't fire on the current backend. +- **D4 quantified (pre-backport) and then closed (post-backport)**: pre-backport drift was `~1e-6°` per-pair / `~2e-5°` per-feature avg. Existing doc note's `~0.0001°` estimate is ~100× too high. **Backported to 6.5.172 commit `5adc45df0`** via Eigen + double precision conversion following the `FindAvgCAxes` (commit `3fc514cce`) and `FindFeatureReferenceCAxisMisorientations` (commit `d4b5509aa`) precedents. **Post-backport: 6.5.172 produces BIT-IDENTICAL output to SIMPLNX** — all 18 per-pair entries + 6 per-feature entries byte-compared via h5py and confirmed identical. +- **D5 partially retracted**: empirical A/B confirmed SIMPLNX nxrunner DOES emit the algorithm-level "Non Hexagonal phases" warning at execute-time. The PR #1438 regression was specifically the *preflight-time* GUI banner, not the warning channel as originally claimed. +- **D6 added 2026-06-04**: Hexagonal_Low support gap surfaced via source-inspection during the post-A/B precedent search. Not observable on the F#6 fixture (no Hex_Low features) but a real behavior gap on wurtzite-class data. **Backported to 6.5.172 commit `5adc45df0`** bundled with D4 (the Eigen conversion commit also lifted the Hex_High-only gate to accept both Hex Laue classes — same gate location, single commit). + +## Post-D4-backport byte-for-byte verification + +After applying commit `5adc45df0` (Eigen + double + Hex_Low) to the 6.5.172 branch, re-ran the A/B comparison via direct h5py byte-comparison: + +``` +AvgCAxisMisalignments byte-by-byte diff: + F1: 172=10.0 NX=10.0 byte-match=True + F2: 172=10.0 NX=10.0 byte-match=True + F3: 172=nan NX=nan byte-match=True + F4: 172=10.0 NX=10.0 byte-match=True + F5: 172=8.333335 NX=8.333335 byte-match=True + F6: 172=5.0 NX=5.0 byte-match=True + +CAxisMisalignmentList byte-by-byte diff (flat 18 entries): + All 18 entries: byte-match=True (incl. all NaN sentinel slots) +``` + +This is the canonical "100% certainty" proof that: +1. The only behavioral deviations between legacy and SIMPLNX for this filter are the four documented (D1, D2, D4, D6 — D5 retracted to UX-only). +2. After backporting all four to 6.5.172 (D1 in `c50223a46`, D4+D6 in `5adc45df0`), 6.5.172 produces output identical to SIMPLNX at the bit level. +3. No other latent algorithmic difference exists in this filter that the V&V cycle missed. + +## Regenerated to fix a circular-oracle situation? + +**Yes.** The retired exemplar archive (`compute_feature_neighbor_caxis_misalignments.tar.gz`) was a hex-phase-only dataset. The per-mismatch decrement branch in the algorithm never fired on this dataset, so the exemplar values were correct under both the buggy and the fixed code — the exemplar could not have caught the D1 bug. This is a degenerate form of the "circular oracle" pattern from the V&V policy: the exemplar happens to land in a region of the parameter space where the bug is dormant. + +The inlined Class 1 + Class 4 fixtures replace the not-bug-catching exemplar with derived-truth oracles plus a deliberately-bug-exposing realistic microstructure that exercises 3 distinct per-feature configurations of the bug. The retired archive was unique to this filter (no other filter test consumed it), so its `download_test_data` line in `test/CMakeLists.txt` was removed entirely. diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json index 8a698edfcd..3f783b444f 100644 --- a/vcpkg-configuration.json +++ b/vcpkg-configuration.json @@ -8,7 +8,7 @@ { "kind": "git", "repository": "https://github.com/bluequartzsoftware/simplnx-registry", - "baseline": "ca7046ad28b4885b018e4ab5fcf43333460d82b2", + "baseline": "b9f4d4c072f0ffc3291378dc03c4f6b38f0b4743", "packages": [ "benchmark", "blosc", diff --git a/vcpkg.json b/vcpkg.json index a8576133a1..d843331ef4 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -83,7 +83,7 @@ "dependencies": [ { "name": "ebsdlib", - "version>=": "2.4.0" + "version>=": "2.4.1" } ] },