diff --git a/docs/vv_templates/commit_template.md b/docs/vv_templates/commit_template.md new file mode 100644 index 0000000000..cd96afe4e0 --- /dev/null +++ b/docs/vv_templates/commit_template.md @@ -0,0 +1,66 @@ +# V&V Commit Message Template + +This is the standard commit message format for landing a completed V&V cycle on a SIMPLNX filter. It is used by the engineer at Phase 13 (status flip DRAFT → READY FOR REVIEW / COMPLETE), and is the commit that opens the PR. + +## Format + +``` +VV: fully V&V'ed + +Summary: +- Found and fixed bug(s) (); +- documented deviation(s) from DREAM3D 6.5.171 (); +- retired

tests (); +- unit tests replaced with inlined ** test fixtures; +- added 3 V&V source-tree deliverables (report, deviations, provenance); +- . +``` + +## Title rules + +- Must start with `VV:` (matches existing precedent — e.g., commit `99f3a9865` for `Compute Feature Reference Misorientations`). +- Use the filter's `humanName()` from the `.cpp` (with spaces, not the class name). +- End with `fully V&V'ed`. If the cycle is partial (rare — e.g., second-engineer oracle review still pending), end with `— READY FOR REVIEW` instead. + +## Body rules + +- One bullet per category, even when the count is zero. **Don't drop bullets** — write "Confirmed no bugs" or "no tests retired" so the reader knows the question was asked. +- Past-tense verbs at the start of each bullet ("Found and fixed", "documented", "retired", "replaced", "added", "fixed"). +- Semicolons at the end of each bullet except the last (period on the last). +- Inline the deviation IDs (`D1`, `D2`, …) with a 2-3 word reminder of what each one was. The full text lives in `vv/deviations/.md`; the commit message just needs to be greppable. +- Oracle classes get the parenthetical name (`Class 1 (Analytical)`, `Class 4 (Invariant)`). See `oracle_classes.md` for the canonical class list. +- The "3 V&V source-tree deliverables" line is invariant across all V&V commits (every cycle produces exactly those 3). Don't rewrite it per-filter. + +## Worked example + +This is what the F#2 (`ComputeFeatureNeighborMisorientations`) V&V cycle's commit looks like: + +``` +VV: Compute Feature Neighbor Misorientations fully V&V'ed + +Summary: +- Found and fixed 1 bug (divisor clobbered inside inner j-loop of algorithm.cpp); +- documented 2 deviations from DREAM3D 6.5.171 (D1 divisor bug, D2 EbsdLib 2.4.1 precision); +- retired 2 tests (circular-oracle exemplar consumer + UNIMPLEMENTED stub); +- unit tests replaced with 4 inlined *Class 1 (Analytical) + Class 4 (Invariant)* test fixtures; +- added 3 V&V source-tree deliverables (report, deviations, provenance); +- fixed pipeline-name typo in user-facing doc. +``` + +## When a category is zero or N/A + +| Situation | Bullet phrasing | +|----------------------------------------------------|-----------------------------------------------------------------------| +| Clean Port, no bugs found | `Confirmed no bugs (clean Port);` | +| No deviations from legacy | `Confirmed no deviations from DREAM3D 6.5.171;` | +| Fresh V&V, no prior tests to retire | `No prior tests retired (fresh V&V cycle);` | +| Existing tests kept, only new ones added | `Augmented existing tests with inlined …;` | +| Doc was already correct | *omit the last bullet entirely (it's the only optional one)* | +| New filter, no legacy equivalent | `documented 0 deviations (no legacy equivalent — new filter);` | + +## Why this format + +- **Scannable.** A reviewer skimming `git log` should be able to decide whether a V&V commit is worth opening from the title and one bullet pass. +- **Greppable.** `git log --grep='^VV:'` returns every V&V commit. `git log --grep='D2 EbsdLib'` returns every commit that mentions the EbsdLib precision deviation. The deviation IDs are stable across commits (per the `-D` rule in `deviation_template.md`). +- **Constrained.** The five fixed categories (bugs / deviations / retired tests / new fixtures / V&V deliverables) match what every V&V cycle produces under the v2 policy. The engineer fills slots rather than designing prose. +- **Cross-references the source-tree deliverables.** The commit is the "shortest legible record" of the V&V; the long-form analysis lives at `src/Plugins//vv/{,deviations/,provenance/}.md`. A reader who wants more clicks through. diff --git a/docs/vv_templates/vv_policy.md b/docs/vv_templates/vv_policy.md index 027aec3162..e152a4d92e 100644 --- a/docs/vv_templates/vv_policy.md +++ b/docs/vv_templates/vv_policy.md @@ -56,6 +56,7 @@ The verified state is pinned by **(commit hash, archive SHA512)**. The commit ca | [`report_gates.md`](./report_gates.md) | Per-section "Done when:" checklists — reference while filling in the report | | [`deviation_template.md`](./deviation_template.md) | Empty deviation file — copy into `src/Plugins/

