From 4e9fc9b2be1449fdafc3b8d639431e95e7c51151 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 10 Jun 2026 21:44:15 -0400 Subject: [PATCH] VV: Compute Feature Reference C-Axis Misalignments fully V&V'ed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Confirmed no SIMPLNX-side bugs (algorithm correctly handles all-non-hex feature via IEEE 754 NaN propagation; D1 + D4 below are pre-existing legacy bugs already fixed in SIMPLNX via PR #1438 + PR #1472); - documented 2 deviations from DREAM3D 6.5.171 (D1 legacy lacks isHex gate → garbage non-hex cell values + non-NaN avg, D4 hand-rolled MatrixMath + float32 stddev precision drift ~1e-4° per cell) — both already closed on v6_5_172 via pre-existing backport commits d4b5509aa + 4435d1997, three-way A/B confirms 6.5.172 ≡ SIMPLNX byte-for-byte; - retired 1 test (Valid Filter Execution — circular oracle on caxis_data.tar.gz exemplar; archive download retained because ComputeCAxisLocationsTest still consumes it); - unit tests replaced with 4 inlined *Class 1 (Analytical) + Class 4 (Invariant)* test fixtures (Simple Hex Triple + Realistic Microstructure exposes-all-non-hex-feature + All-Identical Orientation + Invariants sweep); - added 3 V&V source-tree deliverables (report, deviations, provenance). --- ...teFeatureReferenceCAxisMisorientations.cpp | 62 ++- ...atureReferenceCAxisMisorientationsTest.cpp | 514 ++++++++++++++++-- ...tureReferenceCAxisMisorientationsFilter.md | 146 +++++ ...tureReferenceCAxisMisorientationsFilter.md | 128 +++++ ...tureReferenceCAxisMisorientationsFilter.md | 180 ++++++ 5 files changed, 965 insertions(+), 65 deletions(-) create mode 100644 src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp index 9ab8032387..2208e8677f 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp @@ -6,6 +6,9 @@ #include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/Utilities/ImageRotationUtilities.hpp" +#include "simplnx/Utilities/MessageHelper.hpp" + +#include #include #include @@ -34,29 +37,28 @@ ComputeFeatureReferenceCAxisMisorientations::~ComputeFeatureReferenceCAxisMisori Result<> ComputeFeatureReferenceCAxisMisorientations::operator()() { - /* ************************************************************************** - * This section performs a sanity check to ensure that at least 1 phase is - * hexagonal. - */ + // Preflight: every ensemble index must resolve to a Hex Laue class for the filter to do anything. + // We need to know both whether any phase is hex (else hard error) and whether all phases are hex + // (else warn the user that non-hex phases will be skipped). const auto& crystalStructures = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); - bool allPhasesHexagonal = true; - bool noPhasesHexagonal = true; + bool anyPhaseIsHex = false; + bool allPhasesAreHex = true; for(usize i = 1; i < crystalStructures.size(); ++i) { const auto crystalStructureType = crystalStructures[i]; const bool isHex = crystalStructureType == ebsdlib::CrystalStructure::Hexagonal_High || crystalStructureType == ebsdlib::CrystalStructure::Hexagonal_Low; - allPhasesHexagonal = allPhasesHexagonal && isHex; - noPhasesHexagonal = noPhasesHexagonal && !isHex; + anyPhaseIsHex = anyPhaseIsHex || isHex; + allPhasesAreHex = allPhasesAreHex && isHex; } - if(noPhasesHexagonal) + if(!anyPhaseIsHex) { return MakeErrorResult( -9802, "Finding the feature reference c-axis misorientation requires at least one phase to be Hexagonal-Low 6/m or Hexagonal-High 6/mmm type crystal structures but none were found."); } Result<> result; - if(!allPhasesHexagonal) + if(!allPhasesAreHex) { result.warnings().push_back( {-9803, @@ -86,7 +88,7 @@ Result<> ComputeFeatureReferenceCAxisMisorientations::operator()() const usize numQuatComps = quats.getNumberOfComponents(); - std::vector counts(totalFeatures, 0ULL); + std::vector counts(totalFeatures, 0); std::vector avgMisorientations(totalFeatures, 0.0f); SizeVec3 uDims = m_DataStructure.getDataRefAs(m_InputValues->ImageGeometryPath).getDimensions(); @@ -158,26 +160,30 @@ Result<> ComputeFeatureReferenceCAxisMisorientations::operator()() } } - // Loop over all the features from the feature attribute matrix and compute the - // average C Axis Misorientation for each feature + // Per-feature average. Explicit NaN when no hex cells contributed (counts == 0); without this + // guard, the division below would rely on IEEE 754 0/0 -> NaN, which is correct on every + // platform we ship but fragile to FP-environment changes. + MessageHelper messageHelper(m_MessageHandler); + ThrottledMessenger throttledMessenger = messageHelper.createThrottledMessenger(); for(usize featureId = 1; featureId < totalFeatures; featureId++) { - if(featureId % 1000 == 0) - { - m_MessageHandler(IFilter::Message::Type::Info, fmt::format("Working On Feature {} of {}", featureId, totalFeatures)); - } if(m_ShouldCancel) { return {}; } + throttledMessenger.sendThrottledMessage([&] { return fmt::format("Computing per-feature average {:.2f}% completed", CalculatePercentComplete(featureId, totalFeatures)); }); - // Compute the average value of the misorientations between each feature's cell - // and the average C-Axis for that feature - featAvgCAxisMis[featureId] = avgMisorientations[featureId] / static_cast(counts[featureId]); + if(counts[featureId] == 0) + { + featAvgCAxisMis[featureId] = std::numeric_limits::quiet_NaN(); + } + else + { + featAvgCAxisMis[featureId] = avgMisorientations[featureId] / static_cast(counts[featureId]); + } } - // These 2 loops compute the population standard deviation of those misorientations for - // each feature. + // Population standard deviation. Per-cell accumulate (diff^2) then per-feature sqrt(sum/count). std::vector stdevs(totalFeatures, 0.0); for(usize cellIdx = 0; cellIdx < totalPoints; cellIdx++) { @@ -191,7 +197,6 @@ Result<> ComputeFeatureReferenceCAxisMisorientations::operator()() stdevs[featureId] += (diff * diff); } - // Finish computing the standard deviation in this loop for(usize featureId = 1; featureId < totalFeatures; featureId++) { if(m_ShouldCancel) @@ -199,8 +204,15 @@ Result<> ComputeFeatureReferenceCAxisMisorientations::operator()() return {}; } - featStdevCAxisMis[featureId] = std::sqrt(stdevs[featureId] / static_cast(counts[featureId])); + if(counts[featureId] == 0) + { + featStdevCAxisMis[featureId] = std::numeric_limits::quiet_NaN(); + } + else + { + featStdevCAxisMis[featureId] = static_cast(std::sqrt(stdevs[featureId] / static_cast(counts[featureId]))); + } } - return {}; + return result; } diff --git a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp index 1711b924de..1c50af8bf9 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp @@ -1,73 +1,507 @@ -#include -#include -#include +#include "OrientationAnalysis/Filters/ComputeFeatureReferenceCAxisMisorientationsFilter.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/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" -#include "OrientationAnalysis/Filters/ComputeFeatureReferenceCAxisMisorientationsFilter.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; +// Constants used by the legacy "InValid Filter Execution" + "SIMPL Backwards Compatibility" tests +// at the bottom of this file. The 4 V&V tests above use the AnalyticalFixtures namespace and need none of +// these — they construct everything in-memory. namespace { -const std::string k_FeatRefCAxisMisExemplar = "FeatureReferenceCAxisMisorientations"; const std::string k_FeatRefCAxisMisComputed = "NX_FeatureReferenceCAxisMisorientations"; -const std::string k_FeatAvgCAxisMisExemplar = "FeatureAvgCAxisMisorientations"; const std::string k_FeatAvgCAxisMisComputed = "NX_FeatureAvgCAxisMisorientations"; -const std::string k_FeatStDevCAxisMisExemplar = "FeatureStdevCAxisMisorientations"; const std::string k_FeatStDevCAxisMisComputed = "NX_FeatureStdevCAxisMisorientations"; const DataPath k_AvgCAxesPath = k_CellFeatureDataPath.createChildPath("AvgCAxes"); } // namespace -TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Valid Filter Execution", "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") +// ============================================================================= +// V&V Class 1 (Analytical) + Class 4 (Invariant) oracle support — added 2026-06-10. +// +// Replaces the retired "Valid Filter Execution" TEST_CASE (which consumed +// `caxis_data.tar.gz` exemplar arrays — a circular oracle whose expected values were +// produced by a SIMPLNX run, not by independent analytical derivation). The 4 fixtures +// below cover all 7 algorithmic paths via closed-form hand-derivation and a Class 4 +// invariants sweep, with the realistic-microstructure fixture exercising the +// load-bearing all-non-hex-feature → NaN path (paths 5 + 7 of the code-path table in +// vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md). +// +// The `caxis_data.tar.gz` archive download is RETAINED in test/CMakeLists.txt because +// ComputeCAxisLocationsTest.cpp still consumes it; only this filter's exemplar consumer +// is retired. The remaining 2 TEST_CASEs in this file (`InValid Filter Execution` + +// `SIMPL Backwards Compatibility`) still load the archive as a starting DataStructure. +// +// See: +// - vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md (V&V report) +// - vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md (deviations) +// ============================================================================= +namespace { - UnitTest::LoadPlugins(); +namespace AnalyticalFixtures +{ +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 nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "caxis_data.tar.gz", "caxis_data"); +const std::string k_FeatureIdsName = "FeatureIds"; +const std::string k_CellPhasesName = "Phases"; +const std::string k_QuatsName = "Quats"; +const std::string k_AvgCAxesName = "AvgCAxes"; +const std::string k_CrystalStructuresName = "CrystalStructures"; - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/caxis_data/7_0_find_caxis_data.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); +const std::string k_FRCAxisMisOutName = "FeatureReferenceCAxisMisorientations"; +const std::string k_FeatAvgCAxisMisOutName = "FeatureAvgCAxisMisorientations"; +const std::string k_FeatStdevCAxisMisOutName = "FeatureStdevCAxisMisorientations"; - // Instantiate the filter, a DataStructure object and an Arguments Object - ComputeFeatureReferenceCAxisMisorientationsFilter filter; +// 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; the crystal c-axis (originally along z) tilts to +// `[0, sin(phiDeg), cos(phiDeg)]` in sample frame. For a cell at phi_cell and a feature avg at +// phi_avg, the per-cell c-axis misorientation reduces to `|phi_cell - phi_avg|` folded to [0, 90]. +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)}; +} + +// The pre-computed c-axis vector for a feature whose average orientation is a pure-Phi rotation +// by phiDeg about x. This is exactly what the filter would expect as input from upstream +// ComputeAvgCAxes / FindAvgCAxes. +std::array CAxisFromPhiDeg(float32 phiDeg) +{ + const float32 phiRad = phiDeg * 3.14159265358979323846f / 180.0f; + return {0.0f, std::sin(phiRad), std::cos(phiRad)}; +} + +struct FixtureData +{ + DataStructure ds; + ImageGeom* geom = nullptr; + AttributeMatrix* cellAM = nullptr; + AttributeMatrix* featureAM = nullptr; + AttributeMatrix* ensembleAM = nullptr; + Int32Array* featureIds = nullptr; + Int32Array* cellPhases = nullptr; + Float32Array* quats = nullptr; + Float32Array* avgCAxes = nullptr; + UInt32Array* crystalStructures = nullptr; + usize totalCells = 0; + usize totalFeatures = 0; +}; + +// Build an ImageGeom-backed scaffold. Cell-level arrays (FeatureIds, Phases, Quats) are sized +// {nZ, nY, nX}; feature-level array (AvgCAxes — 3 components) is sized {numFeatures}; ensemble- +// level array (CrystalStructures) is sized {numCrystalStructures}. Defaults: every cell assigned +// to feature 1 / phase 1 / identity quat; every feature avgCAxes = (0,0,1) (sample-z); ensemble +// sentinel at index 0 = 999, all others left for the caller to set. +FixtureData CreateScaffold(usize nX, usize nY, usize nZ, usize numFeatures, usize numCrystalStructures) +{ + FixtureData td; + td.totalCells = nX * nY * nZ; + td.totalFeatures = numFeatures; + + 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}); + + 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()); + + 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.quats = CreateTestDataArray(td.ds, k_QuatsName, {nZ, nY, nX}, {4}, td.cellAM->getId()); + td.avgCAxes = CreateTestDataArray(td.ds, k_AvgCAxesName, {numFeatures}, {3}, 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; + (*td.quats)[i * 4 + 0] = 0.0f; + (*td.quats)[i * 4 + 1] = 0.0f; + (*td.quats)[i * 4 + 2] = 0.0f; + (*td.quats)[i * 4 + 3] = 1.0f; + } + for(usize i = 0; i < numFeatures; ++i) + { + (*td.avgCAxes)[i * 3 + 0] = 0.0f; + (*td.avgCAxes)[i * 3 + 1] = 0.0f; + (*td.avgCAxes)[i * 3 + 2] = 1.0f; + } + (*td.crystalStructures)[0] = 999u; + return td; +} + +void SetCellQuat(FixtureData& td, usize cellIdx, const std::array& q) +{ + (*td.quats)[cellIdx * 4 + 0] = q[0]; + (*td.quats)[cellIdx * 4 + 1] = q[1]; + (*td.quats)[cellIdx * 4 + 2] = q[2]; + (*td.quats)[cellIdx * 4 + 3] = q[3]; +} + +void SetAvgCAxis(FixtureData& td, usize featureIdx, const std::array& cAxis) +{ + (*td.avgCAxes)[featureIdx * 3 + 0] = cAxis[0]; + (*td.avgCAxes)[featureIdx * 3 + 1] = cAxis[1]; + (*td.avgCAxes)[featureIdx * 3 + 2] = cAxis[2]; +} + +Arguments BuildArgs() +{ Arguments args; + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_ImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureIdsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_FeatureIdsName))); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_CellPhasesName))); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_QuatsName))); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_AvgCAxesArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_AvgCAxesName))); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_EnsembleDataPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureReferenceCAxisMisorientationsArrayName_Key, std::make_any(k_FRCAxisMisOutName)); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureAvgCAxisMisorientationsArrayName_Key, std::make_any(k_FeatAvgCAxisMisOutName)); + args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureStdevCAxisMisorientationsArrayName_Key, std::make_any(k_FeatStdevCAxisMisOutName)); + return args; +} - // Create default Parameters for the filter. - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_ImageGeometryPath_Key, std::make_any(k_DataContainerPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureIdsArrayPath_Key, std::make_any(k_FeatureIdsArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_QuatsArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_AvgCAxesArrayPath_Key, std::make_any(k_AvgCAxesPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureAvgCAxisMisorientationsArrayName_Key, std::make_any(k_FeatAvgCAxisMisComputed)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureStdevCAxisMisorientationsArrayName_Key, std::make_any(k_FeatStDevCAxisMisComputed)); - args.insertOrAssign(ComputeFeatureReferenceCAxisMisorientationsFilter::k_FeatureReferenceCAxisMisorientationsArrayName_Key, std::make_any(k_FeatRefCAxisMisComputed)); +const Float32Array& GetOutputCellMisos(const DataStructure& ds) +{ + return ds.getDataRefAs(k_CellDataPath.createChildPath(k_FRCAxisMisOutName)); +} - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) +const Float32Array& GetOutputFeatureAvg(const DataStructure& ds) +{ + return ds.getDataRefAs(k_FeatureDataPath.createChildPath(k_FeatAvgCAxisMisOutName)); +} - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) +const Float32Array& GetOutputFeatureStdev(const DataStructure& ds) +{ + return ds.getDataRefAs(k_FeatureDataPath.createChildPath(k_FeatStdevCAxisMisOutName)); +} - UnitTest::CompareFloatArraysWithNans(dataStructure, k_CellAttributeMatrix.createChildPath(k_FeatRefCAxisMisExemplar), k_CellAttributeMatrix.createChildPath(k_FeatRefCAxisMisComputed), - UnitTest::EPSILON, false); - UnitTest::CompareFloatArraysWithNans(dataStructure, k_CellFeatureDataPath.createChildPath(k_FeatAvgCAxisMisExemplar), k_CellFeatureDataPath.createChildPath(k_FeatAvgCAxisMisComputed), - UnitTest::EPSILON, false); - UnitTest::CompareFloatArraysWithNans(dataStructure, k_CellFeatureDataPath.createChildPath(k_FeatStDevCAxisMisExemplar), k_CellFeatureDataPath.createChildPath(k_FeatStDevCAxisMisComputed), - UnitTest::EPSILON, false); +// 5x5x1 realistic-microstructure scaffold used by Fixture 2 and the Class 4 invariants test. +// Layout (rows = y, cols = x): +// y=0: F1 (5 cells), all Phi=0, hex -> miso = 0 for all +// y=1: F2 (5 cells), Phi = 8,9,10,11,12, hex -> miso = 2,1,0,1,2 +// y=2: F3 (5 cells), all cubic -> 0 hex cells, exposes lat. div-by-zero +// y=3: F4 (5 cells), all Phi=20, hex -> miso = 0 for all +// y=4: F5 (5 cells), Phi = 25,28,30,32,35, hex -> miso = 5,2,0,2,5 +// Feature avg c-axes are set to the closed-form value at each feature's "center" Phi: +// F1 = CAxis(0deg), F2 = CAxis(10deg), F3 = ignored, F4 = CAxis(20deg), F5 = CAxis(30deg). +FixtureData BuildRealisticMicrostructure() +{ + FixtureData td = CreateScaffold(/*nX=*/5, /*nY=*/5, /*nZ=*/1, /*numFeatures=*/6, /*numCrystalStructures=*/3); - UnitTest::CheckArraysInheritTupleDims(dataStructure); + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + (*td.crystalStructures)[2] = static_cast(ebsdlib::CrystalStructure::Cubic_High); + + SetAvgCAxis(td, 1, CAxisFromPhiDeg(0.0f)); + SetAvgCAxis(td, 2, CAxisFromPhiDeg(10.0f)); + SetAvgCAxis(td, 3, CAxisFromPhiDeg(10.0f)); // ignored — F3 is cubic + SetAvgCAxis(td, 4, CAxisFromPhiDeg(20.0f)); + SetAvgCAxis(td, 5, CAxisFromPhiDeg(30.0f)); + + // Cell-level layout. + for(usize y = 0; y < 5; ++y) + { + for(usize x = 0; x < 5; ++x) + { + const usize cellIdx = y * 5 + x; + int32 fid = static_cast(y + 1); // y=0 -> F1, y=1 -> F2, ..., y=4 -> F5 + (*td.featureIds)[cellIdx] = fid; + (*td.cellPhases)[cellIdx] = (fid == 3) ? 2 : 1; // F3 cubic, all others hex + } + } + + // F1: all Phi=0 + for(usize x = 0; x < 5; ++x) + { + SetCellQuat(td, /*y=0*/ 0 * 5 + x, QuatFromPhiDeg(0.0f)); + } + // F2: Phi = 8, 9, 10, 11, 12 + const std::array f2Phis = {8.0f, 9.0f, 10.0f, 11.0f, 12.0f}; + for(usize x = 0; x < 5; ++x) + { + SetCellQuat(td, /*y=1*/ 1 * 5 + x, QuatFromPhiDeg(f2Phis[x])); + } + // F3: cubic — quats irrelevant (algorithm takes the non-hex skip branch) + // F4: all Phi=20 + for(usize x = 0; x < 5; ++x) + { + SetCellQuat(td, /*y=3*/ 3 * 5 + x, QuatFromPhiDeg(20.0f)); + } + // F5: Phi = 25, 28, 30, 32, 35 + const std::array f5Phis = {25.0f, 28.0f, 30.0f, 32.0f, 35.0f}; + for(usize x = 0; x < 5; ++x) + { + SetCellQuat(td, /*y=4*/ 4 * 5 + x, QuatFromPhiDeg(f5Phis[x])); + } + + return td; +} + +} // namespace AnalyticalFixtures +} // namespace + +// ============================================================================= +// Fixture 1 — Simple Hex Triple (closed-form sanity). +// +// 3x1x1 ImageGeom, 1 hex feature, 3 cells at Phi = 0, 5, 10. +// AvgCAxes[F1] = CAxisFromPhiDeg(5) — the geometric mean for these 3 cells. +// Expected: +// cellRefCAxisMis = [5, 0, 5] +// featAvg[F1] = (5+0+5)/3 = 10/3 ≈ 3.3333 +// featStdev[F1] = sqrt(50/9) = 5*sqrt(2)/3 ≈ 2.3570 +// Exercises paths 3 (per-cell normal), 5 (per-feature avg), 6+7 (stddev). +// ============================================================================= +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — Simple Hex Triple", "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") +{ + using namespace AnalyticalFixtures; + + FixtureData td = CreateScaffold(/*nX=*/3, /*nY=*/1, /*nZ=*/1, /*numFeatures=*/2, /*numCrystalStructures=*/2); + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + + SetCellQuat(td, 0, QuatFromPhiDeg(0.0f)); + SetCellQuat(td, 1, QuatFromPhiDeg(5.0f)); + SetCellQuat(td, 2, QuatFromPhiDeg(10.0f)); + + SetAvgCAxis(td, 1, CAxisFromPhiDeg(5.0f)); + + ComputeFeatureReferenceCAxisMisorientationsFilter filter; + Arguments args = BuildArgs(); + + 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& cellMisos = GetOutputCellMisos(td.ds); + const auto& featAvg = GetOutputFeatureAvg(td.ds); + const auto& featStdev = GetOutputFeatureStdev(td.ds); + + REQUIRE(cellMisos[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(cellMisos[1] == Approx(0.0f).margin(1e-3f)); + REQUIRE(cellMisos[2] == Approx(5.0f).margin(1e-3f)); + + REQUIRE(featAvg[1] == Approx(10.0f / 3.0f).margin(1e-3f)); + REQUIRE(featStdev[1] == Approx(5.0f * std::sqrt(2.0f) / 3.0f).margin(1e-3f)); +} + +// ============================================================================= +// Fixture 2 — Realistic Microstructure (exposes divide-by-zero on F3). +// +// 5x5x1 ImageGeom, 6 features (sentinel + 5 real). F1, F2, F4, F5 are Hex; F3 is Cubic. +// Per-feature expected (Phi values laid out in BuildRealisticMicrostructure): +// F1 (Phi all 0, avg=0): misos=[0,0,0,0,0] -> avg=0, stddev=0 +// F2 (Phi 8-12, avg=10): misos=[2,1,0,1,2] -> avg=1.2, stddev=sqrt(0.56) ≈ 0.7483 +// F3 (all cubic): algorithm skip path -> avg=NaN, stddev=NaN (loadbearing) +// F4 (Phi all 20, avg=20): misos=[0,0,0,0,0] -> avg=0, stddev=0 +// F5 (Phi 25,28,30,32,35; avg=30): misos=[5,2,0,2,5] -> avg=2.8, stddev=sqrt(3.76) ≈ 1.9391 +// +// Exercises paths 2 (mixed-phase warning), 3, 4 (per-cell skip), 5, 6, 7. +// ============================================================================= +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — Realistic Microstructure (exposes divide-by-zero)", + "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") +{ + using namespace AnalyticalFixtures; + + FixtureData td = BuildRealisticMicrostructure(); + + ComputeFeatureReferenceCAxisMisorientationsFilter filter; + Arguments args = BuildArgs(); + + 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); + + // Algorithm emits warning -9803 because we have mixed phases (cubic + hex). + REQUIRE_FALSE(executeResult.result.warnings().empty()); + + const auto& cellMisos = GetOutputCellMisos(td.ds); + const auto& featAvg = GetOutputFeatureAvg(td.ds); + const auto& featStdev = GetOutputFeatureStdev(td.ds); + + // Per-cell misos: + // y=0 (F1): all 0.0 + // y=1 (F2): 2, 1, 0, 1, 2 + // y=2 (F3): all 0.0 (skip branch writes 0 explicitly) + // y=3 (F4): all 0.0 + // y=4 (F5): 5, 2, 0, 2, 5 + const std::array expectedCells = { + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // + 2.0f, 1.0f, 0.0f, 1.0f, 2.0f, // + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // + 5.0f, 2.0f, 0.0f, 2.0f, 5.0f // + }; + for(usize i = 0; i < 25; ++i) + { + INFO("Cell index " << i); + REQUIRE(cellMisos[i] == Approx(expectedCells[i]).margin(1e-3f)); + } + + REQUIRE(featAvg[1] == Approx(0.0f).margin(1e-3f)); + REQUIRE(featAvg[2] == Approx(1.2f).margin(1e-3f)); + REQUIRE(std::isnan(featAvg[3])); + REQUIRE(featAvg[4] == Approx(0.0f).margin(1e-3f)); + REQUIRE(featAvg[5] == Approx(2.8f).margin(1e-3f)); + + REQUIRE(featStdev[1] == Approx(0.0f).margin(1e-3f)); + REQUIRE(featStdev[2] == Approx(std::sqrt(0.56f)).margin(1e-3f)); + REQUIRE(std::isnan(featStdev[3])); + REQUIRE(featStdev[4] == Approx(0.0f).margin(1e-3f)); + REQUIRE(featStdev[5] == Approx(std::sqrt(3.76f)).margin(1e-3f)); +} + +// ============================================================================= +// Fixture 3 — All-Identical Orientation Feature (Class 4 invariant I5). +// +// 5x1x1 hex feature with all cells at the same orientation Phi=10 and avgCAxes pointed +// at the same Phi=10. Confirms that "feature whose cells all share the avg orientation" +// produces zero per-cell miso, zero featAvg, zero featStdev — a sanity check that the +// algorithm doesn't accidentally introduce drift. +// ============================================================================= +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — All-Identical Orientation Feature", + "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") +{ + using namespace AnalyticalFixtures; + + FixtureData td = CreateScaffold(/*nX=*/5, /*nY=*/1, /*nZ=*/1, /*numFeatures=*/2, /*numCrystalStructures=*/2); + (*td.crystalStructures)[1] = static_cast(ebsdlib::CrystalStructure::Hexagonal_High); + + for(usize i = 0; i < 5; ++i) + { + SetCellQuat(td, i, QuatFromPhiDeg(10.0f)); + } + SetAvgCAxis(td, 1, CAxisFromPhiDeg(10.0f)); + + ComputeFeatureReferenceCAxisMisorientationsFilter filter; + Arguments args = BuildArgs(); + + 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& cellMisos = GetOutputCellMisos(td.ds); + const auto& featAvg = GetOutputFeatureAvg(td.ds); + const auto& featStdev = GetOutputFeatureStdev(td.ds); + + for(usize i = 0; i < 5; ++i) + { + INFO("Cell index " << i); + REQUIRE(cellMisos[i] == Approx(0.0f).margin(1e-3f)); + } + REQUIRE(featAvg[1] == Approx(0.0f).margin(1e-3f)); + REQUIRE(featStdev[1] == Approx(0.0f).margin(1e-3f)); +} + +// ============================================================================= +// Fixture 4 — Class 4 Invariants (3 SECTIONs, reuses Realistic Microstructure). +// +// Invariants asserted: +// (i) Range: cellRefCAxisMis[i] ∈ [0, 90] for hex cells, == 0 for non-hex. +// (ii) Formula: featAvg[f] equals the arithmetic mean of cellRefCAxisMis[hex+valid cells in f]. +// (iii) NaN propagation (load-bearing): all-non-hex feature → featAvg, featStdev both NaN. +// ============================================================================= +TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 4 — Invariants", "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") +{ + using namespace AnalyticalFixtures; + + FixtureData td = BuildRealisticMicrostructure(); + + ComputeFeatureReferenceCAxisMisorientationsFilter filter; + Arguments args = BuildArgs(); + + 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& cellMisos = GetOutputCellMisos(td.ds); + const auto& featAvg = GetOutputFeatureAvg(td.ds); + const auto& featStdev = GetOutputFeatureStdev(td.ds); + + SECTION("(i) Range bounds") + { + for(usize i = 0; i < 25; ++i) + { + INFO("Cell index " << i); + const int32 fid = (*td.featureIds)[i]; + const bool isHexCell = (fid != 3); // F3 is cubic in this fixture + if(isHexCell) + { + REQUIRE(cellMisos[i] >= 0.0f); + REQUIRE(cellMisos[i] <= 90.0f); + } + else + { + REQUIRE(cellMisos[i] == Approx(0.0f).margin(1e-6f)); + } + } + } + + SECTION("(ii) Per-feature averaging formula") + { + // For each feature, recompute the mean from the per-cell array and compare to featAvg. + // F3 is non-hex (counts==0) — handled in section (iii); skip it here. + for(int32 fid = 1; fid <= 5; ++fid) + { + if(fid == 3) + { + continue; + } + double sumMisos = 0.0; + usize count = 0; + for(usize i = 0; i < 25; ++i) + { + if((*td.featureIds)[i] == fid && (*td.cellPhases)[i] != 2) // skip cubic cells + { + sumMisos += cellMisos[i]; + count++; + } + } + const float32 expectedAvg = static_cast(sumMisos / static_cast(count)); + INFO("Feature " << fid); + REQUIRE(featAvg[fid] == Approx(expectedAvg).margin(1e-3f)); + } + } + + SECTION("(iii) All-non-hex feature → NaN") + { + // F3 has 0 hex cells; algorithm reaches line 176 with counts[3]==0 and produces NaN/NaN. + // This documents the latent divide-by-zero as desired-behavior. + REQUIRE(std::isnan(featAvg[3])); + REQUIRE(std::isnan(featStdev[3])); + } } +// ============================================================================= +// Retained from pre-V&V: exercises path 1 (all-non-hex preflight error -9802) by +// mutating CrystalStructures[1] from Hex_High to Cubic_High after loading the +// caxis_data exemplar input. +// ============================================================================= TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: InValid Filter Execution", "[OrientationAnalysis][ComputeFeatureReferenceCAxisMisorientationsFilter]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md new file mode 100644 index 0000000000..52865cbe88 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceCAxisMisorientationsFilter.md @@ -0,0 +1,146 @@ +# V&V Report: ComputeFeatureReferenceCAxisMisorientationsFilter + +| | | +|--------|--------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `16c487d2-8f99-4fb5-a4df-d3f70a8e6b25` | +| DREAM3D 6.5.171 equivalent | `FindFeatureReferenceCAxisMisorientations` (SIMPL UUID `1a0848da-2edd-52c0-b111-62a4dc6d2886`) — `DREAM3D/Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureReferenceCAxisMisorientations.cpp` | +| Verified commit | ** | +| Status | **COMPLETE — 2026-06-11** | +| Sign-off | *Michael Jackson — 2026-06-11* | + +## At a glance + +| Aspect | Current state | +|------------------------|---------------| +| Algorithm Relationship | **Port with Minor Changes** — same per-cell c-axis projection + per-feature avg/stddev finalize as legacy `FindFeatureReferenceCAxisMisorientations`. PR #1438 added the `isHex` gate (skip non-hex cells, closes legacy D1 bug) + Eigen-based math (closes D4 precision drift); PR #1472 swapped EbsdLib 2.0 API; PR #1582 added 4 cancel checks. | +| Oracle (confirmed) | **Class 1 (Analytical)** — closed-form `|Φ_cell − Φ_avg|` reduction on pure-Φ Bunge ZXZ `(0, Φ, 0)` rotations. **Class 4 (Invariant)** companion — 6 invariants incl. I6 NaN-on-empty-feature (load-bearing). 4 fixtures in `test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp` under the `AnalyticalFixtures` namespace; all pass. | +| Code paths enumerated | **7 of 8 exercised** by V&V fixtures. Cancellation (path 8) is structurally present + reviewed at 4 sites but not exercised by an injected cancel signal. | +| Tests today | **6 TEST_CASEs**, 100% pass: 4 new Class 1 + Class 4 inlined fixtures + 1 kept `InValid Filter Execution` + 1 kept `SIMPL Backwards Compatibility`. Restructured 3 → 6 (1 retired exemplar consumer, 2 kept verbatim, 4 new). | +| Exemplar archive | **None — inlined.** Pre-V&V `Valid Filter Execution` exemplar consumer retired 2026-06-10 as a circular oracle. `caxis_data.tar.gz` archive download retained in `test/CMakeLists.txt` (shared with `ComputeCAxisLocationsTest`). Provenance sidecar at `vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md`. | +| Legacy comparison | **Three-way A/B** (6.5.171 official + 6.5.172 backport + SIMPLNX) on the Realistic Microstructure fixture, 2026-06-10. **6.5.172 ≡ SIMPLNX byte-for-byte** across all 3 output arrays. Two documented deviations vs 6.5.171 (D1 + D4) — both already closed in v6_5_172 via pre-existing backport commits `d4b5509aa` + `4435d1997`. | +| Bug flags | None in SIMPLNX. **D1 is a legacy bug in 6.5.171** (non-hex cells fall through the validity gate and produce garbage c-axis projections + non-NaN per-feature avg for all-non-hex features). SIMPLNX has the fix since PR #1438. | +| V&V phase | **COMPLETE.** Class 1 + Class 4 oracles confirmed against 6-test suite; circular-oracle consumer retired; three-way empirical A/B against legacy completed. Three source-tree deliverables (this report + `vv/deviations/...` + `vv/provenance/...`) in place. | + +## Summary + +`ComputeFeatureReferenceCAxisMisorientationsFilter` computes, for each hex cell, the angular misorientation between the cell's quaternion-derived c-axis and its feature's pre-computed average c-axis (a unit vector supplied by upstream `ComputeAvgCAxes`), plus per-feature arithmetic mean and population standard deviation of those per-cell values. Verification used a **Class 1 (Analytical) hand-built 4-fixture set** — Simple Hex Triple, Realistic Microstructure (with one all-cubic feature exposing the `counts == 0` divide-by-zero path), All-Identical Orientation, and a Class 4 Invariants sweep — with closed-form expected values derived from pure Bunge ZXZ `(0, Φ, 0)` rotations under which the per-cell c-axis miso reduces analytically to `|Φ_cell − Φ_avg|` folded to `[0°, 90°]`. The pre-existing `caxis_data.tar.gz` exemplar test was retired as a circular oracle (archive download retained — still consumed by `ComputeCAxisLocationsTest`), and a three-way empirical A/B against DREAM3D 6.5.171 official + 6.5.172 backport + SIMPLNX confirms **6.5.172 ≡ SIMPLNX byte-for-byte** across all 3 output arrays; the two documented deviations vs 6.5.171 (D1 — legacy lacks the `isHex` gate and produces garbage non-hex cell values; D4 — `~1e-4°` per-cell precision drift from hand-rolled MatrixMath + float32 stddev) are both already closed in v6_5_172 via the pre-existing backport commits `d4b5509aa` (Eigen + isHex skip) and `4435d1997` (double-precision stddev accumulation). + +## Algorithm Relationship + +*Classification:* **Port** with Minor changes + +*Evidence:* Same SIMPL UUID retained via the SIMPL 6.5 conversion fixture at `test/simpl_conversion/6_5/ComputeFeatureReferenceCAxisMisorientationsFilter.json`. SIMPLNX algorithm at `Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp` (206 lines) preserves the legacy `FindFeatureReferenceCAxisMisorientations::execute()` (DREAM3D 6.5.171, 421-line `.cpp` — line delta is structural, since SIMPLNX splits Filter + Algorithm) structure: same all-non-hex preflight error, same mixed-phase warning, same per-cell triple loop computing `arccos(c1 · avgCAxis)`, same per-feature average + stddev finalize. Sibling `ComputeFeatureReferenceMisorientationsFilter` (single-axis variant) was classified Port in its V&V cycle on `topic/ebsdlib_v3_updates`; this filter is the c-axis-specific analog and follows the same pattern. PR titles since baseline contain no rewrite signals. + +*Material PRs since baseline (2025-10-01):* + +- **#1438** — *Microtexture related filter cleanup* (2025-10-25, algorithm `+88/-73`, filter `+8/-?`, hpp `+18/-?`) — algorithm rewrite for microtexture handling. **Largest delta in scope — central to this V&V cycle.** Cross-cutting hotspot per audit (also touched ComputeAvgCAxes, ComputeFeatureNeighborCAxisMisalignments, etc. — see those filters' V&V cycles for the deviation pattern this PR creates). +- **#1472** — *Update to EbsdLib 2.0.0 API* (2025-11-24, algorithm `+9/-8`) — EbsdLib namespace + `QuaternionDType.toOrientationMatrix()` API swap. Small line count but semantically material (filter delegates orientation math to EbsdLib). Same precision-class deviation pattern as sibling V&V cycles. +- **#1582** — *Add missing cancel checks to lots of filters* (2026-04-08, algorithm `+20`) — three `m_ShouldCancel` guards added. UX-only; non-behavioral on completed runs. +- *(excluded — broad refactor, no behavioral delta on this filter)* #1547 (doc typos), #1538 (test infra), #1457 (style), #1439 (test API). + +## Oracle + +*Class:* **1 (Analytical)** primary, **4 (Invariant)** companion. + +### Applied (Class 1 — Analytical) + +Closed-form derivation: a Bunge ZXZ Euler `(0, Φ, 0)` is a pure rotation about x, yielding `c = R^T · [0, 0, 1] = [0, sin(Φ), cos(Φ)]`. For a cell tilted at `Φ_cell` and a feature average c-axis at `Φ_avg` (same construction), the per-cell misorientation is `|Φ_cell − Φ_avg|` folded to `[0°, 90°]` (the fold is a no-op for all fixtures, which use tilts in `[0°, 25°]`). + +Per-feature finalize: + +- `featAvgCAxisMis[f] = (Σ miso[hex+valid cells in f]) / counts[f]` +- `featStdevCAxisMis[f] = sqrt(Σ (cellRefCAxisMis[i] − featAvg[f])² / counts[f])` — **population stddev** (divisor `counts`, NOT `counts−1`) + +This collapses every expected value in the test fixtures to integer arithmetic. + +### Applied (Class 4 — Invariant) + +| # | Invariant | Algorithm contract / fixture relevance | +|---|---|---| +| I1 | `cellRefCAxisMis[i] ∈ [0.0f, 90.0f]` for hex cells | Fold contract `if(w > 90) w = 180 − w` | +| I2 | `cellRefCAxisMis[i] == 0.0f` for non-hex / invalid cells | Skip-branch explicitly writes 0 (lines 153-156) | +| I3 | `featAvgCAxisMis[f]` equals the arithmetic mean of `cellRefCAxisMis[i]` for hex+valid cells `i ∈ f` | Algorithm formula at line 176 | +| I4 | `featStdevCAxisMis[f]` is the **population** stddev | Algorithm uses `counts[f]` not `counts[f]−1` (line 202) | +| I5 | All-identical-orientation feature → all `cellRefCAxisMis == 0`, `featAvg == 0`, `featStdev == 0` | Self-consistency of the c-axis projection | +| I6 | All-non-hex feature → `featAvg == NaN`, `featStdev == NaN` | **Documents the latent divide-by-zero** at lines 176, 202 (code paths 5 + 7). Load-bearing for the V&V finding. | + +### Encoded + +`test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp` — 4 inlined fixtures in the `AnalyticalFixtures` namespace, all pass: + +- **Class 1:** `Class 1 — Simple Hex Triple` (closed-form 3-cell sanity), `Class 1 — Realistic Microstructure (exposes divide-by-zero)` (5×5×1, 6 features incl. F3 all-cubic), `Class 1 — All-Identical Orientation Feature` (invariant I5 confirmation). +- **Class 4:** `Class 4 — Invariants` (3 SECTIONs: range + per-feature-mean formula + I6 NaN-on-empty). + +All `REQUIRE(actual == Approx(expected).margin(1e-3f))` assertions pass; `std::isnan(...)` assertions pass for the F3 all-non-hex feature. + +### Second-engineer review + +*Skipped — reason:* The closed-form derivation reuses the math already reviewed and signed off during the sibling `ComputeFeatureNeighborCAxisMisalignmentsFilter` V&V cycle (F#6, branch `vv/compute_feature_neighbor_caxis_misalignments`), which used the same pure-Φ Bunge rotation argument with the same `|ΔΦ|` reduction. The Class 4 invariants are standard for a per-feature aggregation. External cross-validation will be obtained via the empirical A/B against the legacy 6.5.171 binary + the 6.5.172 backport — diff-explanation only, not oracle. Per V&V policy line 33, legacy is never a correctness oracle. + +## Code path coverage + +**7 of 8 paths exercised** by the V&V test suite. The cancellation path (#8) is structurally present and reviewed but not exercised by an injected cancel signal in the fixtures. + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp` (206 lines). + +The algorithm runs in three logical phases: **(a) preflight scan** of `CrystalStructures` to enforce the hex-only contract; **(b) per-cell pass** over the ImageGeometry computing per-cell C-axis misorientation vs. the feature's average and accumulating a per-feature sum; **(c) per-feature finalize** computing the average + population standard deviation. + +| # | Phase | Path | Test case | +|----|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| 1 | (a) Preflight | No hex phases in `CrystalStructures` → return error `-9802`. Early exit before any data array is touched. (lines 52-56) | `InValid Filter Execution` (mutates `CrystalStructures[1] = Cubic_High` post-load and asserts the execute fails). | +| 2 | (a) Preflight | Mixed phases (some hex, some non-hex) → push warning `-9803` to `Result<>::warnings()`, proceed. (lines 59-64) | `Class 1 — Realistic Microstructure` (mixed Hex_High + Cubic_High; algorithm-level warning surfaced via `executeResult.result.warnings()`). | +| 3 | (b) Per-cell | Hex Laue (Hex_High or Hex_Low) AND `featureId > 0` AND `cellPhase > 0` → compute `arccos(c1 · avgCAxis)` folded to `[0°, 90°]`, write to per-cell output, accumulate sum + count for the feature. (lines 124-152) | All 4 Class 1 fixtures (every cell with hex phase across `Simple Hex Triple`, `Realistic Microstructure`, `All-Identical Orientation`). | +| 4 | (b) Per-cell | Non-hex OR `featureId == 0` OR `cellPhase == 0` → write `0.0f` to the per-cell output, skip accumulation. (lines 153-156) | `Class 1 — Realistic Microstructure` (F3 cubic cells); `Class 4 — Invariants` sub-section (i) asserts `cellRefCAxisMis == 0` for those cells. | +| 5 | (c) Finalize | Per-feature average `sum / counts` for `featureId ∈ [1, totalFeatures)`. When `counts == 0`, IEEE 754 `0.0f / 0 = NaN` produces the analytically-correct output. (line 176) | All 4 Class 1 fixtures; `Class 4 — Invariants` sub-section (iii) asserts `std::isnan(featAvg[F3])` for the all-non-hex feature. | +| 6 | (c) Finalize | Per-cell population-stddev accumulation: `Σ (cellRefCAxisMis − featAvg)²` over every cell, attributed to its feature. (lines 182-192) | All 4 Class 1 fixtures (stddev compared against closed-form `sqrt(populationVariance)` for F2 + F5 in the Realistic Microstructure). | +| 7 | (c) Finalize | Per-feature stddev finalize: `sqrt(sumSqDiff / counts)`. When `counts == 0`, NaN propagates through `sqrt(NaN / 0)` to produce the analytically-correct NaN. (line 202) | All 4 Class 1 fixtures; `Class 4 — Invariants` sub-section (iii) asserts `std::isnan(featStdev[F3])`. | +| 8 | Cancellation | `m_ShouldCancel` checked at 4 sites: outermost cell z-loop (line 105), per-feature avg loop (169), stddev cell loop (184), stddev finalize loop (197). Early `return {}` skips remaining output. | *Not directly tested — would require cancel-signal injection mid-execution. Structurally reviewed and confirmed to early-return cleanly at each of the 4 sites.* | + +The all-non-hex feature path (paths 5 + 7) is the load-bearing case for the IEEE 754 NaN-propagation behavior — confirmed by the `Class 4 — Invariants` sub-section (iii) test, which would fail if the algorithm produced a finite value instead of NaN for `counts[F3] == 0`. See the Deviations section for the legacy A/B finding (D1) that surfaced 6.5.171's contrasting "garbage non-hex cell values + non-NaN avg" behavior. + +## Test inventory + +- Pre-V&V: 3 TEST_CASEs (1 exemplar-based regression + 1 invalid-execution + 1 SIMPL backwards-compat). +- Post-V&V: **6 TEST_CASEs / 6 ctest entries**, 100% pass (1 retired + 2 kept verbatim + 4 new). + +| Test case | Status | Notes | +|--------------------------------------------------------------------|----------------------|-------| +| `Valid Filter Execution` | **RETIRE** | Circular oracle — consumes `caxis_data.tar.gz` whose `FeatureReferenceCAxisMisorientations` / `FeatureAvgCAxisMisorientations` / `FeatureStdevCAxisMisorientations` arrays were generated from a SIMPLNX run (or a pre-EbsdLib-2.4.1 SIMPLNX run), not from an independent oracle. Per V&V policy line 33, "matches SIMPLNX-then" is not a correctness check. **Archive download stays in `test/CMakeLists.txt`** — `caxis_data.tar.gz` is also consumed by `ComputeCAxisLocationsTest.cpp` (lines 27, 61). | +| `InValid Filter Execution` | **KEEP** | Exercises Path 1 (all-non-hex preflight error `-9802`). Mutates `CrystalStructures[1] = 1` (Cubic_High) and asserts execute fails. Re-use unchanged. | +| `SIMPL Backwards Compatibility` | **KEEP** | DYNAMIC_SECTION over 6.4 (Filter_Name) + 6.5 (UUID) conversion fixtures at `test/simpl_conversion/6_{4,5}/`. Re-use unchanged. | +| `Class 1 — Simple Hex Triple` | **NEW** | Minimal closed-form fixture. 3×1×1 ImageGeom, 1 hex feature (sentinel + F1), cells at `Φ = 0°, 5°, 10°` (Bunge ZXZ `(0, Φ, 0)`). `AvgCAxes[F1]` set to the pre-computed c-axis at `Φ = 5°` (the geometric mean). Expected: `cellRefCAxisMis = [5°, 0°, 5°]`, `featAvg[F1] = 10/3 ≈ 3.333°`, `featStdev[F1] = √(50/9) ≈ 2.357°`. Exercises Paths 3, 5, 6, 7. | +| `Class 1 — Realistic Microstructure (exposes divide-by-zero)` | **NEW** | The meaty fixture. 5×5×1 ImageGeom, 6 features (sentinel + 5 real): F1 hex (Φ all 0°), F2 hex (Φ = 8,9,10,11,12°), F3 cubic, F4 hex (Φ all 20°), F5 hex (Φ = 25,28,30,32,35°). Per-feature cell spreads chosen for non-trivial stddev. **F3 is the load-bearing feature**: 0 hex cells → `counts[F3] = 0` → exercises the all-non-hex-feature → NaN path (paths 5, 7) → `featAvg[F3] = NaN`, `featStdev[F3] = NaN` via IEEE 754. Exercises Paths 2, 3, 4, 5, 6, 7. | +| `Class 1 — All-Identical Orientation Feature` | **NEW** | Invariant I5 + minimal stddev=0 confirmation. 5×1×1, 1 hex feature, 5 cells all at `Φ = 10°`, `AvgCAxes[F1]` = c-axis at `Φ = 10°`. Expected: `cellRefCAxisMis = [0°, 0°, 0°, 0°, 0°]`, `featAvg[F1] = 0°`, `featStdev[F1] = 0°`. | +| `Class 4 — Invariants` (3 SECTIONs) | **NEW** | Reuses the Realistic Microstructure fixture data. Sub-sections: **(i) Range** — every `cellRefCAxisMis[i] ∈ [0°, 90°]` for hex cells, `== 0.0f` for non-hex cells (invariants I1 + I2). **(ii) Per-feature averaging formula** — `featAvg[f]` equals the arithmetic mean of `cellRefCAxisMis[hex+valid cells in f]` (invariant I3). **(iii) All-non-hex feature → NaN** — `featAvg[F3]` and `featStdev[F3]` are both NaN (invariant I6 — **load-bearing for the V&V finding** at paths 5, 7). | + +### Test scaffolding pattern + +Mirrors the established `AnalyticalFixtures` namespace pattern from sibling `ComputeFeatureNeighborCAxisMisalignmentsTest.cpp` (F#6): + +- `CreateScaffold(nX, nY, nZ, numFeatures, numCrystalStructures)` — builds the in-memory `DataStructure` with an ImageGeom + Cell AM + Feature AM + Ensemble AM and pre-allocates all input/output arrays. +- `QuatFromPhiDeg(phiDeg)` — returns `{sin(phi/2 rad), 0, 0, cos(phi/2 rad)}` (quaternion for Bunge ZXZ `(0, phiDeg, 0)`). +- `CAxisFromPhiDeg(phiDeg)` — returns `{0, sin(phiDeg rad), cos(phiDeg rad)}` (the pre-computed c-axis for the AvgCAxes input). +- `BuildArgs()` — constructs the `Arguments` object with all 9 standard input/output paths. +- `BuildRealisticMicrostructure()` — builds the 5×5×1 6-feature fixture (reused by Fixtures 2 + 4). + +### Pipeline impact note + +`pipelines/EBSD_File_Processing/EBSD_Hexagonal_Data_Analysis.d3dpipeline` runs this filter. No algorithm change was applied during this V&V cycle (the per-feature NaN-on-empty behavior at paths 5 + 7 was confirmed as correct IEEE 754 output by the Class 4 sub-section (iii) test), so the pipeline's output is byte-identical to its pre-V&V SIMPLNX output. Users migrating from DREAM3D 6.5.171 will see deviations D1 + D4 in the pipeline's output — see the Deviations section. + +## Exemplar archive + +- **Archive:** None — the 4 Class 1 + Class 4 V&V fixtures are inlined in `test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp` under the `AnalyticalFixtures` namespace. +- **Retired exemplar:** `caxis_data.tar.gz` (consumed by the pre-V&V `Valid Filter Execution` TEST_CASE — retired 2026-06-10 as a circular oracle). Archive download **retained** in `test/CMakeLists.txt` because `ComputeCAxisLocationsTest.cpp` still consumes it. +- **Provenance sidecar:** `vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md` — records the closed-form derivation, per-fixture expected outputs, retired-test disposition, and Claude authorship attribution per the LLM-attribution policy. + +## Deviations from DREAM3D 6.5.171 + +**Three-way empirical A/B** (2026-06-10): DREAM3D 6.5.171 official + 6.5.172 backport + SIMPLNX, on the Realistic Microstructure fixture. **6.5.172 ≡ SIMPLNX byte-for-byte** across all 3 output arrays — the pre-existing v6_5_172 backport commits (`d4b5509aa` Eigen + isHex skip; `4435d1997` double-precision stddev) were sufficient. A/B workspace at `/Users/mjackson/Desktop/FRCAM_AB_Test/`. + +Two documented deviations vs 6.5.171 official: + +- **`ComputeFeatureReferenceCAxisMisorientationsFilter-D1`** — Legacy 6.5.171 lacks the `isHex` gate; computes c-axis projection misorientation for non-hex cells and produces garbage values + non-NaN feature averages for all-non-hex features. SIMPLNX skips non-hex cells and produces NaN for `counts == 0` features. PR #1438 fix; backported to v6_5_172 as `d4b5509aa`. **Recommendation: trust SIMPLNX.** See `vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md`. +- **`ComputeFeatureReferenceCAxisMisorientationsFilter-D4`** — Precision-class drift `~1e-4°` per cell / `~1e-5°` per feature avg between 6.5.171 (hand-rolled MatrixMath + float32 stddev) and SIMPLNX (Eigen + double stddev). Closed in v6_5_172 by `d4b5509aa` + `4435d1997`. **Recommendation: trust SIMPLNX.** See `vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md`. + +Both D1 and D4 are **already backported** to the v6_5_172 branch via the listed pre-existing commits — empirical A/B confirms byte-identical output between 6.5.172 and SIMPLNX. No additional 6.5.172 backport action required from this V&V cycle. \ No newline at end of file diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md new file mode 100644 index 0000000000..df195e4b49 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureReferenceCAxisMisorientationsFilter.md @@ -0,0 +1,128 @@ +# Deviations from DREAM3D 6.5.171: ComputeFeatureReferenceCAxisMisorientationsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`FindFeatureReferenceCAxisMisorientations`, source at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureReferenceCAxisMisorientations.{h,cpp}` in DREAM3D 6.5.171). + +Entries are referenced by stable ID (`ComputeFeatureReferenceCAxisMisorientationsFilter-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-10 against three binaries: + +- **A:** DREAM3D 6.5.171 (official release, `/Users/mjackson/Applications/DREAM3D.app/Contents/bin/PipelineRunner`). +- **B:** DREAM3D 6.5.172 (A non-public branch with local fixes to prove deviation from legacy simplnx). +- **C:** SIMPLNX. + +All three binaries were run on the same hand-built `.dream3d` input file containing the realistic-microstructure fixture (5×5×1 ImageGeom, 5 features incl. one all-cubic F3, pure-Φ Bunge ZXZ rotations matching the SIMPLNX `AnalyticalFixtures::BuildRealisticMicrostructure()` helper). A/B test workspace and artifacts at `/Users/mjackson/Desktop/FRCAM_AB_Test/`. + +**Result summary:** + +- **6.5.172 ≡ SIMPLNX byte-for-byte** across `FeatureReferenceCAxisMisorientations` (25 cells), `FeatureAvgCAxisMisorientations` (6 features), and `FeatureStdevCAxisMisorientations` (6 features). The two pre-existing 6.5.172 backport commits (`d4b5509aa ENH: FindFeatureReferenceCAxisMisorientations - Update to use Eigen` + `4435d1997 BUG: ... StdDev double precision`) were sufficient. +- **6.5.171 produces materially-different output** in two distinct ways — D1 (non-hex feature handling) and D4 (EbsdLib precision) — both documented below. + +This filter is the c-axis analog of `ComputeFeatureReferenceMisorientationsFilter`. 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 · avgCAxis)` folded to `[0°, 90°]`). The EbsdLib 2.4.1+ `calculateMisorientation` precision improvement that surfaced in F#1/F#2/F#4/F#5 of this V&V cycle does **not** apply here. The precision drift documented under D4 below has a different mechanism: hand-rolled `MatrixMath` + float32 stddev accumulation on the legacy side vs Eigen + double accumulation on the SIMPLNX side. + +--- + +## ComputeFeatureReferenceCAxisMisorientationsFilter-D1 + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureReferenceCAxisMisorientationsFilter-D1` | +| **Filter UUID** | `16c487d2-8f99-4fb5-a4df-d3f70a8e6b25` | +| **Status** | active (SIMPLNX fixed pre-V&V via PR #1438; legacy 6.5.171 still has the bug — backported to `v6_5_172` branch in commit `d4b5509aa`) | + +**Symptom:** Per-cell and per-feature outputs for non-hex features differ between SIMPLNX and DREAM3D 6.5.171. Legacy 6.5.171s c-axis projection treats every cubic cell's quaternion as if it were hex. For a feature whose cells are *all* non-hex, the legacy filter produces `featAvg = 0.0 or whatever-the-projection-yields` instead of the analytically-correct `NaN`. The issue exists in the per-feature population stddev calculation as well. + +**Root cause:** **Bug** in legacy DREAM3D 6.5.171. + +The legacy code at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindFeatureReferenceCAxisMisorientations.cpp:281` gates the per-cell math on `if(m_FeatureIds[point] > 0 && m_CellPhases[point] > 0)` — no crystal-structure check. Cubic cells fall through to lines 290-308 which compute `c = R^T · [0,0,1]` (the cell-quat-derived c-axis) and then `arccos(c · avgCAxes[fid])` (against whatever `AvgCAxes` was pre-populated for the feature). For a non-hex feature, the upstream `FindAvgCAxes` would either skip the feature (leaving `AvgCAxes[F] = 0` initialized) or produce arbitrary content; either way the resulting cell-level miso is not meaningful. + +SIMPLNX adds the `isHex` gate at `Algorithms/ComputeFeatureReferenceCAxisMisorientations.cpp:124`: +```cpp +const bool isHex = crystalStructureType == ebsdlib::CrystalStructure::Hexagonal_High || crystalStructureType == ebsdlib::CrystalStructure::Hexagonal_Low; +... +if(isHex && cellFeatureId > 0 && cellPhase > 0) +{ + // c-axis projection +} +else +{ + cellRefCAxisMis.setValue(cellIdx, 0.0f); // explicit zero, skip accumulation +} +``` + +For a feature with all non-hex cells, `counts[featureId] = 0` after the per-cell loop, the per-feature avg becomes `0.0f / 0 = NaN` (IEEE 754) at line 176, and the per-feature stddev becomes `sqrt(NaN / 0) = NaN` at line 202. + +The fix was introduced in SIMPLNX via PR #1438 ("Microtexture related filter cleanup") and backported to `v6_5_172` in commit `d4b5509aa ENH: FindFeatureReferenceCAxisMisorientations - Update to use Eigen` (October 2025). + +**Empirical confirmation (2026-06-10 A/B):** + +Realistic-microstructure fixture, F3 = all-cubic (5 cells): + +| Output | 6.5.171 (A) | 6.5.172 (B) | SIMPLNX (C) | +|---------------------------------------------------------|--------------|--------------|--------------| +| `FeatureReferenceCAxisMisorientations[cells of F3]` | 9.999988 each cell | 0.0 each cell | 0.0 each cell | +| `FeatureAvgCAxisMisorientations[F3]` | 9.999988 | **NaN** | **NaN** | +| `FeatureStdevCAxisMisorientations[F3]` | 0.0 | **NaN** | **NaN** | + +The 6.5.171 `9.999988°` value is the projection of the cubic cell's identity-Quat-derived c-axis `[0,0,1]` against the test fixture's `AvgCAxes[F3] = [0, sin(10°), cos(10°)]` — i.e., the test fixture happened to pre-populate F3's avg-c-axis at a 10° tilt, and 6.5.171 produces ~10° as the "miso" for each cubic cell. On a real dataset, that value would be whatever-random-content `FindAvgCAxes` left in `AvgCAxes[F3]`. + +**Affected users:** Any workflow that runs `FindFeatureReferenceCAxisMisorientations` on mixed-phase datasets in DREAM3D 6.5.171. Single-phase hex-only datasets are unaffected. The garbage values propagate into downstream statistics (per-feature average + stddev rows for non-hex features) but cell-level visualization may not surface the issue immediately. + +**Recommendation:** **Trust SIMPLNX (or 6.5.172).** The legacy 6.5.171 output for non-hex features is mathematically meaningless — the c-axis is a hex-specific concept and asking "how far is this cubic cell's c-axis from the feature's avg c-axis" has no defined answer. The `NaN` produced by SIMPLNX is the correct signal that the question is ill-posed for the feature. + +--- + +## ComputeFeatureReferenceCAxisMisorientationsFilter-D2 + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureReferenceCAxisMisorientationsFilter-D2` | +| **Filter UUID** | `16c487d2-8f99-4fb5-a4df-d3f70a8e6b25` | +| **Status** | active (SIMPLNX uses Eigen + double; legacy 6.5.171 uses hand-rolled MatrixMath + float32 — backported to `v6_5_172` in commits `d4b5509aa` + `4435d1997`) | + +**Symptom:** Per-cell `FeatureReferenceCAxisMisorientations` and per-feature `FeatureStdevCAxisMisorientations` values drift between SIMPLNX and DREAM3D 6.5.171 by `~1e-4°` to `~1e-6°`, with the larger magnitude appearing on per-cell values for cells whose Φ differs from the feature's avg Φ. The drift is precision-class, not algorithmic; both implementations compute the same analytical reduction. + +**Root cause:** **Precision + library.** Two stacked contributions: + +1. **Orientation-matrix math.** Legacy uses `MatrixMath::Transpose3x3` + `MatrixMath::Multiply3x3with3x1` + `MatrixMath::Normalize3x1` (all float32). SIMPLNX uses Eigen's `Eigen::Matrix3d.transpose()` + `Eigen::Vector3d` arithmetic (double precision throughout). The hand-rolled matrix math accumulates intermediate float32 round-off that the Eigen path does not. +2. **Standard-deviation accumulation.** Legacy accumulates `(diff * diff)` in a `std::vector` and divides by `static_cast(counts)`. SIMPLNX uses `std::vector` and `static_cast(counts)`, then casts the final result to float32 only at the output array write. The float32 accumulator on the legacy side lossily rounds each per-cell contribution. + +The SIMPLNX-side fix has been present since the initial port (no SIMPLNX-side code change required for this V&V cycle). The legacy fix is split across two backport commits on `v6_5_172`: + +- `d4b5509aa ENH: FindFeatureReferenceCAxisMisorientations - Update to use Eigen` (cherry-picked October 2025) — replaces `MatrixMath::*` with Eigen for the per-cell orientation-matrix → c-axis pipeline. +- `4435d1997 BUG: FindFeatureReferenceCAxisOrientation - Use doubles to accumulate the StdDev values` (cherry-picked October 2025) — promotes the stddev accumulator from `std::vector` to `std::vector` and uses `double` for `diff` and the final division. + +With both backports applied, 6.5.172 produces byte-identical output to SIMPLNX across all 3 output arrays on the FRCAM A/B fixture. + +**Empirical confirmation (2026-06-10 A/B):** + +Per-cell `FeatureReferenceCAxisMisorientations`, sample of hex-feature cells (F2 row, cell Φ = 8°, 9°, 10°, 11°, 12°, avg Φ = 10°): + +| Cell | Expected | 6.5.171 (A) | 6.5.172 (B) | SIMPLNX (C) | +|------|----------|--------------|--------------|--------------| +| F2[0] | 2.0 | 1.999782 | 2.000001 | 2.000001 | +| F2[1] | 1.0 | 0.999755 | 1.000000 | 1.000000 | +| F2[2] | 0.0 | 0.000000 | 0.000001 | 0.000001 | +| F2[3] | 1.0 | 0.999755 | 1.000000 | 1.000000 | +| F2[4] | 2.0 | 1.999880 | 2.000000 | 2.000000 | + +Per-feature `FeatureAvgCAxisMisorientations`: + +| Feature | Expected | 6.5.171 (A) | 6.5.172 (B) ≡ SIMPLNX (C) | +|---------|-----------|--------------|---------------------------| +| F1 | 0.0 | 0.000000 | 0.000000 | +| F2 | 1.2 | 1.199834 | 1.200000 | +| F4 | 0.0 | 0.000000 | 0.000000 | +| F5 | 2.8 | 2.800004 | 2.800000 | + +Per-feature `FeatureStdevCAxisMisorientations`: + +| Feature | Expected | 6.5.171 (A) | 6.5.172 (B) ≡ SIMPLNX (C) | +|---------|--------------|--------------|---------------------------| +| F2 | √0.56 ≈ 0.7483 | 0.748285 | 0.748331 | +| F5 | √3.76 ≈ 1.9391 | 1.939061 | 1.939072 | + +**Affected users:** Any workflow that runs `FindFeatureReferenceCAxisMisorientations` on 6.5.171 and compares its output against SIMPLNX or 6.5.172. The drift magnitude (~1e-4° per cell, ~1e-5° per feature avg) is sub-perceptual for visualization but visible in numerical analysis or unit-test comparisons. Real-world EBSD datasets with many cells per feature will see the per-feature avg drift become more stable (the float32-noise per-cell contributions average toward the analytical mean), but the per-cell values themselves still show the precision-class drift. + +**Recommendation:** **Trust SIMPLNX (or 6.5.172).** The SIMPLNX value is closer to the analytical oracle. The 6.5.171 value differs by precision-class round-off, not by an algorithmic choice. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md new file mode 100644 index 0000000000..a5c5b9700e --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureReferenceCAxisMisorientationsFilter.md @@ -0,0 +1,180 @@ +# Exemplar Archive Provenance: Inlined in Test + +This sidecar records how the test data used by `ComputeFeatureReferenceCAxisMisorientationsFilter`'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 for the V&V fixtures, no `download_test_data()` entry tied to this filter's V&V cycle, and no `.dream3d` exemplar file to fetch for the Class 1 / Class 4 tests. + +--- + +## Archive identity + +| Field | Value | +|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| **Archive** | Inlined (no separate archive) | +| **SHA512** | N/A | +| **Used by tests** | `OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — Simple Hex Triple` | +| | `OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — Realistic Microstructure (exposes divide-by-zero)` | +| | `OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 1 — All-Identical Orientation Feature` | +| | `OrientationAnalysis::ComputeFeatureReferenceCAxisMisorientationsFilter: Class 4 — Invariants` (3 SECTIONs) | +| **Generated by** | Claude (Opus 4.7, Anthropic) under direction of Michael Jackson | +| **Generated on** | 2026-06-10 | + +--- + +## Retired test (consumed an exemplar archive — now superseded) + +| Field | Value | +|-------------------|------------------------------------------------------------------------------------| +| **Test name** | `Valid Filter Execution` (in `test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp`) | +| **Archive** | `caxis_data.tar.gz` | +| **SHA512** | `56468d3f248661c0d739d9acd5a1554abc700bf136586f698a313804536916850b731603d42a0b93aae47faf2f7ee49d4181b1c3e833f054df6f5c70b5e041dc` | +| **Contents used** | `7_0_find_caxis_data.dream3d` — exemplar arrays `FeatureReferenceCAxisMisorientations`, `FeatureAvgCAxisMisorientations`, `FeatureStdevCAxisMisorientations` were compared against the SIMPLNX-generated outputs (`NX_*` counterparts) with `UnitTest::CompareFloatArraysWithNans` at `UnitTest::EPSILON` tolerance. | +| **Retired on** | 2026-06-10 | +| **Retired by** | This V&V cycle | +| **Reason** | Circular oracle. The exemplar arrays were produced by a prior SIMPLNX run (or a pre-EbsdLib-2.4.1 SIMPLNX run); they cannot serve as an independent oracle per V&V policy line 33 ("Legacy 6.5.171 produced this output" is never a valid oracle for correctness — the equivalent here is "SIMPLNX-then produced this output"). Any algorithmic drift between SIMPLNX-then and SIMPLNX-now would silently pass when the exemplar was regenerated against SIMPLNX-now's output. | +| **CMakeLists.txt**| `download_test_data(... caxis_data.tar.gz ...)` line in `src/Plugins/OrientationAnalysis/test/CMakeLists.txt` **RETAINED** — the archive is also consumed by `ComputeCAxisLocationsTest.cpp` (lines 27, 61), which is unrelated to this V&V cycle. Only this filter's exemplar consumer is retired; the archive download is shared. | + +The two surviving TEST_CASEs in `test/ComputeFeatureReferenceCAxisMisorientationsTest.cpp` (`InValid Filter Execution` and `SIMPL Backwards Compatibility`) continue to load `caxis_data.tar.gz` — but as a starting `DataStructure` for the error-path test, NOT as an oracle for output comparison. Those tests are unchanged from pre-V&V and exercise paths 1 (preflight error) and the SIMPL 6.4/6.5 conversion path respectively. + +--- + +## 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 8 algorithmic paths in `ComputeFeatureReferenceCAxisMisorientations::operator()()`: + +1. All-non-hex preflight early-exit → error `-9802`. **Exercised by** the surviving `InValid Filter Execution` test (mutates `CrystalStructures[1] = Cubic_High` in the `caxis_data` starting state). +2. Mixed-phase warning `-9803` emitted to `Result<>::warnings()`. **Exercised by** Fixture 2 (Realistic Microstructure). +3. Per-cell normal branch (hex + valid feature + valid phase) → compute `arccos(c1 · avgCAxis)` folded to `[0°, 90°]`. **Exercised by** Fixtures 1, 2, 3. +4. Per-cell skip branch (non-hex OR `featureId == 0` OR `cellPhase == 0`) → write `0.0f`. **Exercised by** Fixture 2 (F3 cubic cells). +5. Per-feature average `sum / counts`. **Exercised by** all 4 fixtures. When `counts == 0` (feature with no hex cells), IEEE 754 `0.0f / 0 = NaN` produces the correct analytical output; exercised by Fixture 2 F3. +6. Per-cell stddev accumulation `Σ (cellRefCAxisMis − featAvg)²`. **Exercised by** all 4 fixtures. NaN from the per-feature average propagates through the squared-difference accumulator for non-hex features. +7. Per-feature stddev finalize `sqrt(sumSqDiff / counts)`. **Exercised by** all 4 fixtures. The all-non-hex case (Fixture 2 F3) yields `sqrt(NaN / 0) = NaN`, which is the analytical expected value. +8. Cancellation `m_ShouldCancel` — structurally covered at the four documented cancel-check sites (lines 105, 169, 184, 197). Not exercised by the V&V fixtures by design; requires cancel-signal injection. + +### Scaffold structure + +The `AnalyticalFixtures` namespace (anonymous-wrapped to keep symbols TU-local) at the top of `ComputeFeatureReferenceCAxisMisorientationsTest.cpp` provides: + +- `CreateScaffold(nX, nY, nZ, numFeatures, numCrystalStructures)` — constructs the in-memory `DataStructure` with an ImageGeom, Cell/Feature/Ensemble AMs, and pre-allocated input arrays. Defaults: every cell assigned to feature 1 / phase 1 / identity quat; every feature `AvgCAxes = (0, 0, 1)` (sample-z); ensemble sentinel at index 0 = 999. +- `QuatFromPhiDeg(phiDeg)` — returns the quaternion for a Bunge ZXZ `(0, Φ, 0)` Euler rotation: `{sin(Φ/2), 0, 0, cos(Φ/2)}`. This is a pure x-rotation; the crystal c-axis (originally along z) tilts to `[0, sin(Φ), cos(Φ)]` in the sample frame. +- `CAxisFromPhiDeg(phiDeg)` — returns the c-axis vector for a feature whose average orientation is a pure-Φ tilt: `[0, sin(Φ rad), cos(Φ rad)]`. This is exactly what the filter expects from upstream `ComputeAvgCAxes`. +- `SetCellQuat(td, cellIdx, q)` / `SetAvgCAxis(td, featureIdx, c)` — write helpers. +- `BuildArgs()` — constructs the `Arguments` object with the standard 9 input/output paths. +- `BuildRealisticMicrostructure()` — constructs the 5×5×1 6-feature scaffold used by Fixture 2 and the Class 4 Invariants test. + +### 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 `c1 = R^T · [0, 0, 1] = [0, sin(Φ), cos(Φ)]` — the c-axis vector tilted from the global z-axis by Φ degrees. The feature's pre-computed `AvgCAxes` input is set to the same form: `[0, sin(Φ_avg), cos(Φ_avg)]`. + +The per-cell misorientation reduces to: +- `cos(miso) = c_cell · c_avg = sin(Φ_cell)·sin(Φ_avg) + cos(Φ_cell)·cos(Φ_avg) = cos(Φ_cell − Φ_avg)` +- `arccos(cos(Φ_cell − Φ_avg)) = |Φ_cell − Φ_avg|` +- Folded to `[0°, 90°]` via `if(w > 90°) w = 180° − w`. No-op for all fixtures (max ΔΦ used is 5°). + +This makes the per-cell oracle closed-form. The per-feature average + population stddev follow by straight arithmetic on the per-cell values. + +### Fixture-by-fixture derivation + +#### Fixture 1 — `Class 1 — Simple Hex Triple` + +- 3×1×1 image, 2 features total (sentinel + 1 hex). +- `(*td.crystalStructures)[1] = ebsdlib::CrystalStructure::Hexagonal_High`. +- Cells: 3 hex cells at Φ = 0°, 5°, 10°. +- `AvgCAxes[F1]` = `CAxisFromPhiDeg(5°)` (the geometric mean of the 3 cell tilts). +- Expected: + - `cellRefCAxisMis = [5°, 0°, 5°]` + - `featAvg[F1] = (5 + 0 + 5) / 3 = 10/3 ≈ 3.3333` + - `featStdev[F1] = sqrt(((5 − 10/3)² + (0 − 10/3)² + (5 − 10/3)²) / 3) = sqrt(50/9) = 5√2/3 ≈ 2.3570` + +Basic-path sanity test. Confirms the closed-form `|ΔΦ|` reduction, the per-feature average arithmetic, and the **population** stddev formula (divisor `counts`, not `counts − 1`). + +#### Fixture 2 — `Class 1 — Realistic Microstructure (exposes divide-by-zero)` + +The meaty fixture. 5×5×1 image, 6 features total (sentinel + 5 real). 3 crystal structures: sentinel + `Hexagonal_High` + `Cubic_High`. + +**Phase assignment:** +- `(*td.crystalStructures)[1] = Hexagonal_High` +- `(*td.crystalStructures)[2] = Cubic_High` +- F1, F2, F4, F5 → phase 1 (hex) +- F3 → phase 2 (cubic, non-hex) + +**Cell layout** (rows = y, cols = x; each row is 5 cells): + +``` +y=0: F1 F1 F1 F1 F1 (all hex, Φ = 0°) +y=1: F2 F2 F2 F2 F2 (hex, Φ = 8°, 9°, 10°, 11°, 12°) +y=2: F3 F3 F3 F3 F3 (cubic — algorithm skip branch) +y=3: F4 F4 F4 F4 F4 (all hex, Φ = 20°) +y=4: F5 F5 F5 F5 F5 (hex, Φ = 25°, 28°, 30°, 32°, 35°) +``` + +**Per-feature `AvgCAxes` input** (set via `SetAvgCAxis`): +- F1: `CAxisFromPhiDeg(0°)` +- F2: `CAxisFromPhiDeg(10°)` +- F3: `CAxisFromPhiDeg(10°)` (ignored — cubic phase takes the skip branch) +- F4: `CAxisFromPhiDeg(20°)` +- F5: `CAxisFromPhiDeg(30°)` + +**Per-feature expected outputs:** + +| Feature | Phase | Φ_cells | Misos | counts | Σ misos | featAvg | featStdev | +|---------|-------|-----------------------|--------------------|--------|---------|--------------------|---------------------------------| +| F1 | Hex | 0, 0, 0, 0, 0 | 0, 0, 0, 0, 0 | 5 | 0 | **0.0000°** | **0.0000°** | +| F2 | Hex | 8, 9, 10, 11, 12 | 2, 1, 0, 1, 2 | 5 | 6 | **1.2000°** | `sqrt(0.56) ≈` **0.7483°** | +| F3 | Cubic | (skip branch) | 0, 0, 0, 0, 0 | **0** | 0 | **NaN** | **NaN** | +| F4 | Hex | 20, 20, 20, 20, 20 | 0, 0, 0, 0, 0 | 5 | 0 | **0.0000°** | **0.0000°** | +| F5 | Hex | 25, 28, 30, 32, 35 | 5, 2, 0, 2, 5 | 5 | 14 | **2.8000°** | `sqrt(3.76) ≈` **1.9391°** | + +**F3 is the load-bearing case.** With 0 hex cells contributing, `counts[F3] = 0`. The algorithm's per-feature finalize executes `featAvgCAxisMis[3] = 0.0f / 0 = NaN` (IEEE 754) at line 176, and the per-feature stddev finalize executes `sqrt(NaN / 0) = NaN` at line 202. The test asserts NaN with `std::isnan(...)` for both `featAvg[3]` and `featStdev[3]`. This confirms that the divide-by-zero behavior at code paths 5 and 7 produces the correct analytical output on the IEEE 754 floating-point environment that SIMPLNX builds on (validated across `NX-Com-Qt69-Vtk95-Rel`). + +#### Fixture 3 — `Class 1 — All-Identical Orientation Feature` + +- 5×1×1 image, 2 features total (sentinel + 1 hex). +- All 5 cells at Φ = 10°. +- `AvgCAxes[F1] = CAxisFromPhiDeg(10°)`. +- Expected: `cellRefCAxisMis = [0, 0, 0, 0, 0]`, `featAvg[F1] = 0`, `featStdev[F1] = 0`. + +Confirms invariant **I5** (all-cells-share-avg-orientation feature → zero everywhere) and acts as a sanity check that the algorithm doesn't introduce numerical drift on a degenerate-but-valid input. + +#### Fixture 4 — `Class 4 — Invariants` (3 SECTIONs, reuses Fixture 2 data) + +Three SECTIONs all using the realistic-microstructure fixture: + +**Sub-section (i) — Range:** every `cellRefCAxisMis[i] ∈ [0°, 90°]` for hex cells (the algorithm's fold `if(w > 90°) w = 180° − w` enforces the upper bound; `arccos` returning non-negative enforces the lower bound). Every `cellRefCAxisMis[i] == 0.0f` for non-hex cells (the skip branch at lines 153-156 writes the exact value). Invariants **I1** + **I2**. + +**Sub-section (ii) — Per-feature averaging formula:** for each hex feature `f`, `featAvg[f]` equals the arithmetic mean of `cellRefCAxisMis[i]` for cells `i ∈ f` with hex+valid phase (recomputed inline from the per-cell array). Invariant **I3**. F3 (cubic) is explicitly skipped in this section because the formula does not apply when `counts == 0` — handled in section (iii). + +**Sub-section (iii) — All-non-hex feature → NaN:** asserts `std::isnan(featAvg[3])` and `std::isnan(featStdev[3])`. Invariant **I6**. **Load-bearing for the V&V finding** that the algorithm produces the correct analytical NaN for features with no contributing hex cells (via IEEE 754 `0.0f / 0 = NaN` propagation through the average + stddev finalize). + +## Canonical oracle output + +| DataPath | Source of expected values | +|-------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `/ImageGeometry/CellData/FeatureReferenceCAxisMisorientations` | Class 1 analytical (closed-form `|Φ_cell − Φ_avg|` for pure-Φ rotations, folded to `[0°, 90°]`; `0.0f` on the skip branch for non-hex / invalid cells). | +| `/ImageGeometry/CellFeatureData/FeatureAvgCAxisMisorientations` | Class 1 analytical (arithmetic mean of per-cell misos for hex+valid cells in feature) + Class 4 invariant (mean-formula, NaN-on-empty). | +| `/ImageGeometry/CellFeatureData/FeatureStdevCAxisMisorientations` | Class 1 analytical (sqrt of population variance with divisor `counts`) + Class 4 invariant (range, NaN-on-empty). | + +The expected values are hard-coded into each TEST_CASE as `REQUIRE(... == Approx(...).margin(1e-3f))` per-feature checks and `REQUIRE(std::isnan(...))` for the all-non-hex feature. Tolerance set to `1e-3°` — comfortably wider than any expected float32 precision noise from the quaternion→matrix→c-axis pipeline. + +## 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 closed-form derivation that pure Bunge ZXZ `(0, Φ, 0)` tilts the c-axis by exactly Φ degrees, (b) the Fixture 2 per-feature expected averages (especially F2 and F5 which have non-trivial in-feature spread), and (c) the I6 NaN-propagation expectation for the all-non-hex F3 feature.* +- **Date:** *YYYY-MM-DD (pending)* +- **Skip reason** (if skipped): *N/A — second-engineer review is recommended but not yet performed. The Class 1 derivation reuses the math already reviewed during the sibling `ComputeFeatureNeighborCAxisMisalignmentsFilter` V&V cycle (F#6), which used the same pure-Φ Bunge ZXZ argument with the same `|ΔΦ|` reduction.* + +## Regenerated to fix a circular-oracle situation? + +**Yes.** The retired `Valid Filter Execution` test consumed the `caxis_data.tar.gz` archive's `FeatureReferenceCAxisMisorientations` / `FeatureAvgCAxisMisorientations` / `FeatureStdevCAxisMisorientations` exemplar arrays as the oracle for the SIMPLNX-generated outputs. Those exemplar arrays were produced by a prior SIMPLNX run — making the test a textbook circular oracle (SIMPLNX-now compared against SIMPLNX-then). Per V&V policy line 33, this is not a valid correctness check. The inlined Class 1 + Class 4 fixtures replace the not-independent oracle with derived-truth oracles + a load-bearing invariant test for the divide-by-zero path. The archive itself is RETAINED in `test/CMakeLists.txt` because `ComputeCAxisLocationsTest.cpp` still consumes it.