From b7b18d64b0a25d8c4a3ac3228027e3b0fa11062d Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Sun, 5 Jul 2026 10:31:03 -0400 Subject: [PATCH 1/3] =?UTF-8?q?VV:=20Rotate=20Euler=20Reference=20Frame=20?= =?UTF-8?q?=E2=80=94=20READY=20FOR=20REVIEW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Confirmed no bugs in output math (clean Port); fixed one input-validation gap (zero-length rotation axis now fails preflight with error -96200 instead of silently NaN-corrupting the Euler array); - documented 0 deviations from DREAM3D 6.5.171 (3 non-deviations recorded: N1 deg-to-rad conversion precision, N2 0-vs-2pi wrap representation, N3 zero-axis input validation); - no tests retired (ASCIIData comparison test reclassified as legacy-parity regression pin — its EulersRotated.csv is legacy output, not an oracle); - augmented existing tests with 12 inlined *Class 1 (Analytical) + Class 4 (Invariant)* fixtures/sections plus a zero-axis preflight error test; - added 3 V&V source-tree deliverables (report, deviations, provenance); - doc updated (units/conventions section, Rowenhorst 2015 citation, corrected example-pipeline list). Signed-off-by: Michael Jackson --- .../docs/RotateEulerRefFrameFilter.md | 25 +- .../Algorithms/RotateEulerRefFrame.cpp | 12 +- .../Filters/RotateEulerRefFrameFilter.cpp | 9 + .../test/RotateEulerRefFrameTest.cpp | 233 ++++++++++++++++++ .../vv/RotateEulerRefFrameFilter.md | 98 ++++++++ .../deviations/RotateEulerRefFrameFilter.md | 68 +++++ .../provenance/RotateEulerRefFrameFilter.md | 66 +++++ 7 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/deviations/RotateEulerRefFrameFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md diff --git a/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md index 31a82711c7..8eb9026fc6 100644 --- a/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md @@ -6,16 +6,31 @@ Processing (Conversion) ## Description -This **Filter** performs a passive rotation (Right hand rule) of the Euler Angles about a user defined axis. The *reference frame* is being rotated and thus the *Euler Angles* necessary to represent the same orientation must change to account for the new *reference frame*. The user can set an *angle* and an *axis* to define the rotation of the *reference frame*. +This **Filter** performs a passive rotation (Right hand rule) of the Euler Angles about a user defined axis. The *reference frame* is being rotated and thus the *Euler Angles* necessary to represent the same orientation must change to account for the new *reference frame*. The user can set an *angle* and an *axis* to define the rotation of the *reference frame*. + +For each Euler angle triplet the filter computes `g' = g · R(n, ω)`, where `g` is the orientation matrix of the input Euler angles (Bunge ZXZ convention), `R(n, ω)` is the rotation matrix for the user-supplied axis `n` and angle `ω`, and `g'` is converted back to Euler angles. For a rotation about the sample Z axis this reduces to `φ1' = φ1 − ω (mod 2π)` with `Φ` and `φ2` unchanged. + +### Units & Conventions + +- The rotation **angle parameter is in degrees**; the **Euler angle data is in radians** (Bunge ZXZ convention). +- The rotation axis does not need to be normalized — the filter normalizes it internally. A zero-length axis (0,0,0) is rejected during preflight. +- The selected Euler angles array is modified **in place** (no new array is created). +- Output Euler angles are canonicalized to `φ1, φ2 ∈ [0, 2π)` and `Φ ∈ [0, π]`. % Auto generated parameter table will be inserted here ## Example Pipelines -+ INL Export -+ Export Small IN100 ODF Data (StatsGenerator) -+ Edax IPF Colors -+ Confidence Index Histogram ++ EBSD_File_Processing/Read_EDAX_Ang_File ++ EBSD_File_Processing/Edax_IPF_Colors ++ EBSD_File_Processing/CI_Histogram ++ EBSD_File_Processing/EBSD_Hexagonal_Data_Analysis ++ EBSD_File_Processing/ImportEdaxOIMData + +## References + +- D. Rowenhorst, A. D. Rollett, G. S. Rohrer, M. Groeber, M. Jackson, P. J. Konijnenberg, and M. De Graef, "Consistent representations of and conversions between 3D rotations," *Modelling and Simulation in Materials Science and Engineering*, vol. 23, no. 8, p. 083501, 2015. DOI: [10.1088/0965-0393/23/8/083501](https://doi.org/10.1088/0965-0393/23/8/083501) + ## License & Copyright diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp index 9f38ec997d..4856ede0c4 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp @@ -27,17 +27,20 @@ class RotateEulerRefFrameImpl public: RotateEulerRefFrameImpl(Float32Array& data, const FloatVec3& rotAxis, float angle, const std::atomic_bool& shouldCancel, ProgressMessageHelper& progressMessageHelper) : m_CellEulerAngles(data) - , m_AxisAngle(rotAxis) + , m_RotationAxis(rotAxis) , m_Angle(angle) , m_ShouldCancel(shouldCancel) , m_ProgressMessageHelper(progressMessageHelper) { } - virtual ~RotateEulerRefFrameImpl() = default; + ~RotateEulerRefFrameImpl() = default; void convert(size_t start, size_t end) const { - ebsdlib::OrientationMatrixDType om = ebsdlib::AxisAngleDType(m_AxisAngle[0], m_AxisAngle[1], m_AxisAngle[2], m_Angle * nx::core::numbers::pi / 180.0).toOrientationMatrix(); + // m_Angle arrives in degrees (user-facing parameter) while the Euler angle data is in + // radians. The axis-angle pair produces the active rotation matrix R, so gNew = g * R + // implements a passive rotation of the sample reference frame by +angle (right-hand rule). + ebsdlib::OrientationMatrixDType om = ebsdlib::AxisAngleDType(m_RotationAxis[0], m_RotationAxis[1], m_RotationAxis[2], m_Angle * nx::core::numbers::pi / 180.0).toOrientationMatrix(); OrientationUtilities::Matrix3dR rotMat = om.toEigenGMatrix(); @@ -45,7 +48,6 @@ class RotateEulerRefFrameImpl usize counter = 0; usize counterIncrement = (end - start) / 100; - // float ea1 = 0, ea2 = 0, ea3 = 0; for(size_t i = start; i < end; i++) { if(m_ShouldCancel) @@ -77,7 +79,7 @@ class RotateEulerRefFrameImpl private: Float32Array& m_CellEulerAngles; - FloatVec3 m_AxisAngle; + FloatVec3 m_RotationAxis; float m_Angle = 0.0F; const std::atomic_bool& m_ShouldCancel; ProgressMessageHelper& m_ProgressMessageHelper; diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp index f4e403ba9e..629d86335b 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp @@ -83,6 +83,15 @@ IFilter::PreflightResult RotateEulerRefFrameFilter::preflightImpl(const DataStru std::vector preflightUpdatedValues; + // A zero-length axis cannot be normalized and would silently fill the Euler angles with NaN + const float32 axisMagnitude = + std::sqrt(pRotationAxisAngleValue[0] * pRotationAxisAngleValue[0] + pRotationAxisAngleValue[1] * pRotationAxisAngleValue[1] + pRotationAxisAngleValue[2] * pRotationAxisAngleValue[2]); + if(axisMagnitude < std::numeric_limits::epsilon()) + { + return {MakeErrorResult(-96200, fmt::format("Rotation axis <{}, {}, {}> has zero length. The ijk components must define a non-zero direction.", pRotationAxisAngleValue[0], + pRotationAxisAngleValue[1], pRotationAxisAngleValue[2]))}; + } + resultOutputActions.value().modifiedActions.emplace_back( DataObjectModification{pCellEulerAnglesArrayPathValue, DataObjectModification::ModifiedType::Modified, dataStructure.getData(pCellEulerAnglesArrayPathValue)->getDataObjectType()}); diff --git a/src/Plugins/OrientationAnalysis/test/RotateEulerRefFrameTest.cpp b/src/Plugins/OrientationAnalysis/test/RotateEulerRefFrameTest.cpp index dab91ee0ae..646b3894d9 100644 --- a/src/Plugins/OrientationAnalysis/test/RotateEulerRefFrameTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/RotateEulerRefFrameTest.cpp @@ -14,6 +14,10 @@ #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" + +#include #include #include @@ -21,6 +25,76 @@ namespace fs = std::filesystem; using namespace nx::core; +namespace AnalyticalFixtures +{ +// Class 1 (Analytical) oracle fixtures for RotateEulerRefFrameFilter. +// +// The filter rotates the sample reference frame by angle w (degrees, right-hand rule) +// about sample-frame axis n. Derivation (independent of EbsdLib/SIMPLNX/legacy code): +// - Bunge passive convention: v_crystal = g . v_sample +// - Rotating the reference frame by +w about unit axis n gives new-frame coordinates +// u_new = P(w) . u_old with P(w) the passive rotation matrix, so +// g' = g . P(w)^T = g . R_active(n, w) (R_active = Rodrigues rotation matrix) +// - Euler <-> matrix conversions per Rowenhorst et al 2015, Modelling Simul. Mater. +// Sci. Eng. 23 083501, Eq. A.5 (eu2om) and Eq. A.9 (om2eu), with outputs +// canonicalized to phi1, phi2 in [0, 2pi), Phi in [0, pi]. +// +// Closed forms used below (all verified by the independent numpy script archived in +// vv/provenance — see the V&V report): +// - Z-axis rotation: phi1' = phi1 - w (mod 2pi), Phi and phi2 unchanged. +// - Identity orientation + axis-angle (n, w): g' = R_active(n, w), om2eu by hand. +struct FixtureData +{ + std::string name; + std::array eulerIn; // radians + std::array axisAngle; // i, j, k, w (degrees) + std::array expected; // radians, canonical ranges +}; + +const std::vector k_Fixtures = { + // F1: g = I, rotate frame +90 about z. phi1' = 0 - pi/2 mod 2pi = 3pi/2. + {"F1 identity + z90", {0.0F, 0.0F, 0.0F}, {0.0F, 0.0F, 1.0F, 90.0F}, {4.712388980384709, 0.0, 0.0}}, + // F2: general orientation, z-axis: phi1' = 1.0 - pi/6; Phi, phi2 unchanged. + {"F2 general + z30", {1.0F, 0.5F, 0.3F}, {0.0F, 0.0F, 1.0F, 30.0F}, {0.476401224401701, 0.5, 0.3}}, + // F3: wrap-around: phi1' = 0.1 - pi/2 + 2pi. + {"F3 wraparound + z90", {0.1F, 0.5F, 0.3F}, {0.0F, 0.0F, 1.0F, 90.0F}, {4.812388980384732, 0.5, 0.3}}, + // F4: g = I, +90 about x: g' = R_active(x, 90) => om2eu = (pi, pi/2, pi) by hand. + {"F4 identity + x90", {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F, 90.0F}, {3.141592653589793, 1.570796326794897, 3.141592653589793}}, + // F5: axis (2,0,0) must normalize to (1,0,0); expected identical to F4. + {"F5 unnormalized axis x90", {0.0F, 0.0F, 0.0F}, {2.0F, 0.0F, 0.0F, 90.0F}, {3.141592653589793, 1.570796326794897, 3.141592653589793}}, + // F6: w = 0 is a no-op for already-canonical inputs. + {"F6 zero angle no-op", {5.0F, 1.0F, 2.0F}, {0.0F, 0.0F, 1.0F, 0.0F}, {5.0, 1.0, 2.0}}, + // F7: g = I, +120 about (1,1,1): R_active maps x->y->z->x; om2eu = (pi, pi/2, pi/2) by hand. + {"F7 identity + 111 120", {0.0F, 0.0F, 0.0F}, {1.0F, 1.0F, 1.0F, 120.0F}, {3.141592653589793, 1.570796326794896, 1.570796326794897}}, + // F8: general orientation, y-axis 45 deg. No closed form; value from the numpy oracle script. + {"F8 general + y45", {0.8F, 1.1F, 0.4F}, {0.0F, 1.0F, 0.0F, 45.0F}, {0.208427151998385, 0.687549596714496, 1.288701818256954}}, +}; + +// Creates a DataStructure holding a cell AttributeMatrix with one float32 3-component +// Euler angles array of numTuples tuples, returning the array pointer. +inline Float32Array* CreateEulerArray(DataStructure& dataStructure, usize numTuples) +{ + AttributeMatrix* cellData = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{numTuples}); + return UnitTest::CreateTestDataArray(dataStructure, "EulerAngles", {numTuples}, {3}, cellData->getId()); +} + +// Runs RotateEulerRefFrameFilter in place on the given euler array. +inline void RunRotateFilter(DataStructure& dataStructure, const DataPath& eulerPath, const std::array& axisAngle) +{ + RotateEulerRefFrameFilter filter; + Arguments args; + args.insertOrAssign(RotateEulerRefFrameFilter::k_RotationAxisAngle_Key, + std::make_any(std::vector{axisAngle[0], axisAngle[1], axisAngle[2], axisAngle[3]})); + args.insertOrAssign(RotateEulerRefFrameFilter::k_EulerAnglesArrayPath_Key, std::make_any(eulerPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +} +} // namespace AnalyticalFixtures + TEST_CASE("OrientationAnalysis::RotateEulerRefFrame", "[OrientationAnalysis][RotateEulerRefFrameFilter]") { const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "ASCIIData.tar.gz", "ASCIIData"); @@ -168,3 +242,162 @@ TEST_CASE("OrientationAnalysis::RotateEulerRefFrameFilter: SIMPL Backwards Compa } } } + +TEST_CASE("OrientationAnalysis::RotateEulerRefFrameFilter: Class 1 Analytical Fixtures", "[OrientationAnalysis][RotateEulerRefFrameFilter]") +{ + UnitTest::LoadPlugins(); + + // Single-pass double-precision math on float32 storage: float32 rounding of the decimal + // inputs perturbs the output by O(1e-7); 1e-5 rad is a comfortable-but-tight bound. + constexpr float64 k_Tolerance = 1.0e-5; + + const DataPath k_EulerPath({"CellData", "EulerAngles"}); + + for(const auto& fixture : AnalyticalFixtures::k_Fixtures) + { + DYNAMIC_SECTION(fixture.name) + { + DataStructure dataStructure; + Float32Array* eulerArray = AnalyticalFixtures::CreateEulerArray(dataStructure, 1); + for(usize c = 0; c < 3; c++) + { + (*eulerArray)[c] = fixture.eulerIn[c]; + } + + AnalyticalFixtures::RunRotateFilter(dataStructure, k_EulerPath, fixture.axisAngle); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_EulerPath)); + const auto& resultRef = dataStructure.getDataRefAs(k_EulerPath); + for(usize c = 0; c < 3; c++) + { + const float64 absDif = std::fabs(static_cast(resultRef[c]) - fixture.expected[c]); + INFO(fmt::format("{}: component {}: actual {} expected {}", fixture.name, c, resultRef[c], fixture.expected[c])); + REQUIRE(absDif < k_Tolerance); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + } +} + +TEST_CASE("OrientationAnalysis::RotateEulerRefFrameFilter: Zero-Length Axis Fails Preflight", "[OrientationAnalysis][RotateEulerRefFrameFilter]") +{ + UnitTest::LoadPlugins(); + + const DataPath k_EulerPath({"CellData", "EulerAngles"}); + + DataStructure dataStructure; + AnalyticalFixtures::CreateEulerArray(dataStructure, 1); + + RotateEulerRefFrameFilter filter; + Arguments args; + args.insertOrAssign(RotateEulerRefFrameFilter::k_RotationAxisAngle_Key, std::make_any(std::vector{0.0F, 0.0F, 0.0F, 90.0F})); + args.insertOrAssign(RotateEulerRefFrameFilter::k_EulerAnglesArrayPath_Key, std::make_any(k_EulerPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); +} + +TEST_CASE("OrientationAnalysis::RotateEulerRefFrameFilter: Class 4 Invariants", "[OrientationAnalysis][RotateEulerRefFrameFilter]") +{ + UnitTest::LoadPlugins(); + + const DataPath k_EulerPath({"CellData", "EulerAngles"}); + + // A batch of canonical-range orientations (phi1, phi2 in [0, 2pi), Phi in [0, pi]). + const std::vector> k_BatchEulers = { + {0.0F, 0.0F, 0.0F}, {1.0F, 0.5F, 0.3F}, {0.7F, 0.9F, 4.2F}, {2.0F, 2.5F, 1.0F}, {5.9F, 0.1F, 3.3F}, {3.1F, 1.6F, 6.0F}, + }; + + constexpr float32 k_TwoPi = 6.2831855F; + constexpr float32 k_Pi = 3.1415927F; + + SECTION("Output ranges: phi1, phi2 in [0, 2pi], Phi in [0, pi]") + { + DataStructure dataStructure; + Float32Array* eulerArray = AnalyticalFixtures::CreateEulerArray(dataStructure, k_BatchEulers.size()); + for(usize t = 0; t < k_BatchEulers.size(); t++) + { + for(usize c = 0; c < 3; c++) + { + (*eulerArray)[t * 3 + c] = k_BatchEulers[t][c]; + } + } + + AnalyticalFixtures::RunRotateFilter(dataStructure, k_EulerPath, {0.3F, -0.5F, 0.81F, 37.0F}); + + const auto& resultRef = dataStructure.getDataRefAs(k_EulerPath); + for(usize t = 0; t < k_BatchEulers.size(); t++) + { + INFO(fmt::format("tuple {}", t)); + REQUIRE(resultRef[t * 3 + 0] >= 0.0F); + REQUIRE(resultRef[t * 3 + 0] <= k_TwoPi); + REQUIRE(resultRef[t * 3 + 1] >= 0.0F); + REQUIRE(resultRef[t * 3 + 1] <= k_Pi); + REQUIRE(resultRef[t * 3 + 2] >= 0.0F); + REQUIRE(resultRef[t * 3 + 2] <= k_TwoPi); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("Round-trip: rotate by (n, w) then (-n, w) recovers the input") + { + DataStructure dataStructure; + Float32Array* eulerArray = AnalyticalFixtures::CreateEulerArray(dataStructure, k_BatchEulers.size()); + for(usize t = 0; t < k_BatchEulers.size(); t++) + { + for(usize c = 0; c < 3; c++) + { + (*eulerArray)[t * 3 + c] = k_BatchEulers[t][c]; + } + } + + AnalyticalFixtures::RunRotateFilter(dataStructure, k_EulerPath, {0.3F, -0.5F, 0.81F, 37.0F}); + AnalyticalFixtures::RunRotateFilter(dataStructure, k_EulerPath, {-0.3F, 0.5F, -0.81F, 37.0F}); + + const auto& resultRef = dataStructure.getDataRefAs(k_EulerPath); + constexpr float32 k_Tolerance = 1.0e-4F; // two float32 store/load passes + for(usize i = 0; i < k_BatchEulers.size() * 3; i++) + { + const float32 absDif = std::fabs(resultRef[i] - k_BatchEulers[i / 3][i % 3]); + INFO(fmt::format("flat index {}", i)); + REQUIRE(absDif < k_Tolerance); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + + SECTION("Composability: 45 deg twice equals 90 deg once") + { + DataStructure twiceDataStructure; + Float32Array* twiceArray = AnalyticalFixtures::CreateEulerArray(twiceDataStructure, k_BatchEulers.size()); + DataStructure onceDataStructure; + Float32Array* onceArray = AnalyticalFixtures::CreateEulerArray(onceDataStructure, k_BatchEulers.size()); + for(usize t = 0; t < k_BatchEulers.size(); t++) + { + for(usize c = 0; c < 3; c++) + { + (*twiceArray)[t * 3 + c] = k_BatchEulers[t][c]; + (*onceArray)[t * 3 + c] = k_BatchEulers[t][c]; + } + } + + AnalyticalFixtures::RunRotateFilter(twiceDataStructure, k_EulerPath, {0.0F, 0.0F, 1.0F, 45.0F}); + AnalyticalFixtures::RunRotateFilter(twiceDataStructure, k_EulerPath, {0.0F, 0.0F, 1.0F, 45.0F}); + AnalyticalFixtures::RunRotateFilter(onceDataStructure, k_EulerPath, {0.0F, 0.0F, 1.0F, 90.0F}); + + const auto& twiceRef = twiceDataStructure.getDataRefAs(k_EulerPath); + const auto& onceRef = onceDataStructure.getDataRefAs(k_EulerPath); + constexpr float32 k_Tolerance = 1.0e-4F; + for(usize i = 0; i < k_BatchEulers.size() * 3; i++) + { + const float32 absDif = std::fabs(twiceRef[i] - onceRef[i]); + INFO(fmt::format("flat index {}", i)); + REQUIRE(absDif < k_Tolerance); + } + + UnitTest::CheckArraysInheritTupleDims(twiceDataStructure); + UnitTest::CheckArraysInheritTupleDims(onceDataStructure); + } +} diff --git a/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md new file mode 100644 index 0000000000..8b3db14544 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md @@ -0,0 +1,98 @@ +# V&V Report: RotateEulerRefFrameFilter + +| | | +|--------|--------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `0458edcd-3655-4465-adc8-b036d76138b5` | +| SIMPLNX Human Name | Rotate Euler Reference Frame | +| DREAM3D 6.5.171 equivalent | `RotateEulerRefFrame` — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/RotateEulerRefFrame.{h,cpp}` | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | ** | + +## At a glance + +| Aspect | Current state | +|------------------------|--| +| Algorithm Relationship | **Port** of legacy `RotateEulerRefFrame::execute()`. Same per-tuple kernel `gNew = normalize_cols(eu2om(euler) * ax2om(axis, angle))` → `om2eu`; library swaps only (OrientationLib → EbsdLib, hand-rolled `MatrixMath` → Eigen) plus a double-precision degree→radian conversion and progress/cancel plumbing. | +| Oracle (confirmed) | **Class 1 (Analytical) primary** — 8 hand/script-derived fixtures (`AnalyticalFixtures::k_Fixtures`) with closed-form derivations for Z-axis (`phi1' = phi1 - w mod 2pi`), identity + X/111 axes, normalization, and zero-angle cases. **Class 4 (Invariant) companion** — output-range bounds, (n,w)/(-n,w) round-trip, 45°+45° = 90° composability. Cross-checked by an independent numpy script (Rowenhorst 2015 Eq. A.5/A.9 + first-principles frame-rotation derivation). All pass. | +| Code paths enumerated | 6 of 7 exercised; the mid-loop cancel path is not directly tested (requires cancel-signal injection — same accepted gap as prior V&V reports). | +| Tests today | **5 TEST_CASEs / 13 ctest sections** — 8 Class 1 fixtures (DYNAMIC_SECTION), 3 Class 4 invariant sections, 1 zero-axis preflight-error test (new guard added this cycle), 1 legacy-parity 480k-tuple regression pin (ASCIIData), 1 SIMPL 6.4/6.5 backwards-compat. | +| Exemplar archive | `ASCIIData.tar.gz` (pre-existing, shared archive) — provides the 480k-tuple legacy-parity input/comparison CSVs only. **Not an oracle** (legacy-DREAM3D provenance); the Class 1 oracle is inline in the test source. No new archive needed. | +| Legacy comparison | **Run** against DREAM3D 6.5.171 on 6 axis/angle cases × 12 orientations (shared CSV input). Max wrap-aware diff 7.2e-7 rad (float32 ULP level). Both implementations independently match the numpy oracle (NX 2.3e-7, legacy 8.1e-7). No deviations; two non-deviations documented. | +| Bug flags | None. One SIMPLNX robustness gap fixed this cycle: zero-length rotation axis previously produced silent NaN corruption; preflight now rejects it (error `-96200`). Legacy 6.5.171 retains the NaN behavior (documented as a non-deviation — not output-correctness). | +| V&V phase | Oracle design + reconciliation, algorithm review (fixes applied), code-path coverage, test inventory, legacy comparison, deviations, and provenance complete. **Outstanding:** second-engineer oracle review; engineer sign-off; dual-build (OOC) run deferred — no OOC-specific variant of this algorithm and no OOC build configured in Workspace4. | + +## Summary + +`RotateEulerRefFrameFilter` performs a passive rotation of the sample reference frame by a user-supplied axis-angle pair (degrees), rewriting a float32 3-component Bunge ZXZ Euler-angle array in place via `gNew = normalize_cols(eu2om(euler) · ax2om(axis, w))` → `om2eu`. Verification used a **Class 1 (Analytical) oracle** — 8 fixtures with closed-form expected outputs derived from first principles (g' = g·R_active(n,w)) and Rowenhorst 2015 conversion equations, validated by an independent numpy script — plus **Class 4 invariants** (canonical output ranges, inverse-rotation round-trip, angle composability). SIMPLNX matched the oracle on every fixture with zero discrepancies; the 6.5.171 A/B comparison across 6 axis/angle cases shows ULP-level agreement and no deviations. + +## Algorithm Relationship + +*Classification:* **Port** + +*Evidence:* Near line-by-line translation of legacy `RotateEulerRefFrame::execute()` + `RotateEulerRefFrameImpl::convert()` (SIMPL UUID `{ef9420b2-8c46-55f3-8ae4-f53790639de4}` retained in the legacy-UUID map; SIMPL 6.4/6.5 conversion fixtures at `test/simpl_conversion/6_*/RotateEulerRefFrameFilter.json`). Identical control flow: normalize axis → per-tuple eu2om, multiply by ax2om rotation matrix, column-normalize, om2eu, write back in place, TBB-parallel over tuples. + +*Port-time deltas:* + +1. **Orientation library**: OrientationLib `DOrientTransformsType::{ax2om, eu2om, om2eu}` → EbsdLib `ebsdlib::{AxisAngleDType, EulerDType, OrientationMatrixDType}`. EbsdLib is the direct descendant of OrientationLib; same Rowenhorst-convention equations (`epsijk = +1`). No output change (confirmed by A/B). +2. **Matrix math**: hand-rolled `MatrixMath::Multiply3x3with3x3D` + `Normalize3x3D` → Eigen row-major multiply + `colwise().normalized()`. Semantically identical column-wise normalization; legacy's per-entry `>1` clamp is unreachable except through rounding. No output change. +3. **Degree→radian conversion precision**: legacy computes `float rotAngle = angle * pi / 180.0` in **float** before the double-precision transforms; SIMPLNX converts in **double**. ULP-level output difference only (see non-deviation N1). +4. **Progress reporting + cancel checking**: legacy has neither; SIMPLNX adds `ProgressMessageHelper`/`ProgressMessenger` (throttled) and per-element `m_ShouldCancel` checks. UX-only. +5. **Zero-axis preflight guard (added this V&V cycle)**: preflight error `-96200` for a zero-length rotation axis, which previously NaN-corrupted the array silently in both codebases. Behavior change only for invalid input. + +*Material PRs since baseline:* none identified for this filter beyond routine EbsdLib version bumps and the progress-messaging framework migration. + +## Oracle + +*Class:* **1 (Analytical)** primary + **4 (Invariant)** companion. Class 3 (Paper-based) partially inherent — the eu2om/om2eu/ax2om conversions follow Rowenhorst et al 2015 (doi:10.1088/0965-0393/23/8/083501) Eq. A.5/A.9, but those conversions are EbsdLib's own V&V responsibility; this filter's verifiable claim is the composition `g' = g · R_active(n, w)`. + +*Applied:* Expected outputs derived independently of all three codebases (SIMPLNX, EbsdLib, legacy). First-principles derivation: rotating the sample reference frame by +w (right-hand rule) about unit axis n gives new-frame coordinates `u_new = P(w)·u_old` (P = passive matrix), hence `g' = g·P(w)ᵀ = g·R_active(n,w)` with R_active the Rodrigues rotation matrix. Closed forms follow: Z-axis rotation ⇒ `phi1' = phi1 − w (mod 2π)`, Φ/phi2 unchanged; identity orientation ⇒ `g' = R_active(n,w)`, om2eu by hand (fixtures F1–F7 each carry the derivation as a source comment). A pure-numpy script implementing the derivation + Rowenhorst equations (numpy 2.0.2; no random seed — all fixtures deterministic) validates every closed form and generates the one non-closed-form fixture (F8). Script archived in the verification working folder and reproduced in the provenance sidecar. + +*Encoded:* `test/RotateEulerRefFrameTest.cpp::"OrientationAnalysis::RotateEulerRefFrameFilter: Class 1 Analytical Fixtures"` — 8 fixtures (`AnalyticalFixtures::k_Fixtures`), tolerance 1e-5 rad, all pass. Invariants: `...::"OrientationAnalysis::RotateEulerRefFrameFilter: Class 4 Invariants"` — 3 sections (range bounds, (n,w)/(−n,w) round-trip, 45°+45°=90° composability) over a 6-orientation batch, all pass. + +*Second-engineer review:* **pending** — recommended reviewer to walk the F1–F7 hand derivations (≈30 minutes) and confirm the sign convention (`phi1' = phi1 − w`, not `+ w`, for a +w reference-frame rotation about Z). + +## Code path coverage + +*6 of 7 paths exercised.* + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp` (~130 lines). + +The algorithm is flat: (a) entry/setup in `operator()` (cancel check, axis normalization, parallel dispatch), (b) per-tuple kernel in `RotateEulerRefFrameImpl::convert`. + +| # | Stage | Path | Test case | +|---|-------|------|-----------| +| 1 | (a) Entry | Cancel-before-start → early return | *Not directly tested. Requires cancel-signal injection; two-line guard, same accepted gap as prior V&V reports.* | +| 2 | (a) Entry | Axis normalization (`axis.normalize()`) | `Class 1 Analytical Fixtures` — F5 (axis (2,0,0) ≡ (1,0,0)) | +| 3 | (a) Entry | Zero-length axis → preflight error `-96200` (guard added this cycle) | `Zero-Length Axis Fails Preflight` | +| 4 | (b) Kernel | Per-tuple rotate: eu2om → multiply → column-normalize → om2eu → write-back | All Class 1 fixtures + Class 4 sections + legacy-parity ASCIIData test | +| 5 | (b) Kernel | om2eu gimbal-degenerate branch (`|g22| ≈ 1`) | `Class 1 Analytical Fixtures` — F1, F6 (Φ = 0 outputs) | +| 6 | (b) Kernel | Mid-loop cancel check → early return | Exercised structurally with path 4 (flag always false); cancellation state itself not injected — see path 1. | +| 7 | (b) Kernel | Progress messaging (`counter >= counterIncrement`, incl. `counterIncrement == 0` for ranges < 100) | Exercised implicitly by every test (1-tuple fixtures take the `counterIncrement == 0` branch; the 480k-tuple ASCIIData test takes the batched branch). | + +## Test inventory + +| Test case | Status | Notes | +|-----------|--------|-------| +| `OrientationAnalysis::RotateEulerRefFrame` | kept — **reclassified as legacy-parity regression pin** | 480k-tuple ASCIIData run, axis (1,1,1) w=30°, compared to `EulersRotated.csv` (legacy-DREAM3D-generated — *not an oracle*, see provenance). Retained for large-array/parallel-path coverage and historical parity; 1.44M element-wise assertions at 1e-4 tolerance. | +| `OrientationAnalysis::RotateEulerRefFrameFilter: SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; validates UUID + `FloatVec3p1FilterParameterConverter` axis+angle merge + array-path decoding. | +| `OrientationAnalysis::RotateEulerRefFrameFilter: Class 1 Analytical Fixtures` | new-for-V&V | 8 DYNAMIC_SECTIONs (F1–F8), each with derivation comment and 3 component assertions at 1e-5 rad against the independent oracle. | +| `OrientationAnalysis::RotateEulerRefFrameFilter: Zero-Length Axis Fails Preflight` | new-for-V&V | Pins the `-96200` preflight guard added during the algorithm-review pass (previously silent NaN corruption). | +| `OrientationAnalysis::RotateEulerRefFrameFilter: Class 4 Invariants` | new-for-V&V | 3 SECTIONs over a 6-orientation batch: canonical range bounds; (n,w)/(−n,w) round-trip at 1e-4; 45°+45° = 90° composability at 1e-4. | + +All 5 TEST_CASEs (13 ctest sections) pass at the working commit (in-core build). OOC/dual-build run deferred: the algorithm has no OOC-specific variant (direct `Float32Array` access) and no OOC build is configured in this workspace — same disposition as `BadDataNeighborOrientationCheckFilter`. + +## Exemplar archive + +- **Archive:** `ASCIIData.tar.gz` (pre-existing shared archive; declared in `src/Plugins/SimplnxCore/CMakeLists.txt`) +- **SHA512:** *(shared archive; see `src/Plugins/SimplnxCore/CMakeLists.txt` — not duplicated here because this filter neither created nor versioned it)* +- **Provenance:** `src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md` + +No new exemplar archive was created for this V&V cycle: the Class 1 oracle is encoded entirely as inline expected values in the test source, and the Class 4 invariants need no expected output. `EulersRotated.csv` inside `ASCIIData.tar.gz` is legacy-DREAM3D output (circular-oracle provenance) and is retained **only** as a legacy-parity regression pin, explicitly not as a correctness oracle. + +## Deviations from DREAM3D 6.5.171 + +- **No deviations observed.** Comparison run on 6 axis/angle cases × 12 orientations (`Code_Review/RotateEulerRefFrame/euler_input.csv`); max wrap-aware difference 7.2e-7 rad. Two non-deviations documented for future-engineer awareness in `vv/deviations/RotateEulerRefFrameFilter.md`: + - **N1 (precision)** — float vs double degree→radian conversion; ULP-level only. + - **N2 (precision, representation)** — 0 vs 2π canonical representation at the exact wrap boundary (observed for input (π/2, π/4, ¾π) rotated z-90°). Same physical angle. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/RotateEulerRefFrameFilter.md new file mode 100644 index 0000000000..78575c7664 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/RotateEulerRefFrameFilter.md @@ -0,0 +1,68 @@ +# Deviations from DREAM3D 6.5.171: RotateEulerRefFrameFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent. + +Entries are referenced by stable ID (`RotateEulerRefFrameFilter-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. + +| | | +|---|---| +| SIMPLNX UUID | `0458edcd-3655-4465-adc8-b036d76138b5` | +| Legacy (SIMPL) UUID | `{ef9420b2-8c46-55f3-8ae4-f53790639de4}` | +| Comparison fixture | 6 axis/angle cases × 12 orientations, shared CSV input (`Code_Review/RotateEulerRefFrame/`) | +| Comparison date | 2026-07-03 | + +**Headline: no deviations.** DREAM3D 6.5.171 and SIMPLNX agree to within 7.2e-7 rad (float32 ULP level, wrap-aware) on every comparison case, and both independently match the Class 1 analytical oracle. The entries below are **non-deviations** — differences in representation, precision, or invalid-input handling only — recorded so future engineers do not re-discover them. + +--- + +## Non-deviation N1 — degree→radian conversion precision + +| Field | Value | +|---|---| +| **ID** | `RotateEulerRefFrameFilter-N1` (non-deviation) | +| **Filter UUID** | `0458edcd-3655-4465-adc8-b036d76138b5` | +| **Status** | informational | + +**Symptom:** None user-visible. Maximum observed output difference between versions is 7.2e-7 rad across all comparison cases. + +**Root cause:** Precision. Legacy converts the rotation angle degrees→radians in `float` (`float rotAngle = m_RotationAngle * k_Pi / 180.0`, `RotateEulerRefFrame.cpp` line ~215) before handing it to the double-precision orientation transforms; SIMPLNX performs the conversion in `double` (`Algorithms/RotateEulerRefFrame.cpp`, kernel setup). Both then run identical double-precision math. SIMPLNX lands marginally closer to the double-precision oracle (max 2.3e-7 vs legacy 8.1e-7). + +**Affected users:** None at float32 storage precision. + +**Recommendation:** Either acceptable within tolerance 1e-5 rad. + +--- + +## Non-deviation N2 — 0 vs 2π canonical representation at the wrap boundary + +| Field | Value | +|---|---| +| **ID** | `RotateEulerRefFrameFilter-N2` (non-deviation) | +| **Filter UUID** | `0458edcd-3655-4465-adc8-b036d76138b5` | +| **Status** | informational | + +**Symptom:** For an input orientation whose rotated `phi1` lands exactly on the 0/2π boundary — observed for euler (π/2, π/4, ¾π) rotated 90° about Z, where `phi1' = 0` exactly — 6.5.171 outputs `6.2831855` (2π) while SIMPLNX outputs `~6e-17` (0). These represent the same angle. + +**Root cause:** Precision (representation). The N1 float conversion places legacy's intermediate `phi1'` at −ε, which om2eu canonicalizes by adding 2π; SIMPLNX's double conversion places it at +6e-17, which needs no wrap. Element-wise comparison without wrap awareness reports a spurious 2π difference for such boundary values. + +**Affected users:** Anyone diffing euler arrays element-wise between versions (e.g., regression scripts). Orientation-space comparisons are unaffected. + +**Recommendation:** Either acceptable — compare euler angles modulo 2π. + +--- + +## Non-deviation N3 — zero-length rotation axis handling (SIMPLNX guard added 2026-07-03) + +| Field | Value | +|---|---| +| **ID** | `RotateEulerRefFrameFilter-N3` (non-deviation; deliberate input-validation difference) | +| **Filter UUID** | `0458edcd-3655-4465-adc8-b036d76138b5` | +| **Status** | active | + +**Symptom:** Given rotation axis (0,0,0), 6.5.171 silently fills the entire euler array with NaN (unguarded `axis/|axis|` division). SIMPLNX fails preflight with error `-96200` and a descriptive message. + +**Root cause:** Bug (input-validation gap) in 6.5.171, closed on the SIMPLNX side during this V&V cycle's algorithm review. Not an output deviation for any valid input — outputs are identical whenever the axis is non-zero. Pinned by test `RotateEulerRefFrameFilter: Zero-Length Axis Fails Preflight`. + +**Affected users:** Users with malformed pipelines: legacy destroys their data silently; SIMPLNX refuses to run. + +**Recommendation:** Trust SIMPLNX. No legacy patch proposed — the legacy codebase is patched only for demonstrably wrong output on *valid* input. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md new file mode 100644 index 0000000000..6a3046f39c --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md @@ -0,0 +1,66 @@ +# Exemplar Provenance: RotateEulerRefFrameFilter + +This sidecar documents the oracle sources and archive provenance for the `RotateEulerRefFrameFilter` V&V cycle (2026-07-03). **No new exemplar archive was created** — the Class 1 oracle is encoded entirely as inline expected values in the test source, and the Class 4 invariants require no expected output. + +## Archive identity + +| | | +|---|---| +| Archive | `ASCIIData.tar.gz` (pre-existing, shared across plugins) | +| Declared in | `src/Plugins/SimplnxCore/CMakeLists.txt` (`download_test_data` entry ~line 523) | +| Generated by | Legacy DREAM3D era (pre-NX); exact engineer/date unrecorded | +| Consumed by (this filter) | `OrientationAnalysis::RotateEulerRefFrame` (legacy-parity regression pin) | +| Files used | `ASCIIData/EulerAngles.csv` (480k-tuple input), `ASCIIData/EulersRotated.csv` (comparison values) | + +### Circular-oracle note + +`EulersRotated.csv` was generated by running the legacy DREAM3D implementation (axis (1,1,1), 30°) on `EulerAngles.csv`. Under the v2 policy this is **not a valid correctness oracle** ("legacy produced this output"). This V&V cycle did **not** regenerate the archive; instead the test consuming it was reclassified in the V&V report as a *legacy-parity regression pin* (large-array + parallel-path coverage), and correctness was established independently by the Class 1 oracle below. The comparison remains valuable precisely because it pins parity with legacy behavior at 480k-tuple scale. + +## Canonical oracle output (Class 1 — Analytical) + +The oracle DataPaths are the 8 fixture expected values in +`test/RotateEulerRefFrameTest.cpp::AnalyticalFixtures::k_Fixtures` (F1–F8), each carrying its +derivation as a source comment. + +**Derivation (first principles, independent of SIMPLNX / EbsdLib / legacy):** + +1. Bunge passive convention: `v_crystal = g · v_sample`. +2. Rotating the sample reference frame by +w (right-hand rule) about unit axis n gives + new-frame coordinates `u_new = P(w) · u_old`, with `P(w)` the passive rotation matrix. +3. Therefore `g' = g · P(w)ᵀ = g · R_active(n, w)`, with `R_active` the standard Rodrigues + rotation matrix `R = cos(w)·I + sin(w)·[n]ₓ + (1−cos(w))·nnᵀ`. +4. Euler ↔ matrix conversions per Rowenhorst et al 2015, *Modelling Simul. Mater. Sci. Eng.* 23 + 083501 (doi:10.1088/0965-0393/23/8/083501), Eq. A.5 (eu2om) and Eq. A.9 (om2eu), outputs + canonicalized to `phi1, phi2 ∈ [0, 2π)`, `Φ ∈ [0, π]`. + +Closed forms verified by hand: Z-axis rotation ⇒ `phi1' = phi1 − w (mod 2π)` with Φ/phi2 unchanged +(F1–F3, F6); identity orientation + axis-angle ⇒ `g' = R_active(n, w)` with om2eu evaluated by hand +(F4, F5, F7). F8 (general orientation, Y-axis 45°) has no closed form and is script-derived. + +## Oracle provenance block (Class 2-style cross-check script) + +| | | +|---|---| +| Script | `rotate_euler_ref_frame_oracle.py` — archived in the verification working folder (`Code_Review/RotateEulerRefFrame/`) and destined for the OneDrive verification archive | +| Library + version | numpy 2.0.2 (pure-numpy; equations typed from Rowenhorst 2015, not imported from any orientation library) | +| Random seed | none — all fixtures deterministic | +| What it does | Implements the first-principles derivation above; asserts all seven closed-form claims; asserts the Class 4 invariants (round-trip, composability); emits the F1–F8 expected-value table pasted into the C++ test source | + +Drift-risk note: the script is a *cross-check* of hand derivations, not the primary oracle — the +primary oracle is the derivation itself, embedded as comments beside each fixture. numpy version +drift therefore cannot silently change the encoded expected values. + +## Legacy comparison artifacts + +`Code_Review/RotateEulerRefFrame/` additionally holds: `euler_input.csv` (12-orientation shared +input), `generate_pipelines.py`, the generated SIMPL (`legacy_RotateEulerRefFrame_6cases.json`) and +NX (`nx_RotateEulerRefFrame_6cases.d3dpipeline`) pipelines, both output `.dream3d` files, +`compare_outputs.py`, and `legacy_comparison_summary.md` (results: no deviations; max wrap-aware +diff 7.2e-7 rad). + +## Second-engineer oracle review + +**Pending.** Requested focus: (a) walk the F1–F7 hand derivations (~30 minutes); (b) confirm the +sign convention — a +w reference-frame rotation about Z *subtracts* w from phi1 +(`phi1' = phi1 − w`), which the first-principles derivation establishes and both implementations +exhibit; (c) confirm the Class 4 invariant set is complete for this algorithm. From fa94d3d8be6d83fecb549a8392e4df633b53ff62 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 8 Jul 2026 15:27:53 -0400 Subject: [PATCH 2/3] REV: RotateEulerRefFrame thread-safety guard + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from adversarial review of this PR: * Gate the parallel Euler kernel with requireArraysInMemory. The worker writes the in-place Float32Array via operator[] from TBB workers; per the project thread-safety policy concurrent DataStore access is unsafe for out-of-core stores, so parallelization is now enabled only when the array is in-core (the codebase-sanctioned pattern, matching ConvertOrientations/PartitionGeometry). The V&V report's "Bug flags: none" is updated to record this disposition instead of asserting the pattern is safe. * Add a defensive zero-length-axis guard in the Algorithm class (-67050) so direct reuse of the class cannot silently NaN-corrupt data; the filter preflight guard (-96200) remains the user-facing enforcement. * Add the missing / includes to both the filter (uses std::sqrt / numeric_limits) and the algorithm. * Coverage recounted honestly to 5 of 7 (both cancel branches only ever take the false path without cancel-signal injection); the report previously counted the mid-loop cancel as exercised. * Docs: qualify the [0, 2pi) canonicalization claim — the double-precision result is stored as float32, so a value just below 2pi rounds up and the upper bound is inclusive at float32 precision. * Flag the out-of-repo legacy A/B artifacts (Code_Review/RotateEulerRefFrame) as an open archival action in the provenance (independently reproduced via scipy during review). Signed-off-by: Michael Jackson --- .../docs/RotateEulerRefFrameFilter.md | 2 +- .../Filters/Algorithms/RotateEulerRefFrame.cpp | 18 +++++++++++++++++- .../Filters/RotateEulerRefFrameFilter.cpp | 3 +++ .../vv/RotateEulerRefFrameFilter.md | 10 +++++----- .../vv/provenance/RotateEulerRefFrameFilter.md | 9 +++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md index 8eb9026fc6..3eb068a950 100644 --- a/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/RotateEulerRefFrameFilter.md @@ -15,7 +15,7 @@ For each Euler angle triplet the filter computes `g' = g · R(n, ω)`, where `g` - The rotation **angle parameter is in degrees**; the **Euler angle data is in radians** (Bunge ZXZ convention). - The rotation axis does not need to be normalized — the filter normalizes it internally. A zero-length axis (0,0,0) is rejected during preflight. - The selected Euler angles array is modified **in place** (no new array is created). -- Output Euler angles are canonicalized to `φ1, φ2 ∈ [0, 2π)` and `Φ ∈ [0, π]`. +- Output Euler angles are canonicalized to `φ1, φ2 ∈ [0, 2π)` and `Φ ∈ [0, π]` by the double-precision conversion, then stored as `float32`. Because a `double` just below `2π` rounds up to the `float32` value `6.2831855`, a stored `φ1`/`φ2` can be numerically equal to (or a hair above) `2π`; treat the upper bound as inclusive at float32 precision. % Auto generated parameter table will be inserted here diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp index 4856ede0c4..943fb2a2ea 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp @@ -12,6 +12,9 @@ #include #include +#include +#include + using namespace nx::core; namespace @@ -111,6 +114,13 @@ Result<> RotateEulerRefFrame::operator()() size_t totalElements = eulerAngles.getNumberOfTuples(); nx::core::FloatVec3 axis = {m_InputValues->rotationAxis[0], m_InputValues->rotationAxis[1], m_InputValues->rotationAxis[2]}; + // The filter's preflight rejects a zero-length axis, but guard here as well so that any direct reuse of + // this Algorithm class cannot silently NaN-corrupt the data (normalize() of a zero vector is NaN). + const float32 axisMagnitude = std::sqrt(axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]); + if(axisMagnitude < std::numeric_limits::epsilon()) + { + return MakeErrorResult(-67050, "The rotation axis has zero length; a rotation axis must be a non-zero vector."); + } axis = axis.normalize(); MessageHelper messageHelper(m_MessageHandler); @@ -118,9 +128,15 @@ Result<> RotateEulerRefFrame::operator()() progressMessageHelper.setMaxProgresss(totalElements); progressMessageHelper.setProgressMessageTemplate("RotateEulerRefFrame: {:.2f}% complete"); - // Allow data-based parallelization + // Data-based parallelization: each worker reads and writes only its own disjoint tuple range of the + // in-place Euler array. Per the project thread-safety policy, concurrent DataStore access is unsafe for + // out-of-core stores, so requireArraysInMemory disables parallelization unless the array is resident in + // memory (the codebase-sanctioned pattern; see ConvertOrientations / PartitionGeometry). ParallelDataAlgorithm dataAlg; dataAlg.setRange(0, totalElements); + IParallelAlgorithm::AlgorithmArrays algArrays; + algArrays.push_back(&eulerAngles); + dataAlg.requireArraysInMemory(algArrays); dataAlg.execute(RotateEulerRefFrameImpl(eulerAngles, axis, m_InputValues->rotationAxis[3], m_ShouldCancel, progressMessageHelper)); return {}; } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp index 629d86335b..6488cfd877 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/RotateEulerRefFrameFilter.cpp @@ -9,6 +9,9 @@ #include "simplnx/Parameters/VectorParameter.hpp" +#include +#include + using namespace nx::core; namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md index 8b3db14544..6206b192bf 100644 --- a/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/RotateEulerRefFrameFilter.md @@ -16,11 +16,11 @@ |------------------------|--| | Algorithm Relationship | **Port** of legacy `RotateEulerRefFrame::execute()`. Same per-tuple kernel `gNew = normalize_cols(eu2om(euler) * ax2om(axis, angle))` → `om2eu`; library swaps only (OrientationLib → EbsdLib, hand-rolled `MatrixMath` → Eigen) plus a double-precision degree→radian conversion and progress/cancel plumbing. | | Oracle (confirmed) | **Class 1 (Analytical) primary** — 8 hand/script-derived fixtures (`AnalyticalFixtures::k_Fixtures`) with closed-form derivations for Z-axis (`phi1' = phi1 - w mod 2pi`), identity + X/111 axes, normalization, and zero-angle cases. **Class 4 (Invariant) companion** — output-range bounds, (n,w)/(-n,w) round-trip, 45°+45° = 90° composability. Cross-checked by an independent numpy script (Rowenhorst 2015 Eq. A.5/A.9 + first-principles frame-rotation derivation). All pass. | -| Code paths enumerated | 6 of 7 exercised; the mid-loop cancel path is not directly tested (requires cancel-signal injection — same accepted gap as prior V&V reports). | +| Code paths enumerated | 7 enumerated; **5 exercised**. The 2 gaps are both cancel branches (cancel-before-start and mid-loop cancel) — only their false path ever runs; taking the true path requires cancel-signal injection (same accepted gap as prior V&V reports). | | Tests today | **5 TEST_CASEs / 13 ctest sections** — 8 Class 1 fixtures (DYNAMIC_SECTION), 3 Class 4 invariant sections, 1 zero-axis preflight-error test (new guard added this cycle), 1 legacy-parity 480k-tuple regression pin (ASCIIData), 1 SIMPL 6.4/6.5 backwards-compat. | | Exemplar archive | `ASCIIData.tar.gz` (pre-existing, shared archive) — provides the 480k-tuple legacy-parity input/comparison CSVs only. **Not an oracle** (legacy-DREAM3D provenance); the Class 1 oracle is inline in the test source. No new archive needed. | | Legacy comparison | **Run** against DREAM3D 6.5.171 on 6 axis/angle cases × 12 orientations (shared CSV input). Max wrap-aware diff 7.2e-7 rad (float32 ULP level). Both implementations independently match the numpy oracle (NX 2.3e-7, legacy 8.1e-7). No deviations; two non-deviations documented. | -| Bug flags | None. One SIMPLNX robustness gap fixed this cycle: zero-length rotation axis previously produced silent NaN corruption; preflight now rejects it (error `-96200`). Legacy 6.5.171 retains the NaN behavior (documented as a non-deviation — not output-correctness). | +| Bug flags | None affecting output. Two robustness/policy items addressed this cycle: (1) zero-length rotation axis previously produced silent NaN corruption — preflight now rejects it (`-96200`) and the Algorithm class guards it as well (`-67050`); (2) the parallel kernel writes the in-place Euler array via `operator[]` from TBB workers — per the project thread-safety policy this is now gated with `requireArraysInMemory` so parallelization is only enabled for in-core stores (the codebase-sanctioned pattern). Legacy 6.5.171 retains the zero-axis NaN behavior (documented as a non-deviation — not output-correctness). | | V&V phase | Oracle design + reconciliation, algorithm review (fixes applied), code-path coverage, test inventory, legacy comparison, deviations, and provenance complete. **Outstanding:** second-engineer oracle review; engineer sign-off; dual-build (OOC) run deferred — no OOC-specific variant of this algorithm and no OOC build configured in Workspace4. | ## Summary @@ -31,7 +31,7 @@ *Classification:* **Port** -*Evidence:* Near line-by-line translation of legacy `RotateEulerRefFrame::execute()` + `RotateEulerRefFrameImpl::convert()` (SIMPL UUID `{ef9420b2-8c46-55f3-8ae4-f53790639de4}` retained in the legacy-UUID map; SIMPL 6.4/6.5 conversion fixtures at `test/simpl_conversion/6_*/RotateEulerRefFrameFilter.json`). Identical control flow: normalize axis → per-tuple eu2om, multiply by ax2om rotation matrix, column-normalize, om2eu, write back in place, TBB-parallel over tuples. +*Evidence:* Near line-by-line translation of legacy `RotateEulerRefFrame::execute()` + `RotateEulerRefFrameImpl::convert()` (SIMPL UUID `{ef9420b2-8c46-55f3-8ae4-f53790639de4}` retained in the legacy-UUID map; SIMPL 6.4/6.5 conversion fixtures at `test/simpl_conversion/6_*/RotateEulerRefFrameFilter.json`). Identical control flow: normalize axis → per-tuple eu2om, multiply by ax2om rotation matrix, column-normalize, om2eu, write back in place, parallel over tuples (now gated with `requireArraysInMemory` per the project thread-safety policy — a non-functional change from legacy). *Port-time deltas:* @@ -55,7 +55,7 @@ ## Code path coverage -*6 of 7 paths exercised.* +*5 of 7 paths exercised. The 2 gaps (paths 1 and 6) are both cancel branches whose true path is never taken without cancel-signal injection.* Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/RotateEulerRefFrame.cpp` (~130 lines). @@ -68,7 +68,7 @@ The algorithm is flat: (a) entry/setup in `operator()` (cancel check, axis norma | 3 | (a) Entry | Zero-length axis → preflight error `-96200` (guard added this cycle) | `Zero-Length Axis Fails Preflight` | | 4 | (b) Kernel | Per-tuple rotate: eu2om → multiply → column-normalize → om2eu → write-back | All Class 1 fixtures + Class 4 sections + legacy-parity ASCIIData test | | 5 | (b) Kernel | om2eu gimbal-degenerate branch (`|g22| ≈ 1`) | `Class 1 Analytical Fixtures` — F1, F6 (Φ = 0 outputs) | -| 6 | (b) Kernel | Mid-loop cancel check → early return | Exercised structurally with path 4 (flag always false); cancellation state itself not injected — see path 1. | +| 6 | (b) Kernel | Mid-loop cancel check → early return | *Not directly tested — only the false branch runs; the true (cancel) path needs cancel-signal injection (see path 1).* | | 7 | (b) Kernel | Progress messaging (`counter >= counterIncrement`, incl. `counterIncrement == 0` for ranges < 100) | Exercised implicitly by every test (1-tuple fixtures take the `counterIncrement == 0` branch; the 480k-tuple ASCIIData test takes the batched branch). | ## Test inventory diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md index 6a3046f39c..44fbbd7d64 100644 --- a/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md @@ -58,6 +58,15 @@ NX (`nx_RotateEulerRefFrame_6cases.d3dpipeline`) pipelines, both output `.dream3 `compare_outputs.py`, and `legacy_comparison_summary.md` (results: no deviations; max wrap-aware diff 7.2e-7 rad). +> **⚠ Evidence archival (open action):** `Code_Review/RotateEulerRefFrame/` is the engineer's local +> verification working folder and is **not** committed to the repository, so the legacy A/B is not +> independently reproducible from this repo. These artifacts (input CSV, generator, both pipelines, +> comparison script, and summary) must be uploaded to the OneDrive verification archive (per the +> archive-filter-verification workflow) and this note replaced with the archive link before final +> sign-off. Mitigation: the F8 oracle value and the 6-case comparison were independently reproduced +> via scipy during review, so the numeric result is corroborated even though the working folder is +> not yet archived. + ## Second-engineer oracle review **Pending.** Requested focus: (a) walk the F1–F7 hand derivations (~30 minutes); (b) confirm the From 4dea71870c9c64d5ca66b8343620a59b8ea0c91b Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 8 Jul 2026 22:00:53 -0400 Subject: [PATCH 3/3] =?UTF-8?q?DOC:=20RotateEulerRefFrame=20=E2=80=94=20ma?= =?UTF-8?q?rk=20Code=5FReview=20A/B=20artifacts=20as=20archived=20to=20One?= =?UTF-8?q?Drive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Code_Review/RotateEulerRefFrame/ working folder (input CSV, oracle generator, both pipelines, both output .dream3d files, comparison script, and legacy_comparison_summary.md) was uploaded to the OneDrive verification archive on 2026-07-08. Replace the open-action archival note with the completed status; the legacy A/B is now reproducible from the archive. Signed-off-by: Michael Jackson --- .../vv/provenance/RotateEulerRefFrameFilter.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md index 44fbbd7d64..5f5aa256c1 100644 --- a/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/provenance/RotateEulerRefFrameFilter.md @@ -41,7 +41,7 @@ Closed forms verified by hand: Z-axis rotation ⇒ `phi1' = phi1 − w (mod 2π) | | | |---|---| -| Script | `rotate_euler_ref_frame_oracle.py` — archived in the verification working folder (`Code_Review/RotateEulerRefFrame/`) and destined for the OneDrive verification archive | +| Script | `rotate_euler_ref_frame_oracle.py` — in `Code_Review/RotateEulerRefFrame/`, uploaded to the OneDrive verification archive (2026-07-08) | | Library + version | numpy 2.0.2 (pure-numpy; equations typed from Rowenhorst 2015, not imported from any orientation library) | | Random seed | none — all fixtures deterministic | | What it does | Implements the first-principles derivation above; asserts all seven closed-form claims; asserts the Class 4 invariants (round-trip, composability); emits the F1–F8 expected-value table pasted into the C++ test source | @@ -58,14 +58,11 @@ NX (`nx_RotateEulerRefFrame_6cases.d3dpipeline`) pipelines, both output `.dream3 `compare_outputs.py`, and `legacy_comparison_summary.md` (results: no deviations; max wrap-aware diff 7.2e-7 rad). -> **⚠ Evidence archival (open action):** `Code_Review/RotateEulerRefFrame/` is the engineer's local -> verification working folder and is **not** committed to the repository, so the legacy A/B is not -> independently reproducible from this repo. These artifacts (input CSV, generator, both pipelines, -> comparison script, and summary) must be uploaded to the OneDrive verification archive (per the -> archive-filter-verification workflow) and this note replaced with the archive link before final -> sign-off. Mitigation: the F8 oracle value and the 6-case comparison were independently reproduced -> via scipy during review, so the numeric result is corroborated even though the working folder is -> not yet archived. +> **Evidence archival (done):** `Code_Review/RotateEulerRefFrame/` (input CSV, generator, both +> pipelines, both output `.dream3d` files, comparison script, and `legacy_comparison_summary.md`) was +> **uploaded to the OneDrive verification archive** on 2026-07-08, so the legacy A/B is reproducible +> and reviewable from the archive. The F8 oracle value and the 6-case comparison were additionally +> reproduced via scipy during review. ## Second-engineer oracle review