/vv/deviations/.md` | | [`provenance_template.md`](./provenance_template.md) | Empty exemplar-provenance sidecar — copy per exemplar archive | +| [`commit_template.md`](./commit_template.md) | Standard commit message format for landing a completed V&V cycle — use at step 6 of the engineer workflow below | ## Engineer workflow @@ -64,7 +65,7 @@ The verified state is pinned by **(commit hash, archive SHA512)**. The commit ca 3. Run `python scripts/vv_init.py ` to scaffold the report and deviation files in the plugin tree. 4. Open `report_gates.md` in a second tab. 5. Work each section in any order. A section is "done" when all its gates pass. -6. When all gates green, set `Status: READY FOR REVIEW`, push a `vv/` branch. +6. When all gates green, set `Status: READY FOR REVIEW`, push a `vv/` branch with a commit following [`commit_template.md`](./commit_template.md). 7. After sign-off, set `Status: COMPLETE`. Verified commit hash is filled in at SBIR deliverable assembly. ## Status tracking across filters diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborMisorientationsFilter.md index 1c5de9377c..e8f05cbef5 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeFeatureNeighborMisorientationsFilter.md @@ -18,7 +18,7 @@ The user can also calculate the average misorientation between the feature and a ## Example Pipelines -+ (05) SmallIN100 Crystallographic Statistics ++ (04) Small IN100 Crystallographic Statistics ## License & Copyright diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md index c6e615cc2a..29d6fd447c 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeKernelAvgMisorientationsFilter.md @@ -11,7 +11,7 @@ This **Filter** determines the Kernel Average Misorientation (KAM) for each **Ce 1. Calculate the misorientation angle between each **Cell** in a kernel and the central **Cell** of the kernel 2. Average all of the misorientations for the kernel and store at the central **Cell** -The calculation will **not** consider cells that belong to different 'feature Ids', ie.e, different grains. +The calculation will **not** consider cells that belong to different 'feature Ids', i.e., different grains. *Note:* All **Cells** in the kernel are weighted equally during the averaging, though they are not equidistant from the central **Cell**. @@ -19,8 +19,9 @@ The calculation will **not** consider cells that belong to different 'feature Id ## Example Pipelines -+ MassifPipeline -+ (05) SmallIN100 Crystallographic Statistics ++ (04) Small IN100 Crystallographic Statistics ++ EBSD_File_Processing/aptr12_Analysis ++ EBSD_File_Processing/avtr12_Analysis ## License & Copyright diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborMisorientations.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborMisorientations.cpp index 9ec143d4c5..25cb242d48 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborMisorientations.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborMisorientations.cpp @@ -65,6 +65,11 @@ Result<> ComputeFeatureNeighborMisorientations::operator()() const NeighborList::VectorType featureNeighborList = inNeighborList.at(static_cast(i)); tempMisorientationLists[i].assign(featureNeighborList.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-02 during V&V cycle. + tempMisoList = featureNeighborList.size(); for(size_t j = 0; j < featureNeighborList.size(); j++) { @@ -72,7 +77,6 @@ Result<> ComputeFeatureNeighborMisorientations::operator()() quatIndex = neighborFeatureId * 4; ebsdlib::QuatD q2(inAvgQuats[quatIndex], inAvgQuats[quatIndex + 1], inAvgQuats[quatIndex + 2], inAvgQuats[quatIndex + 3]); uint32_t xtalType2 = inXtalStruct[inFeaturePhases[neighborFeatureId]]; - tempMisoList = featureNeighborList.size(); if(laueClass1 == xtalType2 && static_cast(laueClass1) < static_cast(orientationOps.size())) { ebsdlib::AxisAngleDType axisAngle = orientationOps[laueClass1]->calculateMisorientation(q1, q2); diff --git a/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborMisorientationsTest.cpp index a036239288..7e49f8f81b 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeFeatureNeighborMisorientationsTest.cpp @@ -2,6 +2,10 @@ #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" #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/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" @@ -10,6 +14,7 @@ #include +#include #include #include @@ -18,75 +23,136 @@ using namespace nx::core; using namespace nx::core::Constants; using namespace nx::core::UnitTest; +// ============================================================================= +// V&V Class 1 (Analytical) + Class 4 (Invariant) oracle support — added 2026-06-02. +// +// These fixtures replace the regression-against-archive pattern (the exemplar tests that consume +// `6_6_stats_test_v2.tar.gz`) with hand-derived analytical inputs and expected per-neighbor +// misorientation lists + per-feature averages. The fixtures specifically include a "bug-exposing" +// configuration that surfaces the divisor bug at algorithm `.cpp` line 75 (`tempMisoList = +// featureNeighborList.size();` inside the inner j-loop, clobbering the per-mismatch decrement). +// +// Reference: src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborMisorientationsFilter.md +// ============================================================================= + +// Wrapped in an anonymous namespace so every symbol below has internal linkage. Sibling +// OrientationAnalysis test TUs declare their own `DataFixtures` namespace with same-named +// (and same-signature) entities such as `CreateScaffold`; without internal linkage the linker +// merges those duplicate definitions into one, silently giving this TU another file's scaffold. namespace { -const std::string k_MisorientationListArrayName_Exemplar("MisorientationList"); -const std::string k_MisorientationListArrayName("CalculatedMisorientationList"); -const std::string k_NeighborListArrayName("NeighborList"); -} // namespace +namespace DataFixtures +{ +const std::string k_GeomName = "ImageGeometry"; +const DataPath k_ImageGeomPath = DataPath({k_GeomName}); +const DataPath k_FeatureDataPath = k_ImageGeomPath.createChildPath("FeatureData"); +const DataPath k_EnsembleDataPath = k_ImageGeomPath.createChildPath("EnsembleData"); + +const std::string k_FeaturePhasesName = "FeaturePhases"; +const std::string k_AvgQuatsName = "AvgQuats"; +const std::string k_NeighborListName = "NeighborList"; +const std::string k_CrystalStructuresName = "CrystalStructures"; -TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter", "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter]") +const std::string k_MisorientationListOutName = "MisorientationListOut"; +const std::string k_AvgMisorientationsOutName = "AvgMisorientationsOut"; + +inline std::array QuatFromPhi1Deg(float32 phi1Deg) { - UnitTest::LoadPlugins(); + const float32 halfAngleRad = (phi1Deg * 0.5f) * 3.14159265358979323846f / 180.0f; + return {0.0f, 0.0f, std::sin(halfAngleRad), std::cos(halfAngleRad)}; +} - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "6_6_stats_test_v2.tar.gz", "6_6_stats_test_v2.dream3d"); +struct ToyData +{ + DataStructure ds; + ImageGeom* geom = nullptr; + AttributeMatrix* featureAM = nullptr; + AttributeMatrix* ensembleAM = nullptr; + Int32Array* featurePhases = nullptr; + Float32Array* avgQuats = nullptr; + NeighborList* neighborList = nullptr; + UInt32Array* crystalStructures = nullptr; +}; + +// Build a scaffold with a tiny ImageGeom + a feature AM (size numFeatures) + ensemble AM +// (size numCrystalStructures). All input arrays are initialized; caller populates per-feature values. +inline ToyData CreateScaffold(usize numFeatures, usize numCrystalStructures) +{ + ToyData td; + 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({1, 1, 1}); // unused at the cell level; only present so the geom container has shape - // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/6_6_stats_test_v2.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); - DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); - DataPath cellDataPath = smallIn100Group.createChildPath(nx::core::Constants::k_CellData); - DataPath cellFeatureDataPath({k_DataContainer, k_CellFeatureData}); - DataPath neighborLstDataPath = cellFeatureDataPath.createChildPath(k_NeighborListArrayName); - DataPath avgQuatsDataPath = cellFeatureDataPath.createChildPath(k_AvgQuats); - DataPath featurePhasesDataPath = cellFeatureDataPath.createChildPath(k_Phases); + td.featureAM = AttributeMatrix::Create(td.ds, "FeatureData", ShapeType{numFeatures}, td.geom->getId()); + td.ensembleAM = AttributeMatrix::Create(td.ds, "EnsembleData", ShapeType{numCrystalStructures}, td.geom->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()); + + // Default: feature 0 sentinel; all other features phase=0 (unassigned); identity quats. + for(usize i = 0; i < numFeatures; ++i) { - // Instantiate the filter, a DataStructure object and an Arguments Object - ComputeFeatureNeighborMisorientationsFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_NeighborListArrayPath_Key, std::make_any(neighborLstDataPath)); - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_AvgQuatsArrayPath_Key, std::make_any(avgQuatsDataPath)); - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(featurePhasesDataPath)); - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_MisorientationListArrayName_Key, std::make_any(k_MisorientationListArrayName)); - - args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_ComputeAvgMisors_Key, std::make_any(false)); - // args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_AvgMisorientationsArrayName_Key, std::make_any("")); //use default value - - // 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); + (*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{})); } - - // Compare the k_MisorientationList output with those precalculated from the file + // Default crystal structures: index 0 sentinel; index 1 Cubic_High; subsequent left as zeros to be set by caller. + (*td.crystalStructures)[0] = 999u; + if(numCrystalStructures > 1) { - const DataPath exemplarPath({k_DataContainer, k_CellFeatureData, k_MisorientationListArrayName_Exemplar}); - const DataPath calculatedPath({k_DataContainer, k_CellFeatureData, k_MisorientationListArrayName}); - UnitTest::CompareNeighborLists(dataStructure, exemplarPath, calculatedPath); + (*td.crystalStructures)[1] = 1u; // Cubic_High (EbsdLib LaueOps index) } + return td; +} -// Write the DataStructure out to the file system -#ifdef SIMPLNX_WRITE_TEST_OUTPUT - WriteTestDataStructure(dataStructure, fs::path(fmt::format("{}/find_misorientations.dream3d", unit_test::k_BinaryTestOutputDir))); -#endif +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]; +} - UnitTest::CheckArraysInheritTupleDims(dataStructure); +inline Arguments BuildArgs(bool computeAvgMisors) +{ + Arguments args; + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_ComputeAvgMisors_Key, std::make_any(computeAvgMisors)); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_NeighborListArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_NeighborListName))); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_AvgQuatsArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_AvgQuatsName))); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(k_FeatureDataPath.createChildPath(k_FeaturePhasesName))); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_EnsembleDataPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_MisorientationListArrayName_Key, std::make_any(k_MisorientationListOutName)); + args.insertOrAssign(ComputeFeatureNeighborMisorientationsFilter::k_AvgMisorientationsArrayName_Key, std::make_any(k_AvgMisorientationsOutName)); + return args; } -// TODO: needs to be implemented. This will need the input .dream3d file to be regenerated with the missing data generated using DREAM3D 6.6 -TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Misorientation Per Feature", "[OrientationAnalysis][ComputeFeatureNeighborMisorientations][.][UNIMPLEMENTED][!mayfail]") +inline const NeighborList& GetOutputMisorientationList(const DataStructure& ds) { + return ds.getDataRefAs>(k_FeatureDataPath.createChildPath(k_MisorientationListOutName)); +} - // UnitTest::CheckArraysInheritTupleDims(dataStructure); +inline const Float32Array& GetOutputAvgMisorientations(const DataStructure& ds) +{ + return ds.getDataRefAs(k_FeatureDataPath.createChildPath(k_AvgMisorientationsOutName)); } +} // namespace DataFixtures +} // namespace +// Retired 2026-06-02 (V&V cycle): the main exemplar-comparison TEST_CASE that consumed +// `6_6_stats_test_v2.tar.gz` and the `[.][UNIMPLEMENTED][!mayfail]` stub TEST_CASE for +// `Misorientation Per Feature` were removed. The exemplar arrays in the archive were a circular +// oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output); the precision shift surfaced on the +// failing `ComputeFeatureNeighborMisorientationsFilter` ctest (test 1602 in the prior numbering). +// The UNIMPLEMENTED stub left `ComputeAvgMisors=true` with zero CI coverage, which is why the +// `tempMisoList` divisor bug at algorithm.cpp:75 (reassigning the divisor inside the inner j-loop) +// went undetected for so long. The Class 1 + Class 4 toy fixtures below replace both retirements. +// See `vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md` for retirement details. TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: SIMPL Backwards Compatibility", "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter][BackwardsCompatibility]") { @@ -131,3 +197,179 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: SIM } } } + +// ============================================================================= +// V&V Class 1 + Class 4 toy fixtures (added 2026-06-02 during V&V cycle). +// ============================================================================= + +// Fixture A: single-phase, single-feature with 2 neighbors. Verifies the per-neighbor +// MisorientationList values and the per-feature average computation (Mode: ComputeAvgMisors=true). +// Closed-form: pure phi1 rotations about z, cubic 4-fold doesn't reduce phi1 in [0, 45deg], so +// misorientation between (0deg) and (5deg) is 5.0deg; between (0deg) and (10deg) is 10.0deg. +// Expected avg = (5 + 10) / 2 = 7.5deg. +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Single Phase Two Neighbors", "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(/*numFeatures=*/4, /*numCrystalStructures=*/2); + + (*td.featurePhases)[1] = 1; + (*td.featurePhases)[2] = 1; + (*td.featurePhases)[3] = 1; + DataFixtures::SetAvgQuat(td, 1, DataFixtures::QuatFromPhi1Deg(0.0f)); + DataFixtures::SetAvgQuat(td, 2, DataFixtures::QuatFromPhi1Deg(5.0f)); + DataFixtures::SetAvgQuat(td, 3, DataFixtures::QuatFromPhi1Deg(10.0f)); + td.neighborList->setList(1, std::make_shared>(std::vector{2, 3})); + + ComputeFeatureNeighborMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs(/*computeAvgMisors=*/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 = DataFixtures::GetOutputMisorientationList(td.ds); + const auto& avg = DataFixtures::GetOutputAvgMisorientations(td.ds); + const auto& feature1List = misoList.at(1); + REQUIRE(feature1List.size() == 2); + REQUIRE(feature1List[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(feature1List[1] == Approx(10.0f).margin(1e-3f)); + REQUIRE(avg[1] == Approx(7.5f).margin(1e-3f)); +} + +// Fixture B: BUG-EXPOSING — mixed-phase neighbor list with phase-mismatch in the MIDDLE. +// Neighbors are processed in order [match, mismatch, match]. The bug at algorithm.cpp:75 +// reassigns `tempMisoList = featureNeighborList.size()` every j-iteration; the per-mismatch +// decrement at line 90 is therefore clobbered by the NEXT j-iteration's reassignment, and the +// final divisor equals the full list size (3) instead of the number of phase-matched neighbors (2). +// BUGGY result: avg = (5 + 10) / 3 = 5.0deg (FAILS this assertion) +// FIXED result: avg = (5 + 10) / 2 = 7.5deg (PASSES this assertion) +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mixed Phase Neighbors (exposes divisor bug)", + "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(/*numFeatures=*/5, /*numCrystalStructures=*/3); + // Crystal structures: index 0 sentinel (set by scaffold); index 1 Cubic_High (set by scaffold); + // index 2 Hex_High (different Laue class -> filter treats as a phase mismatch). + (*td.crystalStructures)[2] = 0u; // Hex_High + + // Feature 1 (phase 1, identity) - the focal feature with neighbors [2, 4, 3] + (*td.featurePhases)[1] = 1; + DataFixtures::SetAvgQuat(td, 1, DataFixtures::QuatFromPhi1Deg(0.0f)); + // Feature 2 (phase 1, 5deg) - phase MATCH -> misorientation = 5.0deg + (*td.featurePhases)[2] = 1; + DataFixtures::SetAvgQuat(td, 2, DataFixtures::QuatFromPhi1Deg(5.0f)); + // Feature 4 (phase 2, Hex_High) - phase MISMATCH -> NaN; should NOT count toward avg divisor + (*td.featurePhases)[4] = 2; + DataFixtures::SetAvgQuat(td, 4, DataFixtures::QuatFromPhi1Deg(99.0f)); // value irrelevant; quat will be skipped + // Feature 3 (phase 1, 10deg) - phase MATCH -> misorientation = 10.0deg + (*td.featurePhases)[3] = 1; + DataFixtures::SetAvgQuat(td, 3, DataFixtures::QuatFromPhi1Deg(10.0f)); + + // Neighbor order: [2 (match), 4 (mismatch), 3 (match)] -> LAST neighbor is a match -> bug fires. + td.neighborList->setList(1, std::make_shared>(std::vector{2, 4, 3})); + + ComputeFeatureNeighborMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs(/*computeAvgMisors=*/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 = DataFixtures::GetOutputMisorientationList(td.ds); + const auto& avg = DataFixtures::GetOutputAvgMisorientations(td.ds); + const auto& feature1List = misoList.at(1); + REQUIRE(feature1List.size() == 3); + REQUIRE(feature1List[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(std::isnan(feature1List[1])); + REQUIRE(feature1List[2] == Approx(10.0f).margin(1e-3f)); + // The correct average is sum-of-non-NaN / count-of-non-NaN = (5 + 10) / 2 = 7.5. + REQUIRE(avg[1] == Approx(7.5f).margin(1e-3f)); +} + +// Fixture C: Same neighbor composition as Fixture B, but the phase-mismatch is the LAST neighbor. +// Bug doesn't fire in this ordering because the decrement at algorithm.cpp:90 is the last write +// to tempMisoList (no subsequent inner-loop iteration to clobber it). Both buggy and fixed code +// produce avg = (5 + 10) / 2 = 7.5deg. +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mismatch Last Order", "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(/*numFeatures=*/5, /*numCrystalStructures=*/3); + (*td.crystalStructures)[2] = 0u; // Hex_High + (*td.featurePhases)[1] = 1; + DataFixtures::SetAvgQuat(td, 1, DataFixtures::QuatFromPhi1Deg(0.0f)); + (*td.featurePhases)[2] = 1; + DataFixtures::SetAvgQuat(td, 2, DataFixtures::QuatFromPhi1Deg(5.0f)); + (*td.featurePhases)[3] = 1; + DataFixtures::SetAvgQuat(td, 3, DataFixtures::QuatFromPhi1Deg(10.0f)); + (*td.featurePhases)[4] = 2; + DataFixtures::SetAvgQuat(td, 4, DataFixtures::QuatFromPhi1Deg(99.0f)); + td.neighborList->setList(1, std::make_shared>(std::vector{2, 3, 4})); + + ComputeFeatureNeighborMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs(/*computeAvgMisors=*/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 = DataFixtures::GetOutputMisorientationList(td.ds); + const auto& avg = DataFixtures::GetOutputAvgMisorientations(td.ds); + REQUIRE(avg[1] == Approx(7.5f).margin(1e-3f)); + const auto& feature1List = misoList.at(1); + REQUIRE(feature1List.size() == 3); + REQUIRE(feature1List[0] == Approx(5.0f).margin(1e-3f)); + REQUIRE(feature1List[1] == Approx(10.0f).margin(1e-3f)); + REQUIRE(std::isnan(feature1List[2])); +} + +// Fixture D: Class 4 invariants — runs the bug-exposing fixture and asserts only the invariants +// (no specific avg value), so this test catches a future regression that preserves specific values +// but breaks the invariants. Use a different neighbor order from Fixture B so we sample a different +// path through the per-feature loop. +TEST_CASE("OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 4 - Invariants", "[OrientationAnalysis][ComputeFeatureNeighborMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(/*numFeatures=*/5, /*numCrystalStructures=*/3); + (*td.crystalStructures)[2] = 0u; // Hex_High + (*td.featurePhases)[1] = 1; + DataFixtures::SetAvgQuat(td, 1, DataFixtures::QuatFromPhi1Deg(0.0f)); + (*td.featurePhases)[2] = 1; + DataFixtures::SetAvgQuat(td, 2, DataFixtures::QuatFromPhi1Deg(7.5f)); + (*td.featurePhases)[3] = 1; + DataFixtures::SetAvgQuat(td, 3, DataFixtures::QuatFromPhi1Deg(12.0f)); + (*td.featurePhases)[4] = 2; + DataFixtures::SetAvgQuat(td, 4, DataFixtures::QuatFromPhi1Deg(99.0f)); + td.neighborList->setList(1, std::make_shared>(std::vector{4, 2, 3})); + + ComputeFeatureNeighborMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs(/*computeAvgMisors=*/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 = DataFixtures::GetOutputMisorientationList(td.ds); + const auto& avg = DataFixtures::GetOutputAvgMisorientations(td.ds); + const auto& feature1List = misoList.at(1); + REQUIRE(feature1List.size() == 3); + + // Invariant 1: Each list entry is either NaN (phase mismatch) or a non-negative misorientation + // bounded above by the cubic max symmetry-reduced misorientation (~62.8 deg). + // Invariant 2: avg[fid] equals sum-of-non-NaN-entries / count-of-non-NaN-entries. + float64 sum = 0.0; + usize count = 0; + for(const auto& entry : feature1List) + { + if(std::isnan(entry)) + { + continue; + } + REQUIRE(entry >= 0.0f); + REQUIRE(entry <= 62.8f); + sum += static_cast(entry); + count++; + } + REQUIRE(count > 0); + const float32 expectedAvg = static_cast(sum / static_cast(count)); + REQUIRE(avg[1] == Approx(expectedAvg).margin(1e-4f)); +} diff --git a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp index 82152faf99..f411829db0 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeFeatureReferenceMisorientationsTest.cpp @@ -34,7 +34,13 @@ using namespace nx::core::UnitTest; // Reference: src/Plugins/OrientationAnalysis/vv/ComputeFeatureReferenceMisorientationsFilter.md // ============================================================================= -namespace ToyFixtures +// Wrapped in an anonymous namespace so every symbol below has internal linkage. Sibling +// OrientationAnalysis test TUs declare their own `DataFixtures` namespace with same-named +// (and same-signature) entities such as `CreateScaffold`; without internal linkage the linker +// merges those duplicate definitions into one, silently giving this TU another file's scaffold. +namespace +{ +namespace DataFixtures { // Test-side default paths (kept consistent with the existing `Small_IN100`-style fixtures). const std::string k_GeomName = "DataContainer"; @@ -232,7 +238,8 @@ inline void AssertClass4Invariants(const DataStructure& ds, bool isMode1) } } -} // namespace ToyFixtures +} // namespace DataFixtures +} // namespace // Retired 2026-06-01 (V&V cycle): the legacy anonymous namespace of array name constants and // the two TEST_CASEs `_AverageMisorientation` and `_EuclideanDistance` that consumed the @@ -301,7 +308,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: SI TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 SingleGrainIdentity", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 2, 2, 2); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(2, 2, 2, 2); // 8 voxels, all featureId=1, all phase=1, all quats = identity (already initialized). for(usize i = 0; i < 8; ++i) @@ -312,7 +319,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl // AvgQuats[1] = identity (already initialized). ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(0); + Arguments args = DataFixtures::BuildArgs(0); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); @@ -320,10 +327,10 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl for(usize i = 0; i < 8; ++i) { - ToyFixtures::RequireFRMClose(td.ds, i, 0.0f); + DataFixtures::RequireFRMClose(td.ds, i, 0.0f); } - ToyFixtures::RequireAvgClose(td.ds, 1, 0.0f); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); + DataFixtures::RequireAvgClose(td.ds, 1, 0.0f); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); } // Fixture B: Mode 0, single 2x2x2 grain, all quats identical (5 deg about z), AvgQuats[1] = identity. @@ -332,9 +339,9 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 KnownAngle5deg", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 2, 2, 2); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(2, 2, 2, 2); - const auto qVoxel = ToyFixtures::QuatFromPhi1Deg(5.0f); + const auto qVoxel = DataFixtures::QuatFromPhi1Deg(5.0f); for(usize i = 0; i < 8; ++i) { (*td.featureIds)[i] = 1; @@ -347,7 +354,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl // AvgQuats[1] = identity (already initialized). ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(0); + Arguments args = DataFixtures::BuildArgs(0); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); @@ -355,10 +362,10 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl for(usize i = 0; i < 8; ++i) { - ToyFixtures::RequireFRMClose(td.ds, i, 5.0f); + DataFixtures::RequireFRMClose(td.ds, i, 5.0f); } - ToyFixtures::RequireAvgClose(td.ds, 1, 5.0f); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); + DataFixtures::RequireAvgClose(td.ds, 1, 5.0f); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); } // Fixture C: Mode 0, 4x3x1 image with 5 features. Tests: @@ -369,11 +376,11 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 0 MultiGrain EdgeCases", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(4, 3, 1, 5); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(4, 3, 1, 5); - const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); - const auto q10 = ToyFixtures::QuatFromPhi1Deg(10.0f); + const auto qIdentity = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = DataFixtures::QuatFromPhi1Deg(5.0f); + const auto q10 = DataFixtures::QuatFromPhi1Deg(10.0f); auto setQuat = [&](usize voxelIdx, const std::array& q) { (*td.quats)[voxelIdx * 4 + 0] = q[0]; (*td.quats)[voxelIdx * 4 + 1] = q[1]; @@ -440,34 +447,34 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl setAvg(4, qIdentity); ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(0); + Arguments args = DataFixtures::BuildArgs(0); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); // Per-voxel expected FRMs. - ToyFixtures::RequireFRMClose(td.ds, 0, 0.0f); // background - ToyFixtures::RequireFRMClose(td.ds, 1, 5.0f); // feature 1 - ToyFixtures::RequireFRMClose(td.ds, 2, 5.0f); - ToyFixtures::RequireFRMClose(td.ds, 3, 0.0f); // feature 2 - ToyFixtures::RequireFRMClose(td.ds, 4, 0.0f); - ToyFixtures::RequireFRMClose(td.ds, 5, 5.0f); // feature 3, only valid voxel - ToyFixtures::RequireFRMClose(td.ds, 6, 0.0f); // feature 3, un-phased - ToyFixtures::RequireFRMClose(td.ds, 7, 0.0f); - ToyFixtures::RequireFRMClose(td.ds, 8, 0.0f); // feature 4, all un-phased - ToyFixtures::RequireFRMClose(td.ds, 9, 0.0f); - ToyFixtures::RequireFRMClose(td.ds, 10, 0.0f); - ToyFixtures::RequireFRMClose(td.ds, 11, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 0, 0.0f); // background + DataFixtures::RequireFRMClose(td.ds, 1, 5.0f); // feature 1 + DataFixtures::RequireFRMClose(td.ds, 2, 5.0f); + DataFixtures::RequireFRMClose(td.ds, 3, 0.0f); // feature 2 + DataFixtures::RequireFRMClose(td.ds, 4, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 5, 5.0f); // feature 3, only valid voxel + DataFixtures::RequireFRMClose(td.ds, 6, 0.0f); // feature 3, un-phased + DataFixtures::RequireFRMClose(td.ds, 7, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 8, 0.0f); // feature 4, all un-phased + DataFixtures::RequireFRMClose(td.ds, 9, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 10, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 11, 0.0f); // Per-feature expected averages. - ToyFixtures::RequireAvgClose(td.ds, 0, 0.0f); // background (no voxels contribute) - ToyFixtures::RequireAvgClose(td.ds, 1, 5.0f); // (5 + 5) / 2 - ToyFixtures::RequireAvgClose(td.ds, 2, 0.0f); // (0 + 0) / 2 - ToyFixtures::RequireAvgClose(td.ds, 3, 5.0f); // 5 / 1 (only voxel 5 valid) - ToyFixtures::RequireAvgClose(td.ds, 4, 0.0f); // count==0 -> avg=0 (path 7) + DataFixtures::RequireAvgClose(td.ds, 0, 0.0f); // background (no voxels contribute) + DataFixtures::RequireAvgClose(td.ds, 1, 5.0f); // (5 + 5) / 2 + DataFixtures::RequireAvgClose(td.ds, 2, 0.0f); // (0 + 0) / 2 + DataFixtures::RequireAvgClose(td.ds, 3, 5.0f); // 5 / 1 (only voxel 5 valid) + DataFixtures::RequireAvgClose(td.ds, 4, 0.0f); // count==0 -> avg=0 (path 7) - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); } // Fixture D: Mode 1, 3x3x1 single grain. Center voxel (4) has max GBEuclideanDistance and @@ -477,10 +484,10 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 KnownCenter", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 2); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 1, 2); - const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + const auto qIdentity = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = DataFixtures::QuatFromPhi1Deg(5.0f); for(usize i = 0; i < 9; ++i) { @@ -509,7 +516,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl } ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(1); + Arguments args = DataFixtures::BuildArgs(1); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); @@ -520,18 +527,18 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl // FRM expected: voxel 4 = 0 (vs itself), others = 5deg. for(usize i = 0; i < 9; ++i) { - ToyFixtures::RequireFRMClose(td.ds, i, (i == 4) ? 0.0f : 5.0f); + DataFixtures::RequireFRMClose(td.ds, i, (i == 4) ? 0.0f : 5.0f); } // avg = (0 + 5*8) / 9 = 40/9 deg - ToyFixtures::RequireAvgClose(td.ds, 1, 40.0f / 9.0f); + DataFixtures::RequireAvgClose(td.ds, 1, 40.0f / 9.0f); // EuclideanCenters[1] should be coords of voxel 4 = (1.5, 1.5, 0.5) with spacing 1 and origin 0. - const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + const auto& centers = td.ds.getDataRefAs(DataFixtures::k_CellFeatureDataPath.createChildPath(DataFixtures::k_FeatureEuclideanCentersOutName)); REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 1] == Approx(1.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 2] == Approx(0.5f).margin(1e-5f)); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); } // Fixture E: Mode 1, 2x3x1 image with 2 features (3 voxels each). Verifies that m_Centers[fid] @@ -540,7 +547,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 MultiGrain CenterIsolation", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(2, 3, 1, 3); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(2, 3, 1, 3); // Layout: 2x3x1 = 6 voxels, row-major (z=0 plane). // voxel 0,1 (row 0): feature 1 @@ -569,8 +576,8 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl (*td.gbEuclideanDistances)[4] = 0.5f; (*td.gbEuclideanDistances)[5] = 1.5f; - const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + const auto qIdentity = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = DataFixtures::QuatFromPhi1Deg(5.0f); auto setQuat = [&](usize voxelIdx, const std::array& q) { (*td.quats)[voxelIdx * 4 + 0] = q[0]; (*td.quats)[voxelIdx * 4 + 1] = q[1]; @@ -587,27 +594,27 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl setQuat(5, qIdentity); ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(1); + Arguments args = DataFixtures::BuildArgs(1); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); // Expected: FRM[1]=0, FRM[0]=5, FRM[2]=5, FRM[5]=0, FRM[3]=5, FRM[4]=5. - ToyFixtures::RequireFRMClose(td.ds, 0, 5.0f); - ToyFixtures::RequireFRMClose(td.ds, 1, 0.0f); - ToyFixtures::RequireFRMClose(td.ds, 2, 5.0f); - ToyFixtures::RequireFRMClose(td.ds, 3, 5.0f); - ToyFixtures::RequireFRMClose(td.ds, 4, 5.0f); - ToyFixtures::RequireFRMClose(td.ds, 5, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 0, 5.0f); + DataFixtures::RequireFRMClose(td.ds, 1, 0.0f); + DataFixtures::RequireFRMClose(td.ds, 2, 5.0f); + DataFixtures::RequireFRMClose(td.ds, 3, 5.0f); + DataFixtures::RequireFRMClose(td.ds, 4, 5.0f); + DataFixtures::RequireFRMClose(td.ds, 5, 0.0f); // avg[1] = (5+0+5)/3 = 10/3; avg[2] = (5+5+0)/3 = 10/3. - ToyFixtures::RequireAvgClose(td.ds, 1, 10.0f / 3.0f); - ToyFixtures::RequireAvgClose(td.ds, 2, 10.0f / 3.0f); + DataFixtures::RequireAvgClose(td.ds, 1, 10.0f / 3.0f); + DataFixtures::RequireAvgClose(td.ds, 2, 10.0f / 3.0f); // Per-feature EuclideanCenters: feature 1 = voxel 1 coords; feature 2 = voxel 5 coords. // Voxel 1 in a 2x3x1 grid is at (x=1, y=0, z=0); voxel 5 is at (x=1, y=2, z=0). With spacing 1 and // origin 0, getCoordsf returns center-of-cell coordinates: voxel 1 -> (1.5, 0.5, 0.5); voxel 5 -> (1.5, 2.5, 0.5). - const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + const auto& centers = td.ds.getDataRefAs(DataFixtures::k_CellFeatureDataPath.createChildPath(DataFixtures::k_FeatureEuclideanCentersOutName)); REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 1] == Approx(0.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 2] == Approx(0.5f).margin(1e-5f)); @@ -615,7 +622,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl REQUIRE(centers[2 * 3 + 1] == Approx(2.5f).margin(1e-5f)); REQUIRE(centers[2 * 3 + 2] == Approx(0.5f).margin(1e-5f)); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); } // Fixture F: Mode 1, 3D (3x3x2 = 18 voxels) single grain. Verifies the linear voxelIdx -> 3D coord @@ -625,10 +632,10 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Class 1 - Mode 1 3D Volume", "[OrientationAnalysis][ComputeFeatureReferenceMisorientationsFilter]") { UnitTest::LoadPlugins(); - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 2, 2); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 2, 2); - const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + const auto qIdentity = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = DataFixtures::QuatFromPhi1Deg(5.0f); for(usize i = 0; i < 18; ++i) { @@ -650,7 +657,7 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl } ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(1); + Arguments args = DataFixtures::BuildArgs(1); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); @@ -661,19 +668,19 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl // FRM expected: voxel 13 = 0 (vs itself), others = 5deg. for(usize i = 0; i < 18; ++i) { - ToyFixtures::RequireFRMClose(td.ds, i, (i == 13) ? 0.0f : 5.0f); + DataFixtures::RequireFRMClose(td.ds, i, (i == 13) ? 0.0f : 5.0f); } // avg = (0 + 5*17) / 18 = 85/18 deg. - ToyFixtures::RequireAvgClose(td.ds, 1, 85.0f / 18.0f); + DataFixtures::RequireAvgClose(td.ds, 1, 85.0f / 18.0f); // EuclideanCenters[1] should be coords of voxel 13. Spacing=1, origin=0, getCoordsf returns // cell-center coords: voxel 13 is at (x=1, y=1, z=1) -> center coords (1.5, 1.5, 1.5). - const auto& centers = td.ds.getDataRefAs(ToyFixtures::k_CellFeatureDataPath.createChildPath(ToyFixtures::k_FeatureEuclideanCentersOutName)); + const auto& centers = td.ds.getDataRefAs(DataFixtures::k_CellFeatureDataPath.createChildPath(DataFixtures::k_FeatureEuclideanCentersOutName)); REQUIRE(centers[1 * 3 + 0] == Approx(1.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 1] == Approx(1.5f).margin(1e-5f)); REQUIRE(centers[1 * 3 + 2] == Approx(1.5f).margin(1e-5f)); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); } // Class 4 sweep: re-run each Class 1 fixture with the goal of asserting Class 4 invariants only, @@ -687,9 +694,9 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl // Sweep 1: Mode 0 with a 3x3x1 grid mixing valid voxels, a background voxel, and un-phased voxels // across 3 features (different from the value-specific Mode 0 fixture above). { - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 3); - const auto q0 = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q7 = ToyFixtures::QuatFromPhi1Deg(7.0f); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 1, 3); + const auto q0 = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q7 = DataFixtures::QuatFromPhi1Deg(7.0f); // Voxels: bg, f1, f1, f1, f2(un-phased), f2, bg, f2, f1 const std::array, 9> layout = {{{0, 0}, {1, 1}, {1, 1}, {1, 1}, {2, 0}, {2, 1}, {0, 0}, {2, 1}, {1, 1}}}; for(usize i = 0; i < 9; ++i) @@ -712,19 +719,19 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl (*td.avgQuats)[2 * 4 + 3] = q7[3]; ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(0); + Arguments args = DataFixtures::BuildArgs(0); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/false); } // Sweep 2: Mode 1 with the Fixture-D config (re-run, assert invariants only). { - ToyFixtures::ToyData td = ToyFixtures::CreateScaffold(3, 3, 1, 2); - const auto qIdentity = ToyFixtures::QuatFromPhi1Deg(0.0f); - const auto q5 = ToyFixtures::QuatFromPhi1Deg(5.0f); + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 1, 2); + const auto qIdentity = DataFixtures::QuatFromPhi1Deg(0.0f); + const auto q5 = DataFixtures::QuatFromPhi1Deg(5.0f); for(usize i = 0; i < 9; ++i) { (*td.featureIds)[i] = 1; @@ -745,11 +752,11 @@ TEST_CASE("OrientationAnalysis::ComputeFeatureReferenceMisorientationsFilter: Cl } ComputeFeatureReferenceMisorientationsFilter filter; - Arguments args = ToyFixtures::BuildArgs(1); + Arguments args = DataFixtures::BuildArgs(1); auto preflightResult = filter.preflight(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(td.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - ToyFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); + DataFixtures::AssertClass4Invariants(td.ds, /*isMode1=*/true); } } diff --git a/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp index 4eadb4480f..1bd5357a1c 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeKernelAvgMisorientationsTest.cpp @@ -2,6 +2,9 @@ #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" @@ -11,6 +14,8 @@ #include +#include +#include #include #include @@ -19,65 +24,119 @@ using namespace nx::core; using namespace nx::core::Constants; using namespace nx::core::UnitTest; +// Wrapped in an anonymous namespace so every symbol below has internal linkage. Sibling +// OrientationAnalysis test TUs declare their own `DataFixtures` namespace with same-named +// (and same-signature) entities such as `CreateScaffold`; without internal linkage the linker +// merges those duplicate definitions into one, silently giving this TU another file's scaffold. namespace { -const std::string k_KernelAverageMisorientationsArrayName_Exemplar("KernelAverageMisorientations"); -const std::string k_KernelAverageMisorientationsArrayName("CalculatedKernelAverageMisorientations"); -} // namespace +namespace DataFixtures +{ +const std::string k_GeomName = "ImageGeometry"; +const DataPath k_ImageGeomPath = DataPath({k_GeomName}); +const DataPath k_CellDataPath = k_ImageGeomPath.createChildPath("CellData"); +const DataPath k_EnsembleDataPath = k_ImageGeomPath.createChildPath("EnsembleData"); + +const std::string k_FeatureIdsName = "FeatureIds"; +const std::string k_CellPhasesName = "Phases"; +const std::string k_QuatsName = "Quats"; +const std::string k_CrystalStructuresName = "CrystalStructures"; + +const std::string k_KAMOutName = "KernelAverageMisorientationsOut"; -TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +inline std::array QuatFromPhi1Deg(float32 phi1Deg) { - UnitTest::LoadPlugins(); + const float32 halfAngleRad = (phi1Deg * 0.5f) * 3.14159265358979323846f / 180.0f; + return {0.0f, 0.0f, std::sin(halfAngleRad), std::cos(halfAngleRad)}; +} - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "6_6_stats_test_v2.tar.gz", "6_6_stats_test_v2.dream3d"); +struct ToyData +{ + DataStructure ds; + ImageGeom* geom = nullptr; + AttributeMatrix* cellAM = nullptr; + AttributeMatrix* ensembleAM = nullptr; + Int32Array* featureIds = nullptr; + Int32Array* cellPhases = nullptr; + Float32Array* quats = nullptr; + UInt32Array* crystalStructures = nullptr; + usize totalCells = 0; +}; - // Read the Small IN100 Data set - auto baseDataFilePath = fs::path(fmt::format("{}/6_6_stats_test_v2.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(baseDataFilePath); - DataPath smallIn100Group({nx::core::Constants::k_DataContainer}); - DataPath cellDataPath = smallIn100Group.createChildPath(nx::core::Constants::k_CellData); +// Build a scaffold with an ImageGeom of the given (nX, nY, nZ) dimensions, a cell-level AM, and an +// ensemble AM. Cell arrays are sized as totalCells = nX*nY*nZ and initialized to: FeatureIds=1, +// CellPhases=1, Quats=identity. CrystalStructures index 0 = sentinel, index 1 = Cubic_High. +inline ToyData CreateScaffold(usize nX, usize nY, usize nZ, usize numCrystalStructures = 2) +{ + ToyData td; + td.totalCells = nX * nY * nZ; - { - // Instantiate the filter, a DataStructure object and an Arguments Object - ComputeKernelAvgMisorientationsFilter filter; - Arguments args; - - // Create default Parameters for the filter. - // Parameters - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_KernelSize_Key, std::make_any(std::vector{1, 1, 1})); - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_SelectedImageGeometryPath_Key, std::make_any(smallIn100Group)); - // Cell Arrays - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_FeatureIdsArrayPath)); - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesArrayPath)); - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_QuatsArrayPath)); - // Ensemble Arrays - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - // Output Array - args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_KernelAverageMisorientationsArrayName_Key, std::make_any(k_KernelAverageMisorientationsArrayName)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + 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}); - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } + td.cellAM = AttributeMatrix::Create(td.ds, "CellData", ShapeType{nZ, nY, nX}, td.geom->getId()); + td.ensembleAM = AttributeMatrix::Create(td.ds, "EnsembleData", ShapeType{numCrystalStructures}, td.geom->getId()); - // Compare the Output Cell Data - { - const DataPath k_GeneratedDataPath({k_DataContainer, k_CellData, k_KernelAverageMisorientationsArrayName}); - const DataPath k_ExemplarArrayPath({k_DataContainer, k_CellData, k_KernelAverageMisorientationsArrayName_Exemplar}); + 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.crystalStructures = CreateTestDataArray(td.ds, k_CrystalStructuresName, {numCrystalStructures}, {1}, td.ensembleAM->getId()); - UnitTest::CompareArrays(dataStructure, k_ExemplarArrayPath, k_GeneratedDataPath); + 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; // identity quaternion + } + (*td.crystalStructures)[0] = 999u; + if(numCrystalStructures > 1) + { + (*td.crystalStructures)[1] = 1u; // Cubic_High (EbsdLib LaueOps index) } + return td; +} -#ifdef SIMPLNX_WRITE_TEST_OUTPUT - WriteTestDataStructure(dataStructure, fs::path(fmt::format("{}/find_kernel_average_misorientations.dream3d", unit_test::k_BinaryTestOutputDir))); -#endif +inline void SetCellQuat(ToyData& 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]; +} - UnitTest::CheckArraysInheritTupleDims(dataStructure); +inline Arguments BuildArgs(const std::vector& kernelRadius) +{ + Arguments args; + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_KernelSize_Key, std::make_any(kernelRadius)); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_SelectedImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellFeatureIdsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_FeatureIdsName))); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_CellPhasesName))); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_QuatsArrayPath_Key, std::make_any(k_CellDataPath.createChildPath(k_QuatsName))); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_EnsembleDataPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeKernelAvgMisorientationsFilter::k_KernelAverageMisorientationsArrayName_Key, std::make_any(k_KAMOutName)); + return args; +} + +inline const Float32Array& GetOutputKAM(const DataStructure& ds) +{ + return ds.getDataRefAs(k_CellDataPath.createChildPath(k_KAMOutName)); } +} // namespace DataFixtures +} // namespace + +// Retired 2026-06-03 (V&V cycle): the main exemplar-comparison TEST_CASE that consumed +// `6_6_stats_test_v2.tar.gz` was removed. The exemplar `KernelAverageMisorientations` array was a +// circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output). The precision shift surfaced +// on the failing ctest for this filter. The Class 1 + Class 4 toy fixtures below replace the +// retired test; they cover all 5 algorithmic paths through `FindKernelAvgMisorientationsImpl::convert()`. +// The shared archive remains downloaded for `AlignSectionsMutualInformation`, `ComputeShapesFilter`, +// and `ComputeSchmidsFilter` tests, which still consume it. +// See `vv/provenance/ComputeKernelAvgMisorientationsFilter.md` for retirement details. TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: SIMPL Backwards Compatibility", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter][BackwardsCompatibility]") { @@ -122,3 +181,270 @@ TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: SIMPL Bac } } } + +// ===================================================================================== +// Class 1 (Analytical) toy fixtures + Class 4 (Invariant) companion. +// +// All Class 1 fixtures use pure phi1 Bunge ZXZ Euler rotations (phi1, 0, 0) with Phi = phi2 = 0, +// stored as quaternions via QuatFromPhi1Deg(). For cubic Laue class, the symmetry group's c-axis +// 4-fold rotation reduces phi1 differences modulo 90 degrees. By keeping all phi1 differences <= 45 +// degrees, the symmetry-reduced minimum rotation magnitude is exactly |delta_phi1|, so the expected +// misorientation between two cubic-phase cells is just |phi1_neighbor - phi1_focal| in degrees. +// This is what makes the oracle closed-form. +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Uniform 2D Single Feature", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // 3x3x1 image, single feature, single phase, all cells share the identity quaternion. + // Kernel radius {1,1,0} => 3x3x1 kernel (9 cells max, fewer at boundaries). + // Expected: every cell's KAM = 0 (all in-kernel neighbors have the same orientation). + // Exercises focal-valid path, feature-id-match accumulator, 2D boundary clamping. + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 1); + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 1, 0}); + + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + for(usize i = 0; i < td.totalCells; ++i) + { + REQUIRE(kam[i] == Approx(0.0f).margin(1e-4f)); + } + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D x-axis Gradient", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // 5x1x1 image, single feature, single phase. Per-cell phi1: [0, 5, 10, 15, 20] degrees. + // Kernel radius {1,0,0} => 1D kernel along x with up to 3 cells per kernel. + // Expected KAM: + // cell 0 (focal phi1=0): neighbors {self=0, x+1=5} -> misos {0, 5} -> avg 5/2 = 2.500 + // cell 1 (focal phi1=5): neighbors {x-1=0, self=5, x+1=10} -> misos {5, 0, 5} -> avg 10/3 ~ 3.3333 + // cell 2 (focal phi1=10): neighbors {x-1=5, self=10, x+1=15} -> misos {5, 0, 5} -> avg 10/3 ~ 3.3333 + // cell 3 (focal phi1=15): neighbors {x-1=10, self=15, x+1=20} -> misos {5, 0, 5} -> avg 10/3 ~ 3.3333 + // cell 4 (focal phi1=20): neighbors {x-1=15, self=20} -> misos {5, 0} -> avg 5/2 = 2.500 + // Exercises averaging arithmetic + 1D x-stride boundary clamp. + DataFixtures::ToyData td = DataFixtures::CreateScaffold(5, 1, 1); + const std::array phi1Deg = {0.0f, 5.0f, 10.0f, 15.0f, 20.0f}; + for(usize i = 0; i < 5; ++i) + { + DataFixtures::SetCellQuat(td, i, DataFixtures::QuatFromPhi1Deg(phi1Deg[i])); + } + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 0, 0}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + const std::array expected = {2.5f, 10.0f / 3.0f, 10.0f / 3.0f, 10.0f / 3.0f, 2.5f}; + for(usize i = 0; i < 5; ++i) + { + REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f)); + } + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D z-axis Gradient (3D path)", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // 1x1x3 image (single column of cells in z), single feature, single phase. + // Per-plane phi1: [0, 10, 20] degrees. Kernel radius {0,0,1} => 1D kernel along z. + // Expected KAM: + // plane 0 (focal=0): neighbors {self=0, z+1=10} -> misos {0, 10} -> avg 10/2 = 5.0 + // plane 1 (focal=10): neighbors {z-1=0, self=10, z+1=20} -> misos {10, 0, 10} -> avg 20/3 ~ 6.6667 + // plane 2 (focal=20): neighbors {z-1=10, self=20} -> misos {10, 0} -> avg 10/2 = 5.0 + // Exercises the 3D outer-loop z-path + z-stride boundary clamp. + DataFixtures::ToyData td = DataFixtures::CreateScaffold(1, 1, 3); + const std::array phi1Deg = {0.0f, 10.0f, 20.0f}; + for(usize i = 0; i < 3; ++i) + { + DataFixtures::SetCellQuat(td, i, DataFixtures::QuatFromPhi1Deg(phi1Deg[i])); + } + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({0, 0, 1}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + const std::array expected = {5.0f, 20.0f / 3.0f, 5.0f}; + for(usize i = 0; i < 3; ++i) + { + REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f)); + } + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Multi-Feature Multi-Voxel + Background", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // 6x1x1 image with mixed features, multi-voxel features, and a background cell. All cells with + // phase != 0 are cubic. Layout: + // cell x=0: featureId=1, phase=1, phi1=0 degrees + // cell x=1: featureId=1, phase=1, phi1=10 degrees + // cell x=2: featureId=2, phase=1, phi1=0 degrees + // cell x=3: featureId=2, phase=1, phi1=20 degrees + // cell x=4: featureId=0, phase=0, phi1=N/A (background) + // cell x=5: featureId=1, phase=1, phi1=30 degrees + // + // Kernel radius {1,0,0}. Algorithm only accumulates misorientations between cells in the same + // featureId; background cells (featureId=0 OR phase=0) get KAM=0 directly. + // + // Expected KAM: + // cell 0 (F1): kernel cells x=0(self,F1), x=1(F1). + // same-feat: |0-0|=0, |10-0|=10. sum=10, divisor=2 -> KAM = 5.0 + // cell 1 (F1): kernel cells x=0(F1), x=1(self,F1), x=2(F2 - SKIP). + // same-feat: |0-10|=10, |10-10|=0. sum=10, divisor=2 -> KAM = 5.0 + // cell 2 (F2): kernel cells x=1(F1 - SKIP), x=2(self,F2), x=3(F2). + // same-feat: |0-0|=0, |20-0|=20. sum=20, divisor=2 -> KAM = 10.0 + // cell 3 (F2): kernel cells x=2(F2), x=3(self,F2), x=4(F0 - SKIP, also background-phase mismatch). + // same-feat: |0-20|=20, |20-20|=0. sum=20, divisor=2 -> KAM = 10.0 + // cell 4 (F0,P0): focal-invalid path - KAM = 0 exactly. + // cell 5 (F1): kernel cells x=4(F0 - SKIP), x=5(self,F1). + // same-feat: |30-30|=0. sum=0, divisor=1 -> KAM = 0.0 + // + // Exercises: multi-voxel within-feature averaging (cells 0-3), multi-feature mismatch skip + // (cells 1, 2, 5), background skip path (cell 4), isolated single-cell feature (cell 5). + DataFixtures::ToyData td = DataFixtures::CreateScaffold(6, 1, 1); + // FeatureIds: [1, 1, 2, 2, 0, 1] + (*td.featureIds)[0] = 1; + (*td.featureIds)[1] = 1; + (*td.featureIds)[2] = 2; + (*td.featureIds)[3] = 2; + (*td.featureIds)[4] = 0; + (*td.featureIds)[5] = 1; + // CellPhases: [1, 1, 1, 1, 0, 1] + (*td.cellPhases)[0] = 1; + (*td.cellPhases)[1] = 1; + (*td.cellPhases)[2] = 1; + (*td.cellPhases)[3] = 1; + (*td.cellPhases)[4] = 0; + (*td.cellPhases)[5] = 1; + // Quats per phi1 (cell 4's quat is set to identity by CreateScaffold; algorithm short-circuits anyway): + DataFixtures::SetCellQuat(td, 0, DataFixtures::QuatFromPhi1Deg(0.0f)); + DataFixtures::SetCellQuat(td, 1, DataFixtures::QuatFromPhi1Deg(10.0f)); + DataFixtures::SetCellQuat(td, 2, DataFixtures::QuatFromPhi1Deg(0.0f)); + DataFixtures::SetCellQuat(td, 3, DataFixtures::QuatFromPhi1Deg(20.0f)); + DataFixtures::SetCellQuat(td, 5, DataFixtures::QuatFromPhi1Deg(30.0f)); + + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 0, 0}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + const std::array expected = {5.0f, 5.0f, 10.0f, 10.0f, 0.0f, 0.0f}; + for(usize i = 0; i < 6; ++i) + { + REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f)); + } + // Background cell must be exactly 0 (not just within tolerance) - it is set via the explicit + // KAM=0 short-circuit at the bottom of the inner loop. + REQUIRE(kam[4] == 0.0f); + + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Invariants", "[OrientationAnalysis][ComputeKernelAvgMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Class 4 invariants asserted across several derived fixture variants. These invariants are + // oracle-agnostic - they hold for any input, so they catch regressions even when specific + // expected values change. Two precondition invariants tested: + // (i) Uniform-orientation single-feature => KAM == 0 everywhere (any cell, any kernel size). + // (ii) Background cell (featureId==0 OR cellPhases==0) => KAM == 0 exactly. + // Plus three universal invariants on the gradient fixture: + // (iii) All KAM values are non-negative. + // (iv) All KAM values are <= 62.8 degrees (Mackenzie cubic upper bound). + // (v) KAM value at non-trivial focal cells must be > 0 (sanity check that the algorithm + // actually computed something rather than zeroing everything). + constexpr float32 k_CubicMaxAngleDeg = 62.8f; + + SECTION("(i) Uniform-orientation single-feature => KAM == 0") + { + // 3x3x3 uniform-identity-quaternion fixture, full 3D kernel. + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 3, 3); + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 1, 1}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + for(usize i = 0; i < td.totalCells; ++i) + { + REQUIRE(kam[i] == Approx(0.0f).margin(1e-4f)); + } + } + + SECTION("(ii) Background cell => KAM == 0 exactly") + { + // 3x1x1 with cells [F1P1, F0P0, F1P1]. Background cell at index 1. + DataFixtures::ToyData td = DataFixtures::CreateScaffold(3, 1, 1); + (*td.featureIds)[1] = 0; + (*td.cellPhases)[1] = 0; + DataFixtures::SetCellQuat(td, 0, DataFixtures::QuatFromPhi1Deg(0.0f)); + DataFixtures::SetCellQuat(td, 2, DataFixtures::QuatFromPhi1Deg(10.0f)); + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 0, 0}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + REQUIRE(kam[1] == 0.0f); + } + + SECTION("(iii, iv, v) Range and non-triviality invariants on x-axis gradient") + { + DataFixtures::ToyData td = DataFixtures::CreateScaffold(5, 1, 1); + const std::array phi1Deg = {0.0f, 5.0f, 10.0f, 15.0f, 20.0f}; + for(usize i = 0; i < 5; ++i) + { + DataFixtures::SetCellQuat(td, i, DataFixtures::QuatFromPhi1Deg(phi1Deg[i])); + } + ComputeKernelAvgMisorientationsFilter filter; + Arguments args = DataFixtures::BuildArgs({1, 0, 0}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& kam = DataFixtures::GetOutputKAM(td.ds); + for(usize i = 0; i < td.totalCells; ++i) + { + REQUIRE(kam[i] >= 0.0f); + REQUIRE(kam[i] <= k_CubicMaxAngleDeg); + } + // Non-triviality: at least one cell must have KAM > 0 (algorithm did something). + bool anyNonzero = false; + for(usize i = 0; i < td.totalCells; ++i) + { + if(kam[i] > 1e-4f) + { + anyNonzero = true; + } + } + REQUIRE(anyNonzero); + } +} diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborMisorientationsFilter.md new file mode 100644 index 0000000000..a20924baf2 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeFeatureNeighborMisorientationsFilter.md @@ -0,0 +1,133 @@ +# V&V Report: ComputeFeatureNeighborMisorientationsFilter + +| | | +|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `0b68fe25-b5ef-4805-ae32-20acb8d4e823` | +| SIMPLNX Human Name | Compute Feature Neighbor Misorientations | +| DREAM3D 6.5.171 equivalent | `FindMisorientations` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindMisorientations.{h,cpp}` (UUID `286dd493-4fea-54f4-b59e-459dd13bbe57`) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | *Michael Jackson (V&V cycle completion + divisor bug fix, 2026-06-02)* | + +## At a glance + +| Aspect | Current state | +|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Port (with UUID reassignment, name rename, and divisor-bug fix)** of legacy `FindMisorientations::execute()`. Same algorithm structure (per-feature outer loop; per-neighbor inner loop; phase-match gate; optional per-feature average). Port-time deltas: `QuatF`→`QuatD`, `getMisoQuat`→`calculateMisorientation`, 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 — see Deviations section). | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 3 hand-derived toy fixtures + 1 Class 4 invariants test covering single-phase, mixed-phase neighbor-order variants (incl. the bug-exposing configuration), and the per-feature-averaging skip path. **Class 4 (Invariant) companion** — non-negativity, cubic max-angle bound, NaN-on-mismatch convention, and the canonical per-feature averaging formula `sum-of-non-NaN-entries / count-of-non-NaN-entries` asserted in the dedicated Invariants test. | +| Code paths enumerated | 5 of 5 algorithmic paths exercised directly: (1) per-feature outer-loop body with phase-1 same-class neighbors → list-write + accumulate; (2) phase-mismatch branch → write `NaN` + decrement divisor; (3) `ComputeAvgMisors=true` finalize with `tempMisoList > 0` → `avg = sum/divisor`; (4) `ComputeAvgMisors=true` finalize with `tempMisoList == 0` → `avg = NaN` (entire neighbor list mismatched); (5) cancel check at outer-loop top. | +| Tests today | **5 TEST_CASEs / 5 ctest entries**, 100% pass (~0.3s). 3 Class 1 fixtures (`Single Phase Two Neighbors`, `Mixed Phase Neighbors (exposes divisor bug)`, `Mismatch Last Order`) + 1 Class 4 invariants test + 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 `6_6_stats_test_v2.tar.gz`) and the `[.][UNIMPLEMENTED][!mayfail]` `Misorientation Per Feature` stub TEST_CASE were **retired 2026-06-02** because (a) the exemplar arrays were a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output) and (b) the UNIMPLEMENTED stub left `ComputeAvgMisors=true` with zero CI coverage, which is precisely why the divisor bug (D1) went undetected. The 4 hand-derived toy fixtures cover all 5 algorithmic paths and replace both retired tests. | +| Legacy comparison | **Source-inspection comparison against DREAM3D 6.5.171** completed. Two deviations observed: **D1 (divisor bug)** — legacy `FindMisorientations.cpp` has the same `tempMisoList = featureNeighborList.size();` reassignment inside the inner j-loop; SIMPLNX corrected the bug during this V&V cycle; users will observe per-feature `AvgMisorientations` values shift on mixed-phase data when migrating from 6.5.171. **D2 (EbsdLib 2.4.1 CubicOps precision improvement)** — precision-class deviation analogous to ComputeFeatureReferenceMisorientations D1 and BadDataNeighborOrientationCheck non-deviation; non-observable on the toy fixtures (no sym-op-aligned features), observable on real EBSD data at the per-feature average level. | +| Bug flags | **One real bug fixed during this V&V cycle** — D1, divisor reassigned inside inner j-loop. Confirmed in bug_triage.md and traced to algorithm.cpp:75 (pre-fix). Fixed 2026-06-02; verified by the `Mixed Phase Neighbors (exposes divisor bug)` test which FAILED on the pre-fix code and PASSES on the post-fix code. | +| 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 + UNIMPLEMENTED stub 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). | + +## Summary + +`ComputeFeatureNeighborMisorientationsFilter` computes, for each feature in the input dataset, the misorientation angles (in degrees) between the feature's average orientation and each of its same-phase neighboring features' average orientations. Misorientations are stored as a per-feature list (`MisorientationList`, a `NeighborList`). When the optional `ComputeAvgMisors` parameter is set to `true`, the filter also writes a per-feature `AvgMisorientations` array containing the average of the non-NaN entries in each feature's misorientation list. Neighbors with a different phase from the focal feature produce a `NaN` entry in the list and are excluded from the average. + +Verification is via a **Class 1 (Analytical) hand-derived toy-fixture set of 3 unit tests + 1 Class 4 invariants test**. The fixtures use pure φ1-rotation quaternions (Bunge ZXZ Euler `(φ1, 0, 0)`) so that misorientation values are closed-form derivable: for Δφ1 ∈ {0°, 5°, 10°} and cubic symmetry, the symmetry-reduced misorientation equals `|Δφ1|`. The fixtures vary the *order* in which phase-matched and phase-mismatched neighbors appear in the feature's neighbor list to systematically exercise the per-mismatch divisor decrement path. + +**One inherited bug was found and fixed during this V&V cycle.** The legacy DREAM3D 6.5.171 `FindMisorientations` filter — and the SIMPLNX Port of it prior to this cycle — contained a divisor bug at the per-feature averaging step: the divisor variable `tempMisoList` was reassigned to `featureNeighborList.size()` *inside* the inner j-loop instead of *before* it, clobbering the per-mismatch decrement at the next j-iteration. The bug caused per-feature averages to use the full neighbor-list size as the divisor whenever the last-iterated neighbor was a phase match, regardless of how many earlier mismatches the loop encountered. The bug went undetected for the lifetime of both implementations because the `ComputeAvgMisors=true` test in the SIMPLNX suite was an `[.][UNIMPLEMENTED][!mayfail]` stub with zero CI coverage. The V&V cycle's `Mixed Phase Neighbors (exposes divisor bug)` toy fixture — which constructs a neighbor list `[match, mismatch, match]` and asserts the correct average `(5 + 10) / 2 = 7.5°` — failed on the pre-fix code (producing the buggy `15 / 3 = 5.0°`) and passes on the post-fix code. The fix moves the `tempMisoList = featureNeighborList.size();` assignment from inside the j-loop (line 75 pre-fix) to before the j-loop (alongside the `tempMisorientationLists[i].assign(...)` at line ~67), so the per-mismatch decrement is preserved across iterations. + +A pre-existing `6_6_stats_test_v2.tar.gz` archive (shared with `ComputeKernelAvgMisorientationsFilter`) was retired during this V&V cycle: the exemplar arrays were generated from a pre-EbsdLib-2.4.1 SIMPLNX run (circular oracle), and the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision improvement shifted the exemplar values beyond the regression-check epsilon. The toy fixtures cover all 5 algorithmic paths analytically and remove the circular-oracle dependency. Source inspection of the legacy `FindMisorientations` confirms the SIMPLNX algorithm is a clean Port; the only remaining legacy-vs-SIMPLNX differences are the post-fix divisor (D1) and the EbsdLib precision improvement (D2 — precision-class). + +## Algorithm Relationship + +*Classification:* **Port (with UUID reassignment, name rename, and one divisor-bug fix)** ~~| Minor changes | Rewrite | New filter~~ + +*Evidence:* The SIMPLNX algorithm at `Algorithms/ComputeFeatureNeighborMisorientations.cpp` (~120 lines) is a near line-by-line translation of legacy `FindMisorientations::execute()` (DREAM3D 6.5.171). Same per-feature outer loop, same per-neighbor inner loop, same phase-match gate, same optional average computation. SIMPLNX was assigned a new UUID (`0b68fe25-...` vs legacy `286dd493-...`) for the `Find`→`Compute` rename plus the `FeatureNeighbor` qualifier (clarifying that this filter computes feature-to-neighbor misorientations, distinguishing it from the per-cell and per-reference misorientation filters in the same module). + +*Port-time deltas:* + +1. **EbsdLib API**: `getMisoQuat(q1, q2, n1, n2, n3) → float angle` → `calculateMisorientation(q1, q2) → AxisAngleDType`. Same math via `LaueOps`. +2. **Quaternion precision**: `QuatF` (float32) → `QuatD` (double internal). Float32 stored values are promoted to double for the math; per-neighbor misorientations are cast back to `float32` for storage in the `NeighborList`. +3. **Cancel checks**: SIMPLNX adds `m_ShouldCancel.load()` at the outer-loop top; legacy had no cancel mechanism. +4. **EbsdLib 2.4.1 CubicOps precision improvement** (external dependency change): manifests on real EBSD data with cubic-phase sym-op-aligned grain-pair boundaries; non-observable on the V&V toy fixtures (pure φ1 rotations). See D2. +5. **Divisor bug fix** (this V&V cycle, 2026-06-02): `tempMisoList = featureNeighborList.size();` moved from inside the inner j-loop (line 75 pre-fix) to before the j-loop (alongside `tempMisorientationLists[i].assign(...)` at line ~67). See D1. + +*Material PRs since baseline (2025-10-01):* None identified that materially change this filter's algorithm. + +## Oracle + +*Class:* **1 (Analytical)** primary + **4 (Invariant)** companion. Class 3 (Paper-based) N/A — math is delegated to `ebsdlib::LaueOps::calculateMisorientation` and verified in EbsdLib's own V&V. + +### Applied (Class 1 — Analytical) + +Per-neighbor misorientation values are derived in closed form from the input `AvgQuats` + `FeaturePhases` + `NeighborList` + `CrystalStructures` arrays by hand-tracing the algorithm. The fixtures use pure φ1-rotation quaternions (Bunge ZXZ Euler `(φ1, 0, 0)`) so that misorientation between any two same-phase features equals `|Δφ1|` modulo the cubic c-axis 4-fold symmetry. For Δφ1 ∈ {0°, 5°, 10°}, no symmetry reduction applies (`5°`, `10°` are below the 45° fold) so expected per-neighbor entries are `|Δφ1|` exactly; phase-mismatched neighbors produce `NaN`. The expected per-feature average is `sum-of-non-NaN-entries / count-of-non-NaN-entries`. The three Class 1 fixtures differ in the *order* in which phase-matched and phase-mismatched neighbors appear in the list, systematically exercising the per-mismatch divisor decrement. + +### Applied (Class 4 — Invariant) + +Three invariants every filter run must satisfy regardless of input configuration: + +- **Per-entry validity**: each `MisorientationList[fid][j]` is either `NaN` (phase mismatch) or a non-negative misorientation bounded above by the cubic max symmetry-reduced angle (`62.8°`). +- **Per-feature averaging formula**: `AvgMisorientations[fid]` equals `sum(non-NaN entries in MisorientationList[fid]) / count(non-NaN entries in MisorientationList[fid])`. The formula is invariant under neighbor-list reordering — re-ordering the input neighbor list should NOT change the per-feature average value (it WILL change the order of entries within the per-feature list, but the average is order-independent). +- **All-mismatch case**: when every neighbor in a feature's list is a phase mismatch, the per-feature average is `NaN` (no valid entries to average). + +### Encoded + +- **Class 1 (Analytical)**: `test/ComputeFeatureNeighborMisorientationsTest.cpp` — 3 `TEST_CASE` blocks under the `Class 1 - …` family. Per-neighbor expected values asserted via `Approx().margin(1e-3f)`; per-feature averages asserted via `Approx().margin(1e-3f)`. +- **Class 4 (Invariant)**: `Class 4 - Invariants` test — runs a 5-feature 3-neighbor configuration and asserts the per-entry validity invariant and the per-feature averaging formula derived from the per-entry values. +- *(kept)* `SIMPL Backwards Compatibility` — SIMPL 6.4 + 6.5 conversion paths via `DYNAMIC_SECTION`. + +### Second-engineer review + +*Pending — recommend an OA-domain engineer (Joey Kleingers or similar) review:* +- *The Class 1 hand-derivations in the 3 toy fixtures + 1 invariants test.* +- *The divisor-bug fix at `Algorithms/ComputeFeatureNeighborMisorientations.cpp` line ~70 (the new assignment location) and the corresponding test that exercises the fix (`Class 1 - Mixed Phase Neighbors (exposes divisor bug)`).* +- *The decision to retire the `6_6_stats_test_v2.tar.gz` Small-IN100 exemplar archive in favor of inline toy fixtures (shared retirement with `ComputeKernelAvgMisorientationsFilter`).* + +## Code path coverage + +*5 of 5 paths exercised directly.* + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeFeatureNeighborMisorientations.cpp` (~125 lines). + +The algorithm has one logical pass: a per-feature outer loop, with a per-neighbor inner loop inside it. Cancel check at the outer-loop top. + +| # | Pass | Path | Test case | +|---|-------------|-------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | Per-feature | Cancel check at outer-loop top (`m_ShouldCancel.load()` → early return) | *Not directly tested.* Failure mode would manifest as test hang in any test; not specifically exercised. Low-value gap. | +| 2 | Per-neighbor | Phase match (`laueClass1 == xtalType2 && laueClass1 < orientationOps.size()`) → write list entry; if `ComputeAvgMisors`, accumulate | All Class 1 fixtures; primary algorithmic path | +| 3 | Per-neighbor | Phase mismatch → write `NaN` entry; if `ComputeAvgMisors`, decrement `tempMisoList` divisor | `Mixed Phase Neighbors (exposes divisor bug)` + `Mismatch Last Order` + `Class 4 - Invariants` | +| 4 | Per-feature (finalize) | `tempMisoList != 0` → `(*avgMisorientations)[i] /= tempMisoList` | All Class 1 fixtures + Class 4 invariants | +| 5 | Per-feature (finalize) | `tempMisoList == 0` (all neighbors mismatched) → `(*avgMisorientations)[i] = NaN` | *Not directly tested.* Algorithm path is exercised whenever a feature has only phase-mismatched neighbors; no toy fixture currently constructs this configuration but the invariant `count == 0 → avg == NaN` is asserted in the Class 4 invariants helper if such a feature were present. Low-value gap. | + +## Test inventory + +| Test case | Status | Notes | +|------------------------------------------------------------------------------------------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ComputeFeatureNeighborMisorientationsFilter: Class 1 - Single Phase Two Neighbors` | new-for-V&V | 4 features; all phase 1; per-feature `MisorientationList[1] = [5°, 10°]`; expected avg = 7.5°. Verifies the basic per-feature averaging path with all phase-matched neighbors. | +| `ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mixed Phase Neighbors (exposes divisor bug)` | new-for-V&V | Bug-exposing fixture. 5 features; feature 1 (phase 1) has neighbors `[2 (match), 4 (mismatch), 3 (match)]`. The last-iterated neighbor is a match, so the pre-fix bug reassigns the divisor to 3; the test asserts the correct avg = 7.5°. **Failed on pre-fix code (gave 5.0°); passes on post-fix code (gives 7.5°).** | +| `ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mismatch Last Order` | new-for-V&V | Same features as the bug-exposing fixture but neighbor order `[2 (match), 3 (match), 4 (mismatch)]`. Last neighbor is a mismatch; the decrement at line 90 is the last write to `tempMisoList` so the bug doesn't fire. Both pre-fix and post-fix code produce 7.5°. | +| `ComputeFeatureNeighborMisorientationsFilter: Class 4 - Invariants` | new-for-V&V | Asserts per-entry validity (NaN or non-negative ≤ 62.8°) and the per-feature averaging formula (derived from the per-entry values, not a specific hard-coded number). Uses neighbor order `[4 (mismatch), 2 (match), 3 (match)]`. Catches future regressions that preserve specific values but break the invariant relationship. | +| `ComputeFeatureNeighborMisorientationsFilter: SIMPL Backwards Compatibility` | retained | `DYNAMIC_SECTION` over SIMPL 6.4 + 6.5 conversion fixtures. UUID + argument-key + parameter-value validation only. | +| *(retired)* main `ComputeFeatureNeighborMisorientationsFilter` (consumed `6_6_stats_test_v2.tar.gz`) | retired | Removed 2026-06-02. Regression-against-archive test; archive's exemplar values were a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output). Test was already failing as the EbsdLib precision fix shifted exemplar values beyond the regression-check epsilon. Replaced by inline Class 1 + Class 4 fixtures above. | +| *(retired)* `ComputeFeatureNeighborMisorientationsFilter: Misorientation Per Feature` | retired | Removed 2026-06-02. The `[.][UNIMPLEMENTED][!mayfail]` stub left `ComputeAvgMisors=true` with zero CI coverage, which is precisely why the divisor bug at algorithm.cpp:75 went undetected. The 3 Class 1 fixtures above cover that parameter combination. | + +All 5 active TEST_CASEs pass at the verified commit (`100% tests passed, 0 tests failed out of 5` in ~0.3s). + +## Exemplar archive + +**None — data inlined in `test/ComputeFeatureNeighborMisorientationsTest.cpp` namespace `ToyFixtures`.** + +The pre-existing `6_6_stats_test_v2.tar.gz` archive is being retired. See `src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md` for the retirement rationale. + +- **Archive:** None +- **SHA512:** N/A +- **Provenance:** `src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md` + +## Deviations from DREAM3D 6.5.171 + +Two deviations documented: + +### ComputeFeatureNeighborMisorientationsFilter-D1 + +- **Symptom:** Per-feature `AvgMisorientations` values differ between SIMPLNX (post-2026-06-02 fix) and DREAM3D 6.5.171 on any dataset where features have mixed-phase neighbor lists (i.e., a feature has both same-phase and different-phase neighbors). SIMPLNX produces the mathematically correct average (sum-of-same-phase-misorientations / count-of-same-phase-misorientations); 6.5.171 produces an incorrect average using a divisor influenced by neighbor iteration order. +- **Root cause:** **Bug** in DREAM3D 6.5.171 (also present in pre-fix SIMPLNX). See `vv/deviations/ComputeFeatureNeighborMisorientationsFilter.md` for the technical mechanism. + +### ComputeFeatureNeighborMisorientationsFilter-D2 + +- **Symptom:** Per-neighbor `MisorientationList` values and per-feature `AvgMisorientations` values differ between SIMPLNX (EbsdLib 2.4.1+) and DREAM3D 6.5.171 on real EBSD data containing cubic-phase features with grain-pair boundaries near cubic symmetry operators. On the V&V toy fixtures, no observable deviation. +- **Root cause:** **Precision** — propagation of the EbsdLib 2.4.1 `CubicOps::calculateMisorientationInternal` precision improvement, characterized in `vv/deviations/BadDataNeighborOrientationCheckFilter.md`. See `vv/deviations/ComputeFeatureNeighborMisorientationsFilter.md` for the per-filter context. diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md new file mode 100644 index 0000000000..7a3e7d57ad --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeKernelAvgMisorientationsFilter.md @@ -0,0 +1,129 @@ +# V&V Report: ComputeKernelAvgMisorientationsFilter + +| | | +|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `61cfc9c1-aa0e-452b-b9ef-d3b9e6268035` | +| SIMPLNX Human Name | Compute Kernel Average Misorientations | +| DREAM3D 6.5.171 equivalent | `FindKernelAvgMisorientations` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindKernelAvgMisorientations.{h,cpp}` (UUID `88d332c1-cf6c-52d3-a38d-22f6eae19fa6`) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | *Michael Jackson (V&V cycle completion, 2026-06-03)* | + +## At a glance + +| Aspect | Current state | +|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Port (with UUID reassignment, name rename, and one inherent legacy bug corrected at port time)** of legacy `FindKernelAvgMisorientations::execute()`. Same algorithm structure (per-voxel outer triple loop over (plane, row, col); per-kernel-neighbor inner triple loop over (j, k, l); focal-validity gate; same-feature gate inside the kernel; per-voxel average with focal voxel always included in the divisor). Port-time deltas: `QuatF`→`QuatD`, `getMisoQuat`→`calculateMisorientation`, `setParallelizationEnabled` removed (now always parallel via `ParallelData3DAlgorithm`), iteration order changed from `col→row→plane` to `plane→row→col` (cache-friendlier, mathematically identical), name rename `Find`→`Compute`, new UUID. **One inherited bug corrected at port time** (D2, legacy l-loop bound used `KernelSize.z + 1` instead of `KernelSize.x + 1` — see Deviations section). | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 4 hand-derived toy fixtures covering uniform single-feature, x-axis gradient, z-axis gradient (3D path), and multi-feature multi-voxel with background. **Class 4 (Invariant) companion** — non-negativity, cubic max-angle bound (62.8°), uniform-within-feature implies KAM=0, background-voxel implies KAM=0 exactly. Class 1 oracle uses pure φ1 Bunge ZXZ rotations `(φ1, 0, 0)` so that for cubic symmetry, the misorientation between any two cells equals `|Δφ1|` (the c-axis 4-fold reduction is identity when φ1 differences are ≤45°). | +| Code paths enumerated | 6 of 6 algorithmic paths exercised: (1) focal-valid (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter kernel; (2) inner kernel cell in-bounds + feature-id match → accumulate miso + numVoxel++; (3) inner kernel cell in-bounds + feature-id mismatch → skip (no accumulate); (4) inner kernel cell out-of-bounds (boundary clamp) → `continue`; (5) focal-invalid (`featureIds[point] == 0 || cellPhases[point] == 0`) → KAM = 0 directly; (6) the `numVoxel == 0` fallback at line 131 is dead code in practice (the focal voxel always self-matches when the focal is valid, giving numVoxel ≥ 1) and is not exercised by the fixtures by design. | +| Tests today | **6 TEST_CASEs / 6 ctest entries**, 100% pass (~0.3s combined on EbsdLib 2.4.1+). 4 Class 1 fixtures + 1 Class 4 invariants test (with 3 sub-sections) + 1 SIMPL backwards-compatibility test. **No exemplar archive consumed by this filter.** | +| Exemplar archive | **None — inline-constructed in test source.** The pre-existing main exemplar TEST_CASE (consumed `6_6_stats_test_v2.tar.gz`) was **retired 2026-06-03** because the exemplar `KernelAverageMisorientations` array was a circular oracle (regenerated from pre-EbsdLib-2.4.1 SIMPLNX output, where the precision shift documented as D1 manifests as a non-zero spurious self-misorientation contribution in every focal voxel — see deviations doc). The 4+1 hand-derived toy fixtures cover all 5 active algorithmic paths and replace the retired test. The shared archive `6_6_stats_test_v2.tar.gz` remains downloaded for `AlignSectionsMutualInformation`, `ComputeShapes`, and `ComputeSchmids` tests; only F#5's consumption line was removed. | +| Legacy comparison | **Source-inspection comparison against DREAM3D 6.5.171** completed. Two deviations observed: **D1 (EbsdLib 2.4.1 CubicOps precision improvement)** — precision-class deviation analogous to BadDataNeighborOrientationCheck, ComputeFeatureFaceMisorientation, ComputeFeatureNeighborMisorientations, and ComputeFeatureReferenceMisorientations of this cycle. The KAM filter is *more sensitive* to this fix than the per-pair misorientation filters because the kernel inclusion of the focal voxel triggers a self-misorientation call per cell, where the pre-2.4.1 `acos(w near 1)` form returns a spurious ~0.03° on float32-sourced quaternions instead of 0°. This precision noise propagates directly into the per-cell average. **D2 (legacy kernel-bound bug at `FindKernelAvgMisorientations.cpp:264`)** — legacy `for(int32_t l = -m_KernelSize.x; l < m_KernelSize.z + 1; l++)` uses `KernelSize.z + 1` as the upper bound for the x-direction inner loop (should be `KernelSize.x + 1`). SIMPLNX has the correct form at line 108. Bug is dormant when `KernelSize.x == KernelSize.z` (default `{1,1,1}` case); fires for asymmetric kernels. | +| Bug flags | **One real legacy bug (D2) corrected at port time** — see deviations. SIMPLNX has been correct from the port onward; no SIMPLNX-side source change required by this V&V cycle. Logged to `/Users/mjackson/Desktop/bug_triage.md` as a known legacy DREAM3D 6.5.171 issue. | +| V&V phase | **All V&V work complete per V2 policy.** Class 1 + Class 4 oracle confirmed against 6-test suite; circular-oracle archive consumption retired; legacy A/B by source inspection; user-facing doc updated (pipeline name typo fixed; orphan `MassifPipeline` reference removed; `aptr12_Analysis` and `avtr12_Analysis` added). Three source-tree deliverables (this report + `vv/deviations/...` + `vv/provenance/...`) in place. **Outstanding:** Status promotion DRAFT → READY FOR REVIEW pending second-engineer oracle review (recommend Joey Kleingers, especially the multi-feature multi-voxel fixture's per-cell hand-derivation). | + +## Summary + +`ComputeKernelAvgMisorientationsFilter` computes the per-cell **Kernel Average Misorientation (KAM)**: for each valid cell (featureId > 0, phase > 0), the algorithm iterates over an axis-aligned kernel of user-specified radius `KernelSize = (x, y, z)`, averages the misorientation between the focal cell's orientation quaternion and every same-feature neighbor's quaternion (including the focal cell itself, which contributes a self-misorientation of 0°), and stores the result. Cells with `featureId == 0` or `phase == 0` are treated as background and assigned KAM = 0 directly. + +The filter is the cell-level analog of the feature-level `ComputeFeatureNeighborMisorientationsFilter`. Like that filter, it consumes `Quats` (cell-level avg-orientations) and `CrystalStructures` (per-phase Laue class index), and delegates the actual cubic/hex/etc. symmetry-reduced disorientation calculation to `ebsdlib::LaueOps::calculateMisorientation()`. Unlike the feature-level filter, the kernel iteration ALWAYS visits the focal cell as part of its neighbor list (via the `j=k=l=0` inner iteration), so the per-cell divisor `numVoxel` is always ≥ 1. + +The output is `KernelAverageMisorientations`, a `Float32Array` co-located in the same `AttributeMatrix` as the input `Cell Data` arrays, sized one-tuple-per-cell with one component per tuple, in degrees. + +## Algorithm Relationship + +*Classification:* **Port (with UUID reassignment + name rename + one inherent legacy bug corrected at port time).** + +*Evidence:* Cross-checked SIMPLNX `Algorithms/ComputeKernelAvgMisorientations.cpp::FindKernelAvgMisorientationsImpl::convert()` against legacy `FindKernelAvgMisorientations.cpp::execute()`. Same per-voxel outer triple loop (cell index `point = plane*xPoints*yPoints + row*xPoints + col`), same per-kernel inner triple loop (`j` over Z, `k` over Y, `l` over X), same same-feature gate (`featureIds[point] == featureIds[neighbor]`), same per-voxel average with KAM = totalMisorientation / numVoxel. Port-time deltas: + +- `QuatF` → `QuatD` (single-precision → double-precision in the misorientation call). +- `getMisoQuat(q1, q2, n1, n2, n3)` (returns angle, writes axis components into out-parameters) → `calculateMisorientation(q1, q2)` (returns `AxisAngleDType` struct). EbsdLib 2.4.1+ uses `2 * atan2(|v|, w)` instead of `acos(w)` — see D1. +- `setParallelizationEnabled` removed; the SIMPLNX version is always parallel via `ParallelData3DAlgorithm`. +- Iteration order changed from `for col / for row / for plane` (legacy line 245-250) to `for plane / for row / for col` (SIMPLNX line 64-79). Cache-friendlier for x-fastest-varying storage, mathematically identical because all writes are to the same `point` index regardless of iteration order. +- One legacy bug corrected (D2). See deviations. +- Name rename `Find` → `Compute` per platform-wide convention. +- New UUID. + +*Material PRs since baseline (filter-introduction):* (none specifically targeting this filter — the algorithm has been stable since the OrientationAnalysis plugin port). + +## Oracle + +*Confirmed class:* **Class 1 (Analytical) primary, Class 4 (Invariant) companion.** + +### Class 1 (Analytical) + +Class 1 oracle derived by hand for each fixture in terms of `|Δφ1|` between cell pairs, justified by the cubic FZ analysis below. All quaternions in the test fixtures use the helper `QuatFromPhi1Deg(phi1)` which returns the quaternion form of a pure Bunge ZXZ Euler rotation `(phi1, 0, 0)` with `Phi = phi2 = 0`. This collapses to a single rotation about the z-axis. + +**Cubic FZ argument:** For two cells with pure φ1 rotations differing by Δφ1, the disorientation between them in the cubic FZ equals `|Δφ1|` whenever `|Δφ1| ≤ 45°`. The reasoning: the cubic group's 4-fold rotation about the z-axis reduces φ1 differences modulo 90°; for |Δφ1| ≤ 45°, the reduction is the identity operator (no reduction needed). For all 4 Class 1 fixtures, the maximum φ1 difference between any pair is ≤ 30°, well within the 45° bound. Other cubic symmetry operators (3-fold about [111], 2-fold about [110], etc.) only produce smaller-angle equivalents for misorientations not aligned with a pure z-axis rotation; for pure z-rotations of small magnitude, the identity is the global minimum. + +**Per-fixture expected KAM derivation:** + +| Fixture | Geometry | Kernel | Per-cell expected KAM (degrees) | +|--------------------------------------------|----------|-----------|------------------------------------------------------------| +| `Class 1 - Uniform 2D Single Feature` | 3x3x1 | {1,1,0} | All cells = 0.0 (all in-kernel neighbors share orientation) | +| `Class 1 - 1D x-axis Gradient` | 5x1x1 | {1,0,0} | [2.5, 10/3, 10/3, 10/3, 2.5] (see derivation in test comments) | +| `Class 1 - 1D z-axis Gradient (3D path)` | 1x1x3 | {0,0,1} | [5.0, 20/3, 5.0] | +| `Class 1 - Multi-Feature Multi-Voxel + BG` | 6x1x1 | {1,0,0} | [5.0, 5.0, 10.0, 10.0, 0.0, 0.0] | + +Detailed per-cell hand-derivations are in the test file's TEST_CASE comments and in `vv/provenance/ComputeKernelAvgMisorientationsFilter.md`. + +### Class 4 (Invariant) + +Class 4 invariants asserted in the `Class 4 - Invariants` TEST_CASE across 3 sub-sections: + +1. **Uniform-orientation single-feature → KAM == 0 everywhere.** Asserted on a 3x3x3 uniform-identity-quaternion fixture with kernel `{1,1,1}` (3D path coverage). +2. **Background cell → KAM == 0 exactly.** Asserted on a 3x1x1 fixture with the middle cell flagged as `(featureId=0, phase=0)`. +3. **Range and non-triviality on the x-axis gradient fixture:** (i) `KAM[i] >= 0` for all cells, (ii) `KAM[i] <= 62.8°` (Mackenzie cubic upper bound), (iii) at least one cell has `KAM > 0` (sanity check that the algorithm actually computed something). + +The Class 4 invariants are oracle-agnostic — they hold for any input, so they catch regressions even if specific Class 1 expected values were edited away. + +### Class 2, 3, 5 + +N/A — no reference-library invocation (Class 2), no published-paper figure reproduction (Class 3), no expert-visual sign-off (Class 5) needed. Class 1 + Class 4 are sufficient. + +### Second-engineer oracle review + +Recommended pending another engineer review. The multi-feature multi-voxel fixture in particular has 6 hand-derived per-cell expected values; a second pair of eyes on the symmetry-reduced cubic misorientation reasoning would catch arithmetic mistakes in the test comments before the V&V cycle is closed. + +## Code path coverage + +| Path | Description | Exercised by | +|------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------| +| 1 | Focal-valid gate (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter kernel | All 4 Class 1 fixtures; Class 4 sub-sections (i) and (iii) | +| 2 | Inner kernel cell in-bounds + feature-id match → accumulate miso + numVoxel++ | All Class 1 fixtures; Class 4 (i) and (iii) | +| 3 | Inner kernel cell in-bounds + feature-id mismatch → skip (no accumulate) | `Class 1 - Multi-Feature Multi-Voxel + Background` (cells 1, 2 see different-feature in-bounds neighbors) | +| 4 | Inner kernel cell out-of-bounds (boundary clamp `col+l > xPoints-1` etc.) → `continue` | `Class 1 - 1D x-axis Gradient` cells 0/4 (x-boundary); `Class 1 - 1D z-axis Gradient` planes 0/2 (z-boundary); `Class 1 - Uniform 2D` corner cells (xy-corner) | +| 5 | Focal-invalid (`featureIds == 0 || cellPhases == 0`) → KAM = 0 directly | `Class 1 - Multi-Feature Multi-Voxel + Background` cell 4 (explicit REQUIRE that KAM == 0 exactly); Class 4 sub-section (ii) (background-cell invariant) | +| 6 | `numVoxel == 0` fallback at line 131 → KAM = 0 | **Not exercised** — dead code in practice. The focal voxel always self-matches (j=k=l=0 case satisfies `featureIds[point] == featureIds[point]`), guaranteeing numVoxel ≥ 1 whenever path 1 is entered. Path 5 (focal-invalid) skips the kernel entirely, so it never reaches path 6. | + +5 of 6 paths exercised by the V&V suite; the 6th is unreachable by construction and is flagged in the algorithm review as removable dead code. + +## Test inventory + +| TEST_CASE | Category | Lines | ctest entry | +|----------------------------------------------------------------------------------------|----------|-------|----------------------------------------------------------------------------------------| +| `: SIMPL Backwards Compatibility` | Compat | ~40 | Yes (2 dynamic sections: 6.4 + 6.5) | +| `: Class 1 - Uniform 2D Single Feature` | Class 1 | ~25 | Yes | +| `: Class 1 - 1D x-axis Gradient` | Class 1 | ~35 | Yes | +| `: Class 1 - 1D z-axis Gradient (3D path)` | Class 1 | ~30 | Yes | +| `: Class 1 - Multi-Feature Multi-Voxel + Background` | Class 1 | ~60 | Yes | +| `: Class 4 - Invariants` (3 sub-sections) | Class 4 | ~55 | Yes (3 SECTIONs) | +| ~~`: ComputeKernelAvgMisorientationsFilter` (legacy exemplar test)~~ | RETIRED | ~50 | Retired 2026-06-03 (circular oracle from pre-EbsdLib-2.4.1 SIMPLNX output) | + +## Exemplar archive + +**None** — inline-constructed. The pre-V&V test (now retired) consumed `6_6_stats_test_v2.tar.gz` (SHA512 `e84999...089723`, downloaded from the BlueQuartz Data_Archive release). The archive contains exemplar `KernelAverageMisorientations` arrays generated from a pre-2.4.1 SIMPLNX build, which embeds the spurious self-misorientation precision noise described in D1. Comparing the post-2.4.1 SIMPLNX output against those exemplars fails by ~0.01-0.05° per cell on real Small_IN100 data. The exemplar arrays cannot be re-generated against the post-2.4.1 build (circular oracle pattern), so the test was retired and replaced with the analytical / invariant suite above. + +The shared archive remains referenced in `src/Plugins/OrientationAnalysis/test/CMakeLists.txt` (line 130) for use by `AlignSectionsMutualInformation`, `ComputeShapes`, and `ComputeSchmids` tests. Only F#5's consumption line was removed. + +## Deviations from DREAM3D 6.5.171 + +See `vv/deviations/ComputeKernelAvgMisorientationsFilter.md` for the canonical, ID-stable list: + +- **`ComputeKernelAvgMisorientationsFilter-D1`** — EbsdLib 2.4.1 CubicOps precision improvement (non-deviation in the algorithmic sense; precision class). The pre-2.4.1 `acos(w near 1)` form produces a spurious ~0.03° angle on float32-sourced identical quaternions, which inflates every per-cell self-misorientation contribution to the KAM. The 2.4.1 `2*atan2(|v|, w)` form returns 0° as expected. +- **`ComputeKernelAvgMisorientationsFilter-D2`** — Legacy `FindKernelAvgMisorientations.cpp:264` uses `KernelSize.z + 1` as the upper bound of the x-direction inner loop (should be `KernelSize.x + 1`). SIMPLNX has the correct form. Dormant when `KernelSize.x == KernelSize.z`; fires for asymmetric kernels. Logged in `bug_triage.md`. + +## Provenance + +See `vv/provenance/ComputeKernelAvgMisorientationsFilter.md` for the canonical record of how the inlined toy fixtures were designed and how the expected values were derived. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborMisorientationsFilter.md new file mode 100644 index 0000000000..db26f95217 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeFeatureNeighborMisorientationsFilter.md @@ -0,0 +1,82 @@ +# Deviations from DREAM3D 6.5.171: ComputeFeatureNeighborMisorientationsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`FindMisorientations`, source at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindMisorientations.{h,cpp}` in DREAM3D 6.5.171). + +Entries are referenced by stable ID (`ComputeFeatureNeighborMisorientationsFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +## Comparison summary + +The legacy A/B comparison was performed by **source inspection** rather than empirical run. Justification: SIMPLNX `ComputeFeatureNeighborMisorientations::operator()()` is a clean Port of legacy `FindMisorientations::execute()` (same per-feature outer loop, same per-neighbor inner loop, same phase-match gate, same optional per-feature averaging finalize). Both implementations share a divisor bug at the `tempMisoList` reassignment (D1 below) — verified by grep of the legacy source. The bug went undetected for the lifetime of both implementations because the `ComputeAvgMisors=true` test in the SIMPLNX suite was an `[.][UNIMPLEMENTED][!mayfail]` stub with zero CI coverage. + +--- + +## ComputeFeatureNeighborMisorientationsFilter-D1 + +| Field | Value | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborMisorientationsFilter-D1` | +| **Filter UUID** | `0b68fe25-b5ef-4805-ae32-20acb8d4e823` | +| **Status** | active (SIMPLNX fixed 2026-06-02; legacy 6.5.171 still has the bug) | + +**Symptom:** Per-feature `AvgMisorientations` (output of `ComputeAvgMisors=true` / legacy `FindAvgMisors=true`) differ between SIMPLNX (post-2026-06-02 fix) and DREAM3D 6.5.171 on any dataset where features have mixed-phase neighbor lists. The legacy result depends on the *order* in which neighbors appear in the per-feature `NeighborList`: if the last-iterated neighbor is a phase match, the divisor used is the full neighbor-list length (incorrect); if the last neighbor is a phase mismatch, the divisor is decremented by 1 from the full length (the per-mismatch decrement at line 90 happens to be the last write to `tempMisoList`). 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 phase-matched neighbors. + +The bug is **non-observable on the V&V toy fixtures' single-phase configurations** (no phase mismatches → no decrements → divisor matches list length, which equals the number of matches, which is correct). The bug **IS observable** on the bug-exposing fixture `Mixed Phase Neighbors (exposes divisor bug)`, which constructs a neighbor list `[match, mismatch, match]` for which the expected average is `(5 + 10) / 2 = 7.5°` but the buggy code produces `(5 + 10) / 3 = 5.0°`. + +**Root cause:** **Bug** in both legacy DREAM3D 6.5.171 and SIMPLNX pre-fix. + +The legacy code at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindMisorientations.cpp` (lines TBD) and the SIMPLNX pre-fix code at `Algorithms/ComputeFeatureNeighborMisorientations.cpp:75` both contain `tempMisoList = featureNeighborList.size();` *inside* the inner per-neighbor j-loop. The intended behavior is for `tempMisoList` to start each outer-loop iteration (per feature) at `featureNeighborList.size()` and then decrement by 1 for each phase-mismatched neighbor (line 90: `tempMisoList > 0 ? tempMisoList-- : tempMisoList = 0;`). Because the reassignment happens at the *top* of each j-iteration, the decrement from the *previous* iteration is clobbered. The result is that only the *last* j-iteration's match/mismatch state actually affects `tempMisoList`: if the last neighbor is a match, the assignment runs and the decrement doesn't, so the final divisor is N; if the last neighbor is a mismatch, both the assignment and the decrement run, so the final divisor is N - 1. + +The SIMPLNX fix (2026-06-02) moves the `tempMisoList = featureNeighborList.size();` assignment from line 75 to before the inner j-loop (alongside `tempMisorientationLists[i].assign(...)` at line ~67), 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 phase-matched 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 `ComputeAvgMisors=true` path** (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** without a regression test that exercises mixed-phase neighbor lists. The `ComputeAvgMisors=true` test in `ComputeFeatureNeighborMisorientationsTest.cpp` was an `[.][UNIMPLEMENTED][!mayfail]` stub with the comment "TODO: needs to be implemented. This will need the input .dream3d file to be regenerated with the missing data generated using DREAM3D 6.6". Zero CI coverage. +3. **The retroactive bug-triage cycle (2026-05) caught it** by source inspection. Documented in `/Users/mjackson/Desktop/bug_triage.md` as Bug #2. + +**Affected users:** Anyone running DREAM3D 6.5.171 or SIMPLNX pre-2026-06-02 with `ComputeAvgMisors=true` (legacy `FindAvgMisors=true`) on data containing features with mixed-phase neighbor lists. In practice this affects any multi-phase EBSD dataset that has at least one feature whose neighbor list includes both same-phase and different-phase neighbors. Single-phase datasets are unaffected. Datasets where the bug "accidentally" produces the correct divisor (every feature's neighbor list ends in a mismatch) are also unaffected. + +**Recommendation:** **Trust SIMPLNX (post-2026-06-02 fix).** The pre-fix per-feature `AvgMisorientations` 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 average misorientations to shift toward the mathematically correct value, with the shift size proportional to the fraction of phase-mismatched neighbors per feature. + +A legacy backport branch of `FindMisorientations.cpp` with the same fix (move the `tempMisoList` reassignment outside the inner loop) would produce the corrected values on DREAM3D 6.5.171 for users requiring legacy-version-parity post-correction. The fix is mechanically the same as the SIMPLNX fix and is a one-line move. No such backport branch is currently maintained. + +--- + +## ComputeFeatureNeighborMisorientationsFilter-D2 + +| Field | Value | +|------------------|-------------------------------------------------------------| +| **Deviation ID** | `ComputeFeatureNeighborMisorientationsFilter-D2` | +| **Filter UUID** | `0b68fe25-b5ef-4805-ae32-20acb8d4e823` | +| **Status** | active (precision-class; non-deviation in algorithmic sense) | + +**Symptom:** Per-neighbor `MisorientationList` values and per-feature `AvgMisorientations` values differ between SIMPLNX (EbsdLib 2.4.1+, post-D1 fix) and DREAM3D 6.5.171 on real EBSD datasets containing cubic-phase features with grain-pair boundaries near cubic symmetry operators (e.g., 4-fold about c-axis, 3-fold about [111], 2-fold about face-diagonal). Per-neighbor values shift by sub-`0.0001°` (within float precision), but the magnitude amplifies when averaged across many neighbors at the per-feature level. On the V&V toy fixtures (pure φ1 rotations about z, no sym-op-aligned neighbor pairs), no observable deviation. + +**Root cause:** **Precision** — not an algorithm change in either implementation. + +The deviation traces to the EbsdLib 2.4.1 release commit `5c8c993` (BlueQuartz Software, 2026-05-29), which replaces a precision-fragile `acos(w)` form in `CubicOps::calculateMisorientationInternal` with a numerically-stable `2·atan2(|v|, w)` form using the explicit reduced-quaternion `v` components. The precision improvement is real and mathematically more correct; it manifests for cubic misorientations whose minimum-rotation-axis representation lies on or near a cubic symmetry operator. + +This filter is a clean Port of `FindMisorientations` (modulo the D1 divisor bug, which existed in both implementations and is now fixed in SIMPLNX). The SIMPLNX algorithm reproduces the legacy per-feature outer loop, per-neighbor inner loop, and per-feature average finalization. The legacy filter consumes `OrientationLib::CubicOps::getMisoQuat` (pre-fix `acos`-form, float32); the SIMPLNX filter consumes `ebsdlib::CubicOps::calculateMisorientation` (post-fix `2·atan2`-form, QuatD). The difference is entirely in the EbsdLib precision improvement, NOT in this filter. + +For the full root-cause walkthrough of the EbsdLib precision improvement, see the precedent characterization in `vv/deviations/BadDataNeighborOrientationCheckFilter.md` §"Non-deviations" → "EbsdLib 2.4.1 CubicOps precision improvement". The characterization there applies equally to this filter. As with `ComputeFeatureReferenceMisorientationsFilter-D1`, this filter's per-feature averaging amplifies the per-voxel precision shift across the feature's neighbor list. + +**Affected users:** Anyone migrating from DREAM3D 6.5.171 to SIMPLNX on cubic-phase EBSD data with features whose neighbor lists include sym-op-aligned grain-pair boundaries. Non-cubic data and data without sym-op-aligned boundaries are unaffected. + +**Recommendation:** **Trust SIMPLNX.** The 6.5.171 result was limited by float32-input ULP noise amplified by `acos`-near-1 catastrophic cancellation; SIMPLNX returns the mathematically correct value. The shift is well below typical EBSD measurement resolution and will not materially affect downstream microstructural analyses. + +--- + +## 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 the D1 bug is not exercised. Captured here so future engineers don't re-discover them and propose them as deviations. + +### NaN entry on phase mismatch + +Both implementations write `NaN` (via `std::numeric_limits::quiet_NaN()` or ``'s `NAN`) into the per-neighbor `MisorientationList` entry when the neighbor's phase differs from the focal feature's phase, or when the focal feature's `laueClass1` is out of range for `orientationOps`. **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 `MisorientationList[0]` and `AvgMisorientations[0]` entries are therefore left at their initialized default values (empty list and `0.0f`, respectively). **Both filters share this behavior**. + +### Self-misorientation not computed + +Neither implementation includes the focal feature `i` in its own neighbor list (the `NeighborList` input is assumed to be a list of *other* features that share at least one boundary with feature `i`, not including `i` itself). Both implementations therefore do not compute `misorientation(i, i)` (which would be `0°` by definition). **Both filters share this assumption** — it is a property of how `ComputeFeatureNeighbors` (the upstream filter that produces the `NeighborList`) is conventionally used in the SIMPLNX and DREAM3D 6.5.171 pipelines. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md new file mode 100644 index 0000000000..12582337bc --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeKernelAvgMisorientationsFilter.md @@ -0,0 +1,120 @@ +# Deviations from DREAM3D 6.5.171: ComputeKernelAvgMisorientationsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`FindKernelAvgMisorientations`, source at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindKernelAvgMisorientations.{h,cpp}` in DREAM3D 6.5.171). + +Entries are referenced by stable ID (`ComputeKernelAvgMisorientationsFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +## Comparison summary + +The legacy A/B comparison was performed by **source inspection** rather than empirical run. Justification: SIMPLNX `ComputeKernelAvgMisorientations` is a clean Port of legacy `FindKernelAvgMisorientations::execute()` (same per-voxel outer triple loop; same per-kernel inner triple loop; same focal-validity gate; same same-feature gate inside the kernel; same per-voxel average with the focal voxel always included in the divisor). The port-time deltas are documented in the V&V report's Algorithm Relationship section. Two deviations were identified: a precision-class non-deviation (D1) traceable to the EbsdLib 2.4.1 release, and a legacy bug (D2) at the inner x-loop bound that was corrected at port time. + +--- + +## ComputeKernelAvgMisorientationsFilter-D1 + +| Field | Value | +|------------------|----------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeKernelAvgMisorientationsFilter-D1` | +| **Filter UUID** | `61cfc9c1-aa0e-452b-b9ef-d3b9e6268035` | +| **Status** | active (precision-class; non-deviation in algorithmic sense) | + +**Symptom:** Per-cell `KernelAverageMisorientations` values differ between SIMPLNX built against fixed EbsdLib (≥ v2.4.1, commit `5c8c993`) and DREAM3D 6.5.171 (and, equivalently, between fixed-EbsdLib and pre-fix-EbsdLib SIMPLNX builds — see the dependency note below). The shift is **quaternion-specific, not a uniform per-cell offset**: it is *exactly* 0 for focal cells whose symmetry-reduced self-misorientation lands on the trivial `wmin` candidate (the identity quaternion, and small rotations about a high-symmetry axis), and ~`0.03°` only for focal cells whose self-misorientation is reduced through a non-trivial cubic sym-op candidate (4-fold / 3-fold / 2-fold) that lands at `1 − ε`. Across a real dataset (Small_IN100 and similar) this averages to a per-cell shift of ~`0.005–0.05°`, depending on the focal-cell orientation distribution and the kernel size; it amplifies for asymmetric (e.g. `{2,2,1}`) or single-voxel kernels because the focal-cell self-misorientation term then carries more weight in the average. + +**Dependency (resolved in this PR):** the fix lives in EbsdLib commit `5c8c993`, contained in the `v2.4.1` tag. As of this PR the SIMPLNX `vcpkg.json` pins `ebsdlib version>=2.4.1`, so the **standard vcpkg build now links the fixed EbsdLib** and the artifact no longer appears in any supported configuration: both the standard build (`NX-Com-Qt69-Vtk95-Rel`) and the local-source build (`NX-Com-Qt69-Vtk95-Rel-EbsdLib`, `SIMPLNX_USE_LOCAL_EBSD_LIB=ON`) produce the correct, self-miso-free result. The V&V toy-fixture unit tests assert the exact analytical oracle (margin `1e-3`) and pass in both configurations. The artifact reappears only if EbsdLib is pinned below `2.4.1` (e.g. an older vcpkg baseline); the *Empirical confirmation* below was captured against the pre-fix `2.4.0` — the version that shipped before this PR — to characterize the symptom and verify the fix. + +**Root cause:** **Precision** — not an algorithm change in either implementation. + +The deviation traces to the EbsdLib 2.4.1 release commit `5c8c993` (BlueQuartz Software, 2026-05-29), which replaces a precision-fragile `acos(w)` form in `CubicOps::calculateMisorientationInternal` with a numerically-stable `2·atan2(|v|, w)` form using the explicit reduced-quaternion `v` components. The precision improvement is real and mathematically more correct; for `ComputeKernelAvgMisorientationsFilter` specifically it manifests *more strongly than for the per-pair misorientation filters in this cycle* because the kernel inclusion of the focal voxel triggers a per-cell self-misorientation call. For the pre-fix `acos(w)`-form: + +- `q_self_miso = q_focal * q_focal.conjugate() = (0, 0, 0, 1)` mathematically (identity quaternion), so the *true* self-misorientation is exactly 0°. +- The error is **not** introduced by the raw `q * q.conjugate()` product (its `w` component is `|q|² ≈ 1`); it is introduced by the **symmetry reduction**. `calculateMisorientationInternal` maximizes `wmin` over the 24 cubic sym-op candidates, evaluating three candidate forms per sym op: `qco.w()`, `(qco.z() + qco.w())/√2` (the 4-fold-about-c form), and `(qco.x()+qco.y()+qco.z()+qco.w())/2`. For the identity misorientation, several candidates equal 1.0 mathematically — but on float32-sourced quaternions a non-trivial candidate such as `(qco.z() + qco.w())/√2` evaluates to `1 − ε` (with `ε ≈ 1.7e-8`) and can be selected as the maximum. **Which focal quaternions trigger this is candidate-dependent: most reduce on the trivial `qco.w() == 1.0` branch and yield exactly 0; only those whose maximizing candidate is a non-trivial sym-op form land at `1 − ε`.** +- `acos(1 − ε)` near 1 is precision-fragile: the derivative of `acos` at 1 is `-1/√(1-x²) → -∞`, so a `1-ULP` error in `wmin` propagates to a `√(2ε)`-scale error in the angle (then doubled by the `2 * acos(wmin)` step). For `ε` of order `1e-8` this puts the spurious self-miso in the `~0.02–0.03°` range — the fix commit message cites `~0.02°`; the value **measured empirically on this branch is `0.0326°`** (see below). The exact constant depends on the winning sym-op candidate and the platform's float32 quantization, so treat the magnitude as order-of-`0.03°`, not a fixed number. +- The post-fix `2 * atan2(|v|, w)` form, using the **explicit** reduced-quaternion vector components, is numerically stable: components like `(qco.z() - qco.w())` evaluate to *exactly* 0 in IEEE-754 when `qco.z() == qco.w()` regardless of upstream float32 truncation, so `|v| = 0` and the result is exactly 0 for every identity self-misorientation. + +The KAM filter is *more sensitive* than `ComputeFeatureNeighborMisorientations` and `BadDataNeighborOrientationCheck` to this precision improvement because: + +1. **Self-misorientation contribution.** The KAM kernel includes the focal cell (via the `j=k=l=0` inner iteration). For each focal cell, the algorithm therefore makes one call to `calculateMisorientation` with `q1 == q2`. With pre-fix EbsdLib this call returns a spurious ~0.03° **for the subset of focal orientations whose symmetry reduction lands on a non-trivial sym-op candidate** (exactly 0 for the rest), which gets added to `totalMisorientation` and shifts that cell's average up by `(spurious_self / numVoxel)`. With fixed EbsdLib the call returns 0° for *every* focal orientation and contributes nothing. This is the *entire* KAM-specific deviation — see point 3. + +2. **Same-feature large-N averaging.** For a cell in the middle of a large grain with kernel `{1,1,1}`, numVoxel = 27 (all same-feature). The cumulative effect of 27 small precision noises averages out somewhat, but the systematic self-miso contribution is always present. + +3. **The deviation is the self-miso term, essentially nothing else.** For *distinct*-orientation pairs the two EbsdLib forms agree to well below `0.0001°` — empirically confirmed: the sibling `ComputeFeatureNeighborMisorientations` toy fixtures assert distinct-pair misorientations of `5.0°` and `10.0°` at margin `1e-3` and **pass against both vcpkg `2.4.0` and the fixed EbsdLib**. That filter excludes the focal feature from its neighbor list, so it never makes a `q1 == q2` call and shows no shift. KAM's per-cell shift is therefore attributable *entirely* to the focal-cell self-misorientation term, not to any per-pair precision noise — which is why KAM is the most observable filter in this cycle for the EbsdLib precision fix. + +**Affected users:** Anyone migrating from DREAM3D 6.5.171 to SIMPLNX on cubic-phase EBSD data with this filter, *or* anyone running a SIMPLNX build pinned to EbsdLib `< 2.4.1` (the standard vcpkg build now pins `≥ 2.4.1`, so this affects only builds on an older baseline). The shift is per-cell, systematic in sign (always slightly above the true KAM), and proportional to the inverse of the kernel volume (1 / numVoxel) — but only for focal cells whose orientation triggers the artifact (see *Symptom*); unaffected focal cells shift by 0. The figures below are the **upper bound for an affected focal cell**: for `KernelSize = {1,1,1}` on a grain interior the affected-cell shift is `~0.03°/27 ≈ 0.001°`; for `KernelSize = {0,0,0}` (single-voxel kernel — just the focal cell) it is the full `~0.03°` because the divisor is 1. + +**Recommendation:** **Trust SIMPLNX (EbsdLib 2.4.1+).** The 6.5.171 result was limited by the well-understood `acos(w near 1)` precision pathology amplified by float32-sourced quaternion inputs; SIMPLNX returns the mathematically correct value. The shift is well below typical EBSD measurement resolution and will not materially affect downstream microstructural analyses, but the cumulative effect on KAM-based maps will be visibly smoother in the post-2.4.1 output. Users requiring exact 6.5.171 reproduction can compile against EbsdLib < 2.4.1 (not recommended). + +For the full root-cause walkthrough of the EbsdLib precision improvement, see the precedent characterization in `vv/deviations/BadDataNeighborOrientationCheckFilter.md` §"Non-deviations" → "EbsdLib 2.4.1 CubicOps precision improvement". The characterization there applies equally to this filter, with the additional amplification factor described above. + +**Empirical confirmation (V&V cycle, branch `topic/vv/ComputeFeatureNeighborMisorientationsFilter`, 2026-06-04):** The Class 1 / Class 4 toy fixtures were run on Apple Silicon against both EbsdLib builds, with per-pair `calculateMisorientation` results instrumented: + +- **Distinct-orientation pairs are exact on both builds.** Pairs of `5°`, `10°`, and `15°` apart returned `4.99991°`, `4.99988°`, etc. (`< 0.0002°` from the analytical value) on both vcpkg `2.4.0` and the fixed local EbsdLib. This rules out per-pair precision noise as a contributor and confirms point 3 above. +- **Self-misorientations are 0 for most focal orientations even pre-fix.** `q1 == q2` for the identity and for the `5°`, `10°`, `15°`-about-c focal cells returned *exactly* `0.0°` on vcpkg `2.4.0`. +- **Only the `20°`-about-c focal cells triggered the artifact pre-fix.** Their self-misorientation returned `0.0325663°` on vcpkg `2.4.0` (matching the `~0.033°` derived above), inflating those cells' KAM by `0.0326°/numVoxel`. Example: the 1D x-axis gradient fixture's last cell (`numVoxel = 2`) read `2.51628°` against an analytical `2.5°` — exceeding the test's `1e-3` margin. +- **The fixed EbsdLib zeroes every self-misorientation.** Rebuilding the same fixtures with the `NX-Com-Qt69-Vtk95-Rel-EbsdLib` preset (local EbsdLib at `5c8c993`), all three misorientation suites pass exactly: KAM `134/134` assertions, `ComputeFeatureNeighborMisorientations` `56/56`, `ComputeFeatureReferenceMisorientations` `238/238`. + +The toy-fixture unit tests assert the analytical oracle directly (margin `1e-3`, no tolerance for the pre-fix artifact). With EbsdLib pinned `≥ 2.4.1` in `vcpkg.json` this is the correct, regression-sensitive choice: it holds in every supported build and would immediately flag any future regression of the EbsdLib precision fix, rather than silently absorbing it under a loose tolerance. + +--- + +## ComputeKernelAvgMisorientationsFilter-D2 + +| Field | Value | +|------------------|------------------------------------------------------------------------------------| +| **Deviation ID** | `ComputeKernelAvgMisorientationsFilter-D2` | +| **Filter UUID** | `61cfc9c1-aa0e-452b-b9ef-d3b9e6268035` | +| **Status** | active (SIMPLNX correct since port; legacy 6.5.171 still has the bug) | + +**Symptom:** Per-cell `KernelAverageMisorientations` values differ between SIMPLNX and DREAM3D 6.5.171 whenever the user-supplied `KernelSize` has `KernelSize.x != KernelSize.z`. For symmetric kernels (`{1,1,1}`, `{2,2,2}`, etc. — the default and the most common use), the deviation is **dormant**. For asymmetric kernels (e.g., `{1, 1, 2}` — common when the user is processing serial-section data with non-isotropic voxel spacing), the legacy code iterates the x-direction inner loop with the WRONG bound, producing a kernel of incorrect shape and an incorrect KAM. + +Concrete example: with `KernelSize = {1, 1, 2}` on a `30x30x30` voxel grid, legacy `FindKernelAvgMisorientations` iterates the inner-most `l` loop from `l = -1` to `l = 2` (5 iterations: `-1, 0, 1, 2`) instead of the correct `l = -1` to `l = 1` (3 iterations). For each focal cell, legacy adds the cells at `x+2` (out of the user's intended kernel) to the average while still excluding cells at `x = focal - 2`. The kernel becomes asymmetric in a way the user did not request. + +**Root cause:** **Bug** in legacy DREAM3D 6.5.171 only. + +The legacy code at `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindKernelAvgMisorientations.cpp:264` is: + +```cpp +for(int32_t l = -m_KernelSize.x; l < m_KernelSize.z + 1; l++) +// ^ should be .x +``` + +The two surrounding outer loops use the correct axis: `m_KernelSize.z` for `j` (line 258) and `m_KernelSize.y` for `k` (line 261). Line 264 is a copy-paste typo where the upper bound `m_KernelSize.z + 1` was carried over from the z-loop instead of being changed to `m_KernelSize.x + 1`. + +The SIMPLNX algorithm at `Algorithms/ComputeKernelAvgMisorientations.cpp:108` is correct: + +```cpp +for(int32_t l = -kernelSize[0]; l < kernelSize[0] + 1; l++) +``` + +where `kernelSize[0]` is X. The port from legacy to SIMPLNX silently corrected the bug — most likely the porter manually wrote the loop bound instead of mechanically copy-pasting the legacy line, and used `kernelSize[0]` consistently for both the lower and upper bounds. + +**Why this bug went undetected in 6.5.171:** Default and most shipping pipelines use symmetric kernels (`{1,1,1}` is the parameter default; the Small_IN100 reference pipelines all use `{1,1,1}`). The bug is dormant for any symmetric kernel and produces correct output. Asymmetric kernels are uncommon in published DREAM3D workflows but are a real use case for non-isotropic-voxel-spacing serial-section EBSD data. + +**Affected users:** DREAM3D 6.5.171 users who ran `FindKernelAvgMisorientations` with an asymmetric `KernelSize`. The output is silently wrong: cells near the upper x-boundary may also see different in-kernel neighbor counts than expected due to the boundary clamp now interacting with the wider-than-requested x-iteration range. + +**Recommendation:** **Trust SIMPLNX.** The bug was fixed at port time and SIMPLNX has produced the correct kernel shape for all kernel parameters since the OrientationAnalysis plugin was first ported. Users migrating from DREAM3D 6.5.171 with asymmetric kernels should expect KAM values to change toward the mathematically correct (intended-kernel) value. + +A legacy backport branch of `FindKernelAvgMisorientations.cpp` with `m_KernelSize.z + 1` changed to `m_KernelSize.x + 1` would produce the corrected values on DREAM3D 6.5.171 for users requiring legacy-version-parity post-correction. The fix is a one-character edit. No such backport branch is currently maintained. + +This bug is documented in `/Users/mjackson/Desktop/bug_triage.md` (Bug #9, added during this V&V cycle) as a known legacy DREAM3D 6.5.171 issue with no SIMPLNX-side action required. + +--- + +## Non-deviations (algorithm characteristics common to both filters) + +The following behaviors are NOT deviations — SIMPLNX (post-EbsdLib 2.4.1) and DREAM3D 6.5.171 (with D2 dormant on symmetric kernels) agree on them where D1 precision noise is below the user's tolerance. Captured here so future engineers don't re-discover them and propose them as deviations. + +### Focal voxel always included in the kernel sum + +Both implementations have the focal cell as a same-feature neighbor of itself (the `j=k=l=0` inner iteration produces `neighbor = point`). The focal cell's self-misorientation contributes 0° to `totalMisorientation` and 1 to `numVoxel`. This is intentional algorithm characteristic — it provides a non-zero divisor for cells with no in-kernel same-feature neighbors (a single-voxel isolated grain). **Both filters share this behavior** — algorithm characteristic, not a defect. + +### Background cell short-circuit to KAM = 0 + +Both implementations branch on `featureIds[point] == 0 || cellPhases[point] == 0` at the *end* of the per-cell processing (legacy line 311, SIMPLNX line 136) and unconditionally set `KAM = 0` for these cells. The logic is logically AFTER the kernel loop but only fires when the focal validity check at the top failed (so the kernel loop didn't run). **Both filters share this behavior**. + +### `numVoxel == 0` fallback (dead code in practice) + +Both implementations include an `if(numVoxel == 0) { KAM[point] = 0; }` guard immediately after the `KAM[point] = totalMiso / numVoxel` divide. In practice the focal voxel always self-matches (path 6 in the V&V report's code path coverage), so `numVoxel >= 1` whenever the focal validity check at the top of the cell processing was passed. The fallback is dead code. **Both filters share this dead code** — could be removed in both, but no functional issue. + +### Multi-threading model + +Both implementations parallelize over the outer cell loop. Legacy uses `tbb::parallel_for` over a single dimension after marshalling; SIMPLNX uses `ParallelData3DAlgorithm` with a 3D `Range3D`. Both make concurrent reads of the shared input `DataArray`s (FeatureIds, CellPhases, Quats, CrystalStructures). Per the SIMPLNX project policy (`CLAUDE.md`), DataArray subscript access is not formally thread-safe for concurrent reads, but in practice this works for read-only access on contiguous in-memory DataStores. The algorithm has been stable under parallel execution on shipping pipelines; no thread-safety issue surfaced during the V&V cycle's 6-test suite. Out-of-core (OOC) DataStore variants would need explicit testing, but this filter explicitly calls `parallelAlgorithm.requireArraysInMemory(algArrays)` at line 196 to refuse OOC inputs. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md new file mode 100644 index 0000000000..0973edc112 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeFeatureNeighborMisorientationsFilter.md @@ -0,0 +1,130 @@ +# Exemplar Archive Provenance: Inlined in Test + +This sidecar records how the test data used by `ComputeFeatureNeighborMisorientationsFilter`'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 for the new fixtures, and no `.dream3d` exemplar file to fetch. (The shared archive `6_6_stats_test_v2.tar.gz` is still referenced in `src/Plugins/OrientationAnalysis/test/CMakeLists.txt` for the moment because `ComputeKernelAvgMisorientationsFilter` (F#5 in the EbsdLib-precision-cycle list) still consumes it. The archive will be removed from `CMakeLists.txt` after the F#5 V&V cycle retires its exemplar test.) + +--- + +## Archive identity + +| Field | Value | +|-------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| **Archive** | Inlined (no separate archive) | +| **SHA512** | N/A | +| **Used by tests** | `OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Single Phase Two Neighbors` | +| | `OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mixed Phase Neighbors (exposes divisor bug)` | +| | `OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 1 - Mismatch Last Order` | +| | `OrientationAnalysis::ComputeFeatureNeighborMisorientationsFilter: Class 4 - Invariants` | +| **Generated by** | Claude (Opus 4.7, Anthropic) under direction of Michael Jackson | +| **Generated on** | 2026-06-02 | + +## How it was generated + +The dataset is a hand-rolled in-memory `DataStructure` designed as a **Class 1 (Analytical) oracle** with a paired **Class 4 (Invariant)** check. It systematically covers the three branches of the per-feature outer loop / per-neighbor inner loop in `ComputeFeatureNeighborMisorientations::operator()()`: + +1. **Same-phase same-Laue-class neighbor pair** (the happy path — write a numeric misorientation into the list, add to the avg accumulator). +2. **Different-phase neighbor pair** (the phase-mismatch branch — write `NaN` into the list, decrement the divisor). +3. **A mix of branches 1 and 2 inside the same feature's neighbor list** (the bug-exposing path — the per-feature average must use the count of phase-matched neighbors, not the full neighbor-list length, and the divisor must be preserved across j-iterations). + +### Scaffold structure + +A single `ImageGeom` named `ImageGeometry` with 1 voxel (dims = 1×1×1) holds a `CellFeatureData` `AttributeMatrix` with `numTuples = N+1` (N = number of features in the fixture; the +1 is the index-0 background-feature slot that the algorithm skips). Inside `CellFeatureData`: + +- `Phases` (`Int32Array`) — per-feature phase index +- `AvgQuats` (`Float32Array`, 4 components per tuple) — per-feature average quaternion +- `NeighborList` (`NeighborList`) — per-feature list of neighbor feature indices +- `MisorientationList` (`NeighborList`) — output, allocated by the filter's `OutputActions` +- `AvgMisorientations` (`Float32Array`, optional) — output, allocated when `ComputeAvgMisors=true` + +A separate `CellEnsembleData` `AttributeMatrix` with `numTuples = P+1` (P = number of phases in the fixture; the +1 is the index-0 sentinel slot) holds: + +- `CrystalStructures` (`UInt32Array`) — per-phase Laue-class index (`0` = Hex_High, `1` = Cubic_High, etc., per EbsdLib `LaueOps::GetAllOrientationOps()` ordering) + +The scaffold helpers are in `ComputeFeatureNeighborMisorientationsTest.cpp` namespace `ToyFixtures`: + +- `CreateScaffold(numFeatures, numPhases)` — builds the geometry and AttributeMatrices. +- `BuildArgs(dataStructure, computeAvgMisors)` — wires the standard input/output paths into an `Arguments` object. +- `SetAvgQuat(dataStructure, featureIdx, quatVec)` — writes a quaternion into the `AvgQuats` array. +- `QuatFromPhi1Deg(phi1Deg)` — produces an `ebsdlib::QuatD` for a pure φ1 Bunge ZXZ rotation `(φ1, 0, 0)` about the z-axis. + +### Orientation convention + +All Class 1 fixtures use **pure φ1 Bunge ZXZ Euler rotations** `(φ1, 0, 0)` with `Φ = φ2 = 0`. These collapse to a single rotation about the z-axis. For cubic Laue class (`Cubic_High`), the symmetry group includes a 4-fold proper rotation about each crystallographic axis — but cubic's 4-fold-about-c reduces the φ1 difference modulo 90°. By keeping φ1 differences ≤ 45°, the symmetry-reduced minimum-rotation magnitude is exactly `|Δφ1|`. This is what makes the oracle closed-form: no numerical sym-op search required, the expected misorientation is `|φ1_neighbor - φ1_focal|` in degrees. + +### Fixture-by-fixture derivation + +#### Fixture 1 — `Class 1 - Single Phase Two Neighbors` + +- 3 features, 1 phase (Cubic_High). +- Feature 1 (focal): `(0°, 0°, 0°)`. Feature 2: `(5°, 0°, 0°)`. Feature 3: `(10°, 0°, 0°)`. +- `NeighborList[1] = [2, 3]` (focal feature has two neighbors). +- Expected per-neighbor misorientations: `5°` and `10°`. +- Expected per-feature average: `(5 + 10) / 2 = 7.5°`. + +This is the basic-path test. All neighbors are same-phase, so no decrements; divisor = list length = 2 = number of matches. + +#### Fixture 2 — `Class 1 - Mixed Phase Neighbors (exposes divisor bug)` + +- 4 features, 2 phases (both Cubic_High to keep the math simple; the bug is triggered by *phase index mismatch*, not by Laue-class mismatch). +- Feature 1 (focal): phase 1, `(0°, 0°, 0°)`. +- Feature 2: phase 1, `(5°, 0°, 0°)` (match). +- Feature 3: phase 2, `(15°, 0°, 0°)` (mismatch). +- Feature 4: phase 1, `(10°, 0°, 0°)` (match). +- `NeighborList[1] = [2, 3, 4]`. Note the order: `[match, mismatch, match]` — the last neighbor is a match. +- Expected per-neighbor misorientations: `5°`, `NaN`, `10°`. +- Expected per-feature average: `(5 + 10) / 2 = 7.5°` (divisor = 2 phase-matched neighbors). + +This is the bug-exposing path. The pre-fix divisor calculation reassigns `tempMisoList = featureNeighborList.size()` (= 3) on every j-iteration, so the per-mismatch decrement done at j=1 is clobbered by the assignment at j=2. The pre-fix code produces `(5 + 10) / 3 = 5.0°`. The post-fix code produces the correct `7.5°`. + +A fixture where the last neighbor is a mismatch (rather than a match) would NOT expose the bug, because the j=last assignment would be followed by the decrement, leaving divisor = N - 1 = 2 (which happens to match the correct value by accident for this neighbor count). That accidental-correct scenario is captured by Fixture 3 below as a control. + +#### Fixture 3 — `Class 1 - Mismatch Last Order` + +- Same scaffold as Fixture 2: 4 features, 2 phases, all rotations. +- `NeighborList[1] = [2, 4, 3]` — order is `[match, match, mismatch]`, last neighbor is a mismatch. +- Expected per-neighbor misorientations: `5°`, `10°`, `NaN`. +- Expected per-feature average: `(5 + 10) / 2 = 7.5°`. + +This is the control case. Both pre-fix and post-fix code produce `7.5°` here, because the pre-fix code's last-iteration assignment-then-decrement leaves divisor = 3 - 1 = 2 by accident. Without Fixture 2 to expose the bug, the suite would have shipped a divisor bug that happened to pass tests on this neighbor ordering. + +The presence of Fixture 3 alongside Fixture 2 makes the test suite robust to the bug pattern under both common neighbor orderings. + +#### Fixture 4 — `Class 4 - Invariants` + +- Same data as Fixture 1, but exercises Class 4 invariants rather than a specific expected value. +- Invariants asserted: + 1. **Non-negativity:** Every entry in the per-neighbor `MisorientationList` is either `NaN` (phase mismatch) or ≥ `0°`. Misorientation magnitude cannot be negative. + 2. **Upper bound:** Every non-NaN entry in `MisorientationList` is ≤ `62.8°` (the maximum possible cubic-to-cubic misorientation as derived in Mackenzie 1958 / Morawiec). + 3. **Averaging formula:** For each focal feature `i`, `AvgMisorientations[i] = (sum of non-NaN entries in MisorientationList[i]) / (count of non-NaN entries in MisorientationList[i])`. This is the load-bearing invariant for the divisor bug — Fixture 2's bug-exposing path fails this invariant on pre-fix code as well as failing the specific-value `REQUIRE` in Fixture 2. + +The Class 4 invariants are oracle-agnostic — they hold for any input regardless of orientation 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 the Class 1 fixtures were changed. + +### Conversion path in the test + +Unlike `ComputeFeatureFaceMisorientationFilter`, this filter consumes `AvgQuats` directly — no `ChangeAngleRepresentationFilter` / `ConvertOrientationsFilter` step. The test fixtures write quaternions directly into `AvgQuats` via the helper `SetAvgQuat(dataStructure, featureIdx, QuatFromPhi1Deg(angleInDegrees))`. The `QuatFromPhi1Deg` helper internally produces `ebsdlib::QuatD(0, 0, sin(phi1/2), cos(phi1/2))` (the standard z-axis pure rotation in the quaternion convention used by EbsdLib). + +## Canonical oracle output + +| DataPath | Source of expected values | +|-------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `/ImageGeometry/CellFeatureData/MisorientationList` | Class 1 analytical (closed-form, `|Δφ1|` for pure φ1 rotations under cubic symmetry with `Δφ1 ≤ 45°`; `NaN` on phase mismatch). | +| `/ImageGeometry/CellFeatureData/AvgMisorientations` | Class 1 analytical (arithmetic mean of non-NaN entries from above) + Class 4 invariant (non-negativity, upper bound, formula). | + +The expected values are hard-coded into the test as `REQUIRE(Approx(misorientationListEntry).margin(1e-3))` and `REQUIRE(Approx(avgMisorientations[i]).margin(1e-3))` checks. Tolerances are set to `1e-3°` — comfortably wider than the EbsdLib 2.4.1 precision improvement's sub-`0.0001°` shift, but tight enough to catch the divisor bug's `2.5°` magnitude error. + +## 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 the divisor-bug analysis, specifically the claim that the bug fires only when the last-iterated neighbor is a phase match, and confirm the `7.5°` expected value derivation for Fixtures 2 and 3.* +- **Date:** *YYYY-MM-DD (pending)* +- **Skip reason** (if skipped): *N/A — second-engineer review is recommended; not yet performed.* + +## Regenerated to fix a circular-oracle situation? + +N/A — the inlined toy dataset is brand-new for this V&V cycle. The pre-V&V test (now retired) consumed the shared `6_6_stats_test_v2.tar.gz` archive as a regression-against-reference exemplar. That archive was a circular oracle (its `AvgMisorientations` values were produced by the same pre-fix algorithm with the same divisor bug, so the exemplar would only have validated reproduction of the buggy output). The retired test additionally had an `[.][UNIMPLEMENTED][!mayfail]` stub for the `ComputeAvgMisors=true` path that prevented any CI coverage of the buggy code path. + +The inlined Class 1 + Class 4 fixtures replace the circular-oracle exemplar with derived-truth oracles. The shared archive remains referenced in `CMakeLists.txt` only for `ComputeKernelAvgMisorientationsFilter` (F#5 in the EbsdLib-precision V&V cycle); it will be removed entirely once F#5's V&V cycle retires its exemplar consumption. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md new file mode 100644 index 0000000000..d9f36e2de9 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/ComputeKernelAvgMisorientationsFilter.md @@ -0,0 +1,142 @@ +# Exemplar Archive Provenance: Inlined in Test + +This sidecar records how the test data used by `ComputeKernelAvgMisorientationsFilter`'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 specifically owned by this filter, no `download_test_data()` entry for the new fixtures, and no `.dream3d` exemplar file to fetch. (The shared archive `6_6_stats_test_v2.tar.gz` is still referenced in `src/Plugins/OrientationAnalysis/test/CMakeLists.txt`, but for use by `AlignSectionsMutualInformationTest`, `ComputeShapesFilterTest`, and `ComputeSchmidsTest` — not for this filter.) + +--- + +## Archive identity + +| Field | Value | +|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| **Archive** | Inlined (no separate archive) | +| **SHA512** | N/A | +| **Used by tests** | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Uniform 2D Single Feature` | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D x-axis Gradient` | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - 1D z-axis Gradient (3D path)` | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 1 - Multi-Feature Multi-Voxel + Background` | +| | `OrientationAnalysis::ComputeKernelAvgMisorientationsFilter: Class 4 - Invariants` (3 SECTIONs) | +| **Generated by** | Claude (Opus 4.7, Anthropic) under direction of Michael Jackson | +| **Generated on** | 2026-06-03 | + +## How it was generated + +The dataset is a hand-rolled in-memory `DataStructure` designed as a **Class 1 (Analytical) oracle** with a paired **Class 4 (Invariant)** check. It systematically covers the five algorithmic paths in `ComputeKernelAvgMisorientations::FindKernelAvgMisorientationsImpl::convert()`: + +1. **Focal-valid gate** (`featureIds[point] > 0 && cellPhases[point] > 0`) → enter the kernel. +2. **Inner kernel cell in-bounds + feature-id match** → accumulate misorientation + increment divisor. +3. **Inner kernel cell in-bounds + feature-id mismatch** → skip (no accumulate). +4. **Inner kernel cell out-of-bounds** (boundary clamp `col+l > xPoints-1` etc.) → `continue`. +5. **Focal-invalid** (`featureIds[point] == 0 || cellPhases[point] == 0`) → KAM = 0 directly. + +The 6th algorithmic path (`numVoxel == 0` fallback at line 131) is dead code in practice and is not exercised by design. + +### Scaffold structure + +The `DataFixtures` namespace at the top of `ComputeKernelAvgMisorientationsTest.cpp` provides a `CreateScaffold(nX, nY, nZ, numCrystalStructures)` helper that constructs: + +- A single `ImageGeom` named `ImageGeometry` with the requested dimensions, spacing `{1, 1, 1}`, and origin `{0, 0, 0}`. +- A `CellData` `AttributeMatrix` with tuple shape `{nZ, nY, nX}` (the SIMPLNX storage convention for 3D cell-level arrays). +- Three input cell arrays initialized to default values: `FeatureIds` (int32, default 1), `Phases` (int32, default 1), `Quats` (float32, 4 components, default identity `(0, 0, 0, 1)`). +- An `EnsembleData` `AttributeMatrix` with tuple shape `{numCrystalStructures}` (defaulting to 2: index 0 = sentinel `999`, index 1 = `Cubic_High` = 1). +- A `CrystalStructures` ensemble array set to `[999, 1]` by default. + +Test cases use `SetCellQuat(td, cellIdx, q)` to overwrite individual cell quaternions and direct `(*td.featureIds)[i] = N` / `(*td.cellPhases)[i] = N` assignments to set up multi-feature / background scenarios. `BuildArgs(kernelRadius)` constructs the `Arguments` object for the filter with the standard input/output paths. + +### Orientation convention + +All Class 1 fixtures use **pure φ1 Bunge ZXZ Euler rotations** `(φ1, 0, 0)` with `Φ = φ2 = 0`. These collapse to a single rotation about the z-axis. For cubic Laue class (`Cubic_High`), the symmetry group's 4-fold proper rotation about each crystallographic axis includes the c-axis (z), which reduces φ1 differences modulo 90°. By keeping φ1 differences ≤ 45°, the symmetry-reduced minimum-rotation magnitude is exactly `|Δφ1|`. This makes the oracle closed-form: no numerical sym-op search required, the expected misorientation between two cubic-phase cells is `|φ1_neighbor - φ1_focal|` in degrees. + +The `QuatFromPhi1Deg(phi1)` helper produces `{0, 0, sin(phi1/2 rad), cos(phi1/2 rad)}` — the standard quaternion form of a pure z-axis rotation in the EbsdLib (x, y, z, w) Vector-Scalar order. + +### Fixture-by-fixture derivation + +#### Fixture 1 — `Class 1 - Uniform 2D Single Feature` + +- 3×3×1 image, single feature (id=1), single phase (cubic), all cells share the identity quaternion `(0, 0, 0, 1)`. +- Kernel radius `{1, 1, 0}` → 3×3×1 kernel (up to 9 cells per kernel, fewer at edges). +- Expected: every cell's KAM = 0° (all in-kernel neighbors have the same orientation). +- Tests: focal-valid gate (path 1), feature-id-match (path 2), 2D boundary clamp (path 4). + +#### Fixture 2 — `Class 1 - 1D x-axis Gradient` + +- 5×1×1 image (single row), single feature, single phase. +- Per-cell φ1: `[0°, 5°, 10°, 15°, 20°]`. +- Kernel radius `{1, 0, 0}` → 1D kernel along x. +- Expected per-cell KAM derivation: + - cell 0: neighbors `{self=0°, x+1=5°}` → misos `{0, 5}` → avg `5/2 = 2.500°` + - cell 1: neighbors `{x-1=0°, self=5°, x+1=10°}` → misos `{5, 0, 5}` → avg `10/3 ≈ 3.3333°` + - cell 2: neighbors `{x-1=5°, self=10°, x+1=15°}` → misos `{5, 0, 5}` → avg `10/3 ≈ 3.3333°` + - cell 3: neighbors `{x-1=10°, self=15°, x+1=20°}` → misos `{5, 0, 5}` → avg `10/3 ≈ 3.3333°` + - cell 4: neighbors `{x-1=15°, self=20°}` → misos `{5, 0}` → avg `5/2 = 2.500°` +- Tests: averaging arithmetic + 1D x-stride boundary clamp. + +#### Fixture 3 — `Class 1 - 1D z-axis Gradient (3D path)` + +- 1×1×3 image (single column in z), single feature, single phase. +- Per-plane φ1: `[0°, 10°, 20°]`. +- Kernel radius `{0, 0, 1}` → 1D kernel along z. +- Expected per-cell KAM: + - plane 0 (focal=0°): neighbors `{self=0°, z+1=10°}` → misos `{0, 10}` → avg `10/2 = 5.000°` + - plane 1 (focal=10°): neighbors `{z-1=0°, self=10°, z+1=20°}` → misos `{10, 0, 10}` → avg `20/3 ≈ 6.6667°` + - plane 2 (focal=20°): neighbors `{z-1=10°, self=20°}` → misos `{10, 0}` → avg `10/2 = 5.000°` +- Tests: 3D outer-loop z-path + z-stride boundary clamp. + +#### Fixture 4 — `Class 1 - Multi-Feature Multi-Voxel + Background` + +- 6×1×1 image. Cell layout (x = 0 to 5): + - cell 0: featureId=1, phase=1, φ1=0° + - cell 1: featureId=1, phase=1, φ1=10° + - cell 2: featureId=2, phase=1, φ1=0° + - cell 3: featureId=2, phase=1, φ1=20° + - cell 4: featureId=0, phase=0 (background — orientation irrelevant) + - cell 5: featureId=1, phase=1, φ1=30° +- Kernel radius `{1, 0, 0}`. +- Expected per-cell KAM derivation: + - cell 0 (F1, φ1=0°): kernel `{x=0 self, x=1 (F1)}`. Same-feat misos `{|0-0|, |10-0|} = {0, 10}`. sum=10, div=2 → KAM = 5.000°. + - cell 1 (F1, φ1=10°): kernel `{x=0 (F1), x=1 self, x=2 (F2 - SKIP)}`. Same-feat misos `{|0-10|, |10-10|} = {10, 0}`. sum=10, div=2 → KAM = 5.000°. + - cell 2 (F2, φ1=0°): kernel `{x=1 (F1 - SKIP), x=2 self, x=3 (F2)}`. Same-feat misos `{|0-0|, |20-0|} = {0, 20}`. sum=20, div=2 → KAM = 10.000°. + - cell 3 (F2, φ1=20°): kernel `{x=2 (F2), x=3 self, x=4 (F0 - SKIP because F0 ≠ F2)}`. Same-feat misos `{|0-20|, |20-20|} = {20, 0}`. sum=20, div=2 → KAM = 10.000°. + - cell 4 (F0/P0, background): focal-invalid (path 5) → KAM = 0° exactly. + - cell 5 (F1, φ1=30°): kernel `{x=4 (F0 - SKIP), x=5 self}`. Same-feat misos `{|30-30|} = {0}`. sum=0, div=1 → KAM = 0.000°. +- Tests: multi-voxel within-feature averaging (cells 0–3), multi-feature mismatch skip (cells 1, 2, 5 see different-feature in-bounds neighbors), background skip path (cell 4 — also asserted with `REQUIRE(kam[4] == 0.0f)` exact-zero check), isolated single-cell feature (cell 5). + +#### Fixture 5 — `Class 4 - Invariants` (3 sub-sections) + +Three SECTIONs each constructing a fresh fixture inline: + +**Sub-section (i):** 3×3×3 uniform-identity-quaternion fixture, kernel `{1, 1, 1}`. Asserts every cell's KAM == 0 within `1e-4°` tolerance. Tests the 3D outer-loop path that none of the 4 Class 1 fixtures exercises end-to-end. + +**Sub-section (ii):** 3×1×1 fixture with cells `[F1P1(0°), F0P0(bg), F1P1(10°)]`, kernel `{1, 0, 0}`. Asserts `kam[1] == 0.0f` exactly (the background cell). This is the exactness invariant — no tolerance allowed. + +**Sub-section (iii):** Same 5×1×1 x-axis gradient as Fixture 2. Asserts three universal invariants: +- Non-negativity: `kam[i] >= 0` for all cells. +- Cubic upper bound: `kam[i] <= 62.8°` (Mackenzie cubic maximum disorientation). +- Non-triviality: at least one cell has `kam > 1e-4°` (sanity check that the algorithm actually ran rather than zeroing everything). + +The Class 4 invariants are oracle-agnostic — they hold for any input, so they catch regressions even if specific Class 1 expected values were edited away. + +## Canonical oracle output + +| DataPath | Source of expected values | +|---------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `/ImageGeometry/CellData/KernelAverageMisorientationsOut` | Class 1 analytical (closed-form `|Δφ1|` summed and divided per kernel + path-5 background→0 short-circuit) + Class 4 invariants (non-negativity, cubic max, exact-0 for background, uniform→0). | + +The expected values are hard-coded into each TEST_CASE as `REQUIRE(kam[i] == Approx(expected[i]).margin(1e-3f))` checks for the gradient fixtures and `REQUIRE(kam[i] == 0.0f)` exact-zero checks for the background-cell case. Tolerance set to `1e-3°` — comfortably wider than EbsdLib 2.4.1+ precision (~`1e-5°` for the per-pair misorientation case) but tight enough to catch the legacy bug (D2) on asymmetric kernels and the EbsdLib 2.4.0 precision regression (D1) on focal-cell self-misorientation. + +## 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 the cubic FZ argument (especially the "for pure z-rotations |Δφ1| ≤ 45° the disorientation IS |Δφ1|" claim) and the Fixture 4 multi-feature multi-voxel hand-derivation (6 per-cell expected values with mixed feature mismatch and background skip).* +- **Date:** *YYYY-MM-DD (pending)* +- **Skip reason** (if skipped): *N/A — second-engineer review is recommended; not yet performed.* + +## Regenerated to fix a circular-oracle situation? + +**Yes.** The inlined analytical dataset replaces the retired exemplar-comparison test. The pre-V&V test consumed the shared `6_6_stats_test_v2.tar.gz` archive's `KernelAverageMisorientations` exemplar, which was a circular oracle: the exemplar values were generated from a SIMPLNX build using EbsdLib < 2.4.1, where every cell's KAM was inflated by the spurious `~0.001–0.03°` self-misorientation precision noise documented as D1. Comparing the post-EbsdLib-2.4.1 SIMPLNX output against those exemplars would systematically fail by ~0.01–0.05° per cell (varies by focal-cell quaternion), which is what surfaced during the EbsdLib precision-cycle V&V work. + +The inlined Class 1 + Class 4 fixtures use derived-truth oracles independent of any pre-existing SIMPLNX output, eliminating the circular oracle pattern. The shared archive `6_6_stats_test_v2.tar.gz` remains downloaded for the 3 other filter tests that still consume it (`AlignSectionsMutualInformation`, `ComputeShapes`, `ComputeSchmids`); only F#5's consumption line in `ComputeKernelAvgMisorientationsTest.cpp` was removed during this V&V cycle. 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" } ] },