diff --git a/docs/vv_templates/report_template.md b/docs/vv_templates/report_template.md index b6bcc360e0..3b3123334a 100644 --- a/docs/vv_templates/report_template.md +++ b/docs/vv_templates/report_template.md @@ -1,5 +1,7 @@ # V&V Report: + + | | | |--------|--------------| | Plugin | | @@ -46,6 +48,17 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie *Second-engineer review:* ** OR *skipped — reason.* +## Bugs found and fixed + + + +*This branch fixes all defects in this table. The fixes will be in the DREAM3D-NX release after v.* + +| Deviation | Defect | Affected released versions | Resolution in this branch | +|---|---|---|---| +| `-D1` | *State the defect and its effect.* | *DREAM.3D 6.5.171; DREAM3D-NX v through v.* | *State how the verified branch fixes the defect.* | +| `-D2` | *State the defect and its effect.* | *DREAM.3D 6.5.171 only. DREAM3D-NX was not affected.* | *State how the verified branch fixes or prevents the defect.* | + ## Code path coverage *N of M paths exercised. If N < M: which paths are NOT covered, and why each gap is acceptable (or what would close it).* @@ -78,7 +91,7 @@ When a path is intentionally not covered, write `*Not directly tested. `* - **Provenance:** *`src/Plugins/

/vv/provenance/.md`* -## Deviations from DREAM3D 6.5.171 +## Deviations from DREAM.3D 6.5.171 *Either:* @@ -86,5 +99,9 @@ When a path is intentionally not covered, write `*Not directly tested. -D1` — *one-line symptom* — see `vv/deviations/.md` -- `-D2` — *one-line symptom* — see `vv/deviations/.md` +See `vv/deviations/.md` for the root cause, affected users, and recommendation for each deviation. + +| Deviation | Observed difference | +|---|---| +| `-D1` | *State the user-visible difference.* | +| `-D2` | *State the user-visible difference.* | diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeSchmidsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeSchmidsFilter.md index dee10769d3..068883fe01 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeSchmidsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeSchmidsFilter.md @@ -6,47 +6,68 @@ Statistics (Crystallography) ## Description -This **Filter** calculates the Schmid factor for each **Feature** (grain), which measures how favorably oriented that grain is for plastic deformation under a given loading direction. A higher Schmid factor means the grain is more likely to deform (slip) under the applied load. +This **Filter** calculates the Schmid factor of each **Feature** given its average orientation and a user-defined loading axis. The Schmid factor combines the component of the axial force *F* that lies parallel to the slip direction with the component perpendicular to the slip plane. The equation is: -### What is the Schmid Factor? +Schmid Factor = (cos φ cos λ) -When a force is applied to a polycrystalline material, each grain responds differently depending on how its internal crystal planes are oriented relative to the loading direction. Deformation occurs by *slip* -- atoms sliding along specific crystal planes in specific directions. Each combination of a slip plane and a slip direction is called a *slip system*. +*The angle φ is the angle between the tensile axis and the slip plane normal, and λ is the angle between the tensile axis and the slip direction in the slip plane.* -The Schmid factor quantifies how much of the applied force is resolved onto a given slip system: +The **Filter** evaluates this equation for every enumerated slip system in each **Feature's** crystal structure. It stores the largest Schmid factor and the corresponding slip-system index. Critical resolved shear stress is not considered. -**Schmid Factor = cos(φ) × cos(λ)** +The user-supplied *Loading Direction* is normalized before use, so only its direction matters — `[1, 2, 3]` and `[3, 6, 9]` give identical results. It is a **sample-frame** direction; the **Filter** rotates it into each **Feature's** crystal frame using that **Feature's** average orientation before evaluating the slip systems. The loading direction must be non-zero. When *Override Default Slip System* is enabled, the supplied slip-plane normal and slip direction must also be non-zero and perpendicular. -where: +**Feature 0** is the conventional "unassigned" **Feature** and is never computed; all of its output values are zero. A **Feature** whose crystal-structure value is outside EbsdLib's supported Laue-group range is skipped, and all of its output values are also zero. For a valid Laue class that has no enumerated slip systems, *Schmids*, *Slip Systems*, *Phis*, and *Lambdas* are zero, while *Poles* still records the transformed loading direction. -- **φ** is the angle between the loading axis and the *slip plane normal* (the direction perpendicular to the slip plane) -- **λ** is the angle between the loading axis and the *slip direction* (the direction atoms slide along within the slip plane) +### Ties Between Slip Systems -The Schmid factor ranges from 0 to 0.5. A value of 0.5 means the slip system is optimally aligned with the applied load. A value near 0 means the slip system is poorly oriented for deformation. +Candidate slip systems are compared with a strict greater-than, in the fixed order in which they are enumerated for the crystal structure. When two or more slip systems share the maximum Schmid factor — which happens for high-symmetry loading directions such as `[0, 0, 1]` or `[1, 1, 1]` in a cubic crystal — the **lowest-numbered** of the tied systems is reported. This is a property of the enumeration order, not a physical preference: the tied systems are equally favoured. -![Fig. 1: The geometric relationship between the tensile axis, slip plane, and slip direction that defines the Schmid factor.](Images/ComputeSchmids_SchmidFactor.png) +### Slip Systems Numbering Depends on Override Default Slip System -### How This Filter Works +The meaning of the *Slip Systems* output changes with the *Override Default Slip System* toggle: -1. The user specifies a **Loading Direction** as a unit vector in the sample reference frame (e.g., [0, 0, 1] for loading along the Z-axis). This is a dimensionless direction, not a magnitude. -2. For each **Feature**, the filter rotates the loading direction from the sample frame into the grain's crystal frame using the grain's average orientation. -3. The filter evaluates the Schmid factor for every slip system available for that grain's crystal structure. -4. By default, the slip system with the **largest** Schmid factor is reported. Alternatively, the user can specify a particular slip plane and slip direction to evaluate. +| Override Default Slip System | *Slip Systems* value | +|---|---| +| Off (default) | Index into the crystal structure's built-in slip system list — numbered from **0** (0–11) for Cubic-High and Cubic-Low, and from **1** (1–6) for Hexagonal-High and Hexagonal-Low | +| On | Index of the **symmetry operator** (0–23 for Cubic-High) that maps the user-supplied slip plane and slip direction onto the winning variant | -### Note +The two numbering schemes are not comparable, and neither is the base of the *off* numbering comparable between the cubic and hexagonal classes. -Only the geometric Schmid factor is considered. The critical resolved shear stress (CRSS), which varies between slip systems in real materials, is not taken into account. This means the reported "most favorable" slip system is based purely on geometric alignment, not on the actual stress required to activate slip. +A *Slip Systems* value of `0` can mean **"no slip system found"** in two situations: + +- With *Override Default Slip System* **on** and no symmetry-operator variant producing a non-zero Schmid factor — for example a loading direction parallel to the slip plane normal, which puts the loading axis perpendicular to every slip direction. +- With the toggle **off** on a **Hexagonal-High or Hexagonal-Low** phase, where the built-in systems are numbered 1–6 and no candidate exceeds the initial Schmid factor of `0`. Because that numbering starts at 1, `0` falls outside the valid range and is unambiguous. (For the cubic classes, whose numbering starts at 0, the reported `0` in this situation is indistinguishable from a genuine win on system 0; check the reported Schmid factor, which is `0` in the degenerate case.) + +In every one of these cases the reported Schmid factor is `0`. + +With *Override Default Slip System* on, the reported index is **relative to the symmetry-operator table** of the orientation library, and several operators typically tie at the maximum Schmid factor — for a `(001)[100]` system under `[1,2,3]` loading, six of the twenty-four do. The reported index is simply the first of those in table order. It identifies *a* maximizing variant, not a uniquely determined one, and the same input run through DREAM3D 6.5.x reports a different index for the same physical answer because that version's table is ordered differently. Treat this value as a handle into the current library's table, not as a portable label. + +### Phis and Lambdas Units Depend on Override Default Slip System + +The *Phis* and *Lambdas* arrays change **units** with the same toggle, which is easy to miss because the array names do not change: + +| Override Default Slip System | *Phis* / *Lambdas* contents | +|---|---| +| Off (default) | The direction **cosines** cos φ and cos λ (dimensionless, in [0, 1]) | +| On | The **angles** φ and λ themselves, in **radians** | + +With the toggle off, the reported Schmid factor is the product of the two stored values. With the toggle on, it is the product of their cosines. If you need consistent units across both modes, convert explicitly rather than assuming. + +### Poles Is Not a Miller Index + +Despite its name, the *Poles* array does **not** contain a crystallographic index. It is the unit loading direction expressed in the **Feature's** crystal frame, multiplied by 100 and **truncated toward zero** to an integer — a compact fixed-point encoding of that unit vector, retaining two decimal places. Components can be negative, and the sum of squares is approximately 10 000 rather than 1. Because the conversion truncates rather than rounds, a component whose scaled value falls near an integer can differ by one from the value you would get by rounding. Reducing the triplet to a Miller index requires dividing by the greatest common divisor yourself, and the truncation means the result is only approximate. ### Required Input Sources -- **Average Quaternions** -- the per-feature average orientation produced by [Compute Average Orientations](ComputeAvgOrientationsFilter.md). +- **Average Quaternions** -- produced by [Compute Average Orientations](ComputeAvgOrientationsFilter.md). - **Feature Phases** -- produced by [Compute Feature Phases](../SimplnxCore/ComputeFeaturePhasesFilter.md). -- **Crystal Structures** -- ensemble-level array read from EBSD data or created by [Create Ensemble Info](CreateEnsembleInfoFilter.md). +- **Crystal Structures** -- read from EBSD data or produced by [Create Ensemble Info](CreateEnsembleInfoFilter.md). % Auto generated parameter table will be inserted here ## Example Pipelines -+ `(05) SmallIN100 Crystallographic Statistics` ++ (04) Small IN100 Crystallographic Statistics ## License & Copyright diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.cpp index 72a0040533..111e30120e 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.cpp @@ -9,11 +9,11 @@ using namespace nx::core; // ----------------------------------------------------------------------------- -ComputeSchmids::ComputeSchmids(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ComputeSchmidsInputValues* inputValues) +ComputeSchmids::ComputeSchmids(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ComputeSchmidsInputValues* inputValues) : m_DataStructure(dataStructure) , m_InputValues(inputValues) , m_ShouldCancel(shouldCancel) -, m_MessageHandler(mesgHandler) +, m_MessageHandler(messageHandler) { } @@ -21,7 +21,7 @@ ComputeSchmids::ComputeSchmids(DataStructure& dataStructure, const IFilter::Mess ComputeSchmids::~ComputeSchmids() noexcept = default; // ----------------------------------------------------------------------------- -const std::atomic_bool& ComputeSchmids::getCancel() +const std::atomic_bool& ComputeSchmids::getCancel() const { return m_ShouldCancel; } @@ -31,36 +31,36 @@ Result<> ComputeSchmids::operator()() { std::vector orientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); - const auto& avgQuatPtr = m_DataStructure.getDataRefAs(m_InputValues->AvgQuatsArrayPath); - const auto& featurePhases = m_DataStructure.getDataRefAs(m_InputValues->FeaturePhasesArrayPath); - const auto& crystalStructures = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); - auto& schmidArray = m_DataStructure.getDataRefAs(m_InputValues->SchmidsArrayName); - auto& slipSystems = m_DataStructure.getDataRefAs(m_InputValues->SlipSystemsArrayName); - auto& poleArrays = m_DataStructure.getDataRefAs(m_InputValues->PolesArrayName); - - auto* phiArray = m_DataStructure.getDataAs(m_InputValues->PhisArrayName); - auto* lambdaArray = m_DataStructure.getDataAs(m_InputValues->LambdasArrayName); + const auto& avgQuatsRef = m_DataStructure.getDataRefAs(m_InputValues->AvgQuatsArrayPath); + const auto& featurePhasesRef = m_DataStructure.getDataRefAs(m_InputValues->FeaturePhasesArrayPath); + const auto& crystalStructuresRef = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); + auto& schmidsRef = m_DataStructure.getDataRefAs(m_InputValues->SchmidsArrayName); + auto& slipSystemsRef = m_DataStructure.getDataRefAs(m_InputValues->SlipSystemsArrayName); + auto& polesRef = m_DataStructure.getDataRefAs(m_InputValues->PolesArrayName); + + auto* phisPtr = m_DataStructure.getDataAs(m_InputValues->PhisArrayName); + auto* lambdasPtr = m_DataStructure.getDataAs(m_InputValues->LambdasArrayName); + // Feature 0 is the conventional unassigned-Feature sentinel and is never computed; the loop below + // starts at 1. Preflight now creates all five arrays with a "0" fill value so every tuple is + // defined regardless, but these explicit writes are kept because they are what documents the + // sentinel row's meaning at the point of use. if(m_InputValues->StoreAngleComponents) { - (*phiArray)[0] = 0.0F; - (*lambdaArray)[0] = 0.0F; + (*phisPtr)[0] = 0.0F; + (*lambdasPtr)[0] = 0.0F; } - schmidArray[0] = 0.0F; - poleArrays[0] = 0; - poleArrays[1] = 0; - poleArrays[2] = 0; - slipSystems[0] = 0; - - size_t totalFeatures = avgQuatPtr.getNumberOfTuples(); + schmidsRef[0] = 0.0F; + polesRef[0] = 0; + polesRef[1] = 0; + polesRef[2] = 0; + slipSystemsRef[0] = 0; - int32_t slipSystem = 0; + const usize featureCount = avgQuatsRef.getNumberOfTuples(); + const usize ensembleCount = crystalStructuresRef.getNumberOfTuples(); Eigen::Vector3d sampleLoading = {m_InputValues->LoadingDirection[0], m_InputValues->LoadingDirection[1], m_InputValues->LoadingDirection[2]}; sampleLoading.normalize(); - double angleComps[2] = {0.0f, 0.0f}; - double schmid = 0.0f; - Eigen::Vector3d plane; Eigen::Vector3d direction; if(m_InputValues->OverrideSystem) @@ -72,41 +72,67 @@ Result<> ComputeSchmids::operator()() direction.normalize(); } - for(size_t i = 1; i < totalFeatures; i++) + for(usize featureIdx = 1; featureIdx < featureCount; featureIdx++) { if(m_ShouldCancel) { return {}; } - uint32_t laueClass = crystalStructures[featurePhases[i]]; + // The phase id indexes the Crystal Structures array directly, so it has to be validated before + // it is used. An id at or beyond the ensemble count, or a negative one, would read outside the + // array and dispatch on whatever Laue value that read produced. + const int32 phaseId = featurePhasesRef[featureIdx]; + if(phaseId < 0) + { + return MakeErrorResult(-13502, fmt::format("Feature {} has a negative phase value of {}. Phase values index the Crystal Structures array '{}' and must be zero or greater.", featureIdx, phaseId, + m_InputValues->CrystalStructuresArrayPath.toString())); + } + if(static_cast(phaseId) >= ensembleCount) + { + return MakeErrorResult(-13501, fmt::format("Feature {} has a phase value of {} but the Crystal Structures array '{}' only has {} entries (valid phase values are 0 through {}).", featureIdx, + phaseId, m_InputValues->CrystalStructuresArrayPath.toString(), ensembleCount, ensembleCount - 1)); + } + + const uint32_t laueClass = crystalStructuresRef[phaseId]; if(laueClass >= ebsdlib::CrystalStructure::LaueGroupEnd) { continue; } - auto om = ebsdlib::QuaternionDType(avgQuatPtr[i * 4 + 0], avgQuatPtr[i * 4 + 1], avgQuatPtr[i * 4 + 2], avgQuatPtr[i * 4 + 3]).toOrientationMatrix(); - Eigen::Vector3d crystalLoading = om * sampleLoading; + + // Re-initialized every iteration on purpose. EbsdLib Laue ops that enumerate no slip systems + // for their class return without writing every output, so a buffer hoisted out of the loop + // would carry the previous Feature's values into the current Feature's row. Fixed in EbsdLib + // 3.1.1 as well; keeping the locals loop-scoped means this filter is correct against + // any EbsdLib. + double schmid = 0.0; + double angleComponents[2] = {0.0, 0.0}; + int32_t slipSystem = 0; + + const auto orientationMatrix = + ebsdlib::QuaternionDType(avgQuatsRef[featureIdx * 4 + 0], avgQuatsRef[featureIdx * 4 + 1], avgQuatsRef[featureIdx * 4 + 2], avgQuatsRef[featureIdx * 4 + 3]).toOrientationMatrix(); + Eigen::Vector3d crystalLoading = orientationMatrix * sampleLoading; if(!m_InputValues->OverrideSystem) { - orientationOps[laueClass]->getSchmidFactorAndSS(crystalLoading.data(), schmid, angleComps, slipSystem); + orientationOps[laueClass]->getSchmidFactorAndSS(crystalLoading.data(), schmid, angleComponents, slipSystem); } else { - orientationOps[laueClass]->getSchmidFactorAndSS(crystalLoading.data(), plane.data(), direction.data(), schmid, angleComps, slipSystem); + orientationOps[laueClass]->getSchmidFactorAndSS(crystalLoading.data(), plane.data(), direction.data(), schmid, angleComponents, slipSystem); } - schmidArray[i] = static_cast(schmid); + schmidsRef[featureIdx] = static_cast(schmid); if(m_InputValues->StoreAngleComponents) { - (*phiArray)[i] = angleComps[0]; - (*lambdaArray)[i] = angleComps[1]; + (*phisPtr)[featureIdx] = angleComponents[0]; + (*lambdasPtr)[featureIdx] = angleComponents[1]; } - poleArrays[3 * i] = static_cast(crystalLoading[0] * 100.0); - poleArrays[3 * i + 1] = static_cast(crystalLoading[1] * 100.0); - poleArrays[3 * i + 2] = static_cast(crystalLoading[2] * 100.0); - slipSystems[i] = slipSystem; + polesRef[3 * featureIdx] = static_cast(crystalLoading[0] * 100.0); + polesRef[3 * featureIdx + 1] = static_cast(crystalLoading[1] * 100.0); + polesRef[3 * featureIdx + 2] = static_cast(crystalLoading[2] * 100.0); + slipSystemsRef[featureIdx] = slipSystem; } return {}; diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.hpp index 5974e034ec..612e5a317b 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.hpp @@ -54,7 +54,7 @@ struct ORIENTATIONANALYSIS_EXPORT ComputeSchmidsInputValues class ORIENTATIONANALYSIS_EXPORT ComputeSchmids { public: - ComputeSchmids(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ComputeSchmidsInputValues* inputValues); + ComputeSchmids(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ComputeSchmidsInputValues* inputValues); ~ComputeSchmids() noexcept; ComputeSchmids(const ComputeSchmids&) = delete; @@ -64,7 +64,7 @@ class ORIENTATIONANALYSIS_EXPORT ComputeSchmids Result<> operator()(); - const std::atomic_bool& getCancel(); + const std::atomic_bool& getCancel() const; private: DataStructure& m_DataStructure; diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeSchmidsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeSchmidsFilter.cpp index d3f1be9c07..a4808311b3 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeSchmidsFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeSchmidsFilter.cpp @@ -12,6 +12,9 @@ #include "simplnx/Parameters/VectorParameter.hpp" +#include +#include + using namespace nx::core; namespace nx::core @@ -54,21 +57,21 @@ Parameters ComputeSchmidsFilter::parameters() const // Create the parameter descriptors that are needed for this filter params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); - params.insert(std::make_unique(k_LoadingDirection_Key, "Loading Direction", "The loading axis for the sample", std::vector({1.0F, 1.0F, 1.0F}), - std::vector({"X", "Y", "Z"}))); + params.insert(std::make_unique(k_LoadingDirection_Key, "Loading Direction", "A non-zero loading direction in the sample reference frame", + std::vector({1.0F, 1.0F, 1.0F}), std::vector({"X", "Y", "Z"}))); params.insertLinkableParameter( std::make_unique(k_StoreAngleComponents_Key, "Store Angle Components of Schmid Factor", "Whether to store the angle components for each Feature", false)); params.insertLinkableParameter(std::make_unique(k_OverrideSystem_Key, "Override Default Slip System", "Allows the user to manually input the slip plane and slip direction", false)); - params.insert(std::make_unique(k_SlipPlane_Key, "Slip Plane", "Vector defining the slip plane normal.", std::vector({0.0F, 0.0F, 1.0F}), + params.insert(std::make_unique(k_SlipPlane_Key, "Slip Plane", "A non-zero vector defining the slip plane normal", std::vector({0.0F, 0.0F, 1.0F}), std::vector({"X", "Y", "Z"}))); - params.insert(std::make_unique(k_SlipDirection_Key, "Slip Direction", "Vector defining the slip direction.", std::vector({1.0F, 0.0F, 0.0F}), + params.insert(std::make_unique(k_SlipDirection_Key, "Slip Direction", "A non-zero vector defining the slip direction", std::vector({1.0F, 0.0F, 0.0F}), std::vector({"X", "Y", "Z"}))); params.insertSeparator(Parameters::Separator{"Input Feature Data"}); - params.insert(std::make_unique(k_FeaturePhasesArrayPath_Key, "Phases", "Specifies to which Ensemble each cell belongs", DataPath({"Cell Feature Data", "Phases"}), + params.insert(std::make_unique(k_FeaturePhasesArrayPath_Key, "Phases", "Specifies the Ensemble to which each Feature belongs", DataPath({"Cell Feature Data", "Phases"}), ArraySelectionParameter::AllowedTypes{nx::core::DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{1}})); - params.insert(std::make_unique(k_AvgQuatsArrayPath_Key, "Average Quaternions", "Specifies the average orienation of each Feature in quaternion representation", + params.insert(std::make_unique(k_AvgQuatsArrayPath_Key, "Average Quaternions", "Specifies the average orientation of each Feature in quaternion representation", DataPath({"Cell Feature Data", "AvgQuats"}), ArraySelectionParameter::AllowedTypes{nx::core::DataType::float32}, ArraySelectionParameter::AllowedComponentShapes{{4}})); params.insertSeparator(Parameters::Separator{"Input Ensemble Data"}); @@ -82,10 +85,12 @@ Parameters ComputeSchmidsFilter::parameters() const "Schmids")); params.insert(std::make_unique(k_SlipSystemsArrayName_Key, "Slip Systems", "The name of the array containing the enumeration of the slip system that has the highest Schmid factor", "SlipSystems")); - params.insert(std::make_unique(k_PolesArrayName_Key, "Poles", - "The name of the array specifying the crystallographic pole that points along the user defined loading direction", "Poles")); - params.insert(std::make_unique(k_PhisArrayName_Key, "Phis", "The name of the array containing the angle between tensile axis and slip plane normal. ", "Schmid_Phis")); - params.insert(std::make_unique(k_LambdasArrayName_Key, "Lambdas", "The name of the array containing the angle between tensile axis and slip direction.", "Schmid_Lambdas")); + params.insert(std::make_unique( + k_PolesArrayName_Key, "Poles", "The name of the array containing the normalized loading direction in the crystal frame, multiplied by 100 and truncated toward zero", "Poles")); + params.insert(std::make_unique( + k_PhisArrayName_Key, "Phis", "The name of the array containing cos(phi) when the default slip systems are used or phi in radians when they are overridden", "Schmid_Phis")); + params.insert(std::make_unique( + k_LambdasArrayName_Key, "Lambdas", "The name of the array containing cos(lambda) when the default slip systems are used or lambda in radians when they are overridden", "Schmid_Lambdas")); // Associate the Linkable Parameter(s) to the children parameters that they control params.linkParameters(k_StoreAngleComponents_Key, k_PhisArrayName_Key, true); params.linkParameters(k_StoreAngleComponents_Key, k_LambdasArrayName_Key, true); @@ -98,7 +103,8 @@ Parameters ComputeSchmidsFilter::parameters() const //------------------------------------------------------------------------------ IFilter::VersionType ComputeSchmidsFilter::parametersVersion() const { - return 1; + // Version 2 clarifies the vector constraints and the mode-dependent output-array semantics. + return 2; } //------------------------------------------------------------------------------ @@ -119,57 +125,103 @@ IFilter::PreflightResult ComputeSchmidsFilter::preflightImpl(const DataStructure auto pFeaturePhasesArrayPathValue = filterArgs.value(k_FeaturePhasesArrayPath_Key); auto pAvgQuatsArrayPathValue = filterArgs.value(k_AvgQuatsArrayPath_Key); auto pCrystalStructuresArrayPathValue = filterArgs.value(k_CrystalStructuresArrayPath_Key); - DataPath cellFeatDataPath = pFeaturePhasesArrayPathValue.getParent(); - auto pSchmidsArrayNameValue = cellFeatDataPath.createChildPath(filterArgs.value(k_SchmidsArrayName_Key)); - auto pSlipSystemsArrayNameValue = cellFeatDataPath.createChildPath(filterArgs.value(k_SlipSystemsArrayName_Key)); - auto pPolesArrayNameValue = cellFeatDataPath.createChildPath(filterArgs.value(k_PolesArrayName_Key)); - auto pPhisArrayNameValue = cellFeatDataPath.createChildPath(filterArgs.value(k_PhisArrayName_Key)); - auto pLambdasArrayNameValue = cellFeatDataPath.createChildPath(filterArgs.value(k_LambdasArrayName_Key)); - - PreflightResult preflightResult; + DataPath cellFeatureDataPath = pFeaturePhasesArrayPathValue.getParent(); + auto pSchmidsArrayNameValue = cellFeatureDataPath.createChildPath(filterArgs.value(k_SchmidsArrayName_Key)); + auto pSlipSystemsArrayNameValue = cellFeatureDataPath.createChildPath(filterArgs.value(k_SlipSystemsArrayName_Key)); + auto pPolesArrayNameValue = cellFeatureDataPath.createChildPath(filterArgs.value(k_PolesArrayName_Key)); + auto pPhisArrayNameValue = cellFeatureDataPath.createChildPath(filterArgs.value(k_PhisArrayName_Key)); + auto pLambdasArrayNameValue = cellFeatureDataPath.createChildPath(filterArgs.value(k_LambdasArrayName_Key)); nx::core::Result resultOutputActions; - DataPath featureDataGroup = pFeaturePhasesArrayPathValue.getParent(); + const Int32Array& phasesRef = dataStructure.getDataRefAs(pFeaturePhasesArrayPathValue); + const UInt32Array& crystalStructuresRef = dataStructure.getDataRefAs(pCrystalStructuresArrayPathValue); + + auto tupleValidityCheck = dataStructure.validateNumberOfTuples({pFeaturePhasesArrayPathValue, pAvgQuatsArrayPathValue}); + if(!tupleValidityCheck) + { + return {MakeErrorResult(-13503, fmt::format("The Feature Phases array '{}' and Average Quaternions array '{}' must have equal tuple counts. {}", + pFeaturePhasesArrayPathValue.toString(), pAvgQuatsArrayPathValue.toString(), tupleValidityCheck.error()))}; + } - const Int32Array& phases = dataStructure.getDataRefAs(pFeaturePhasesArrayPathValue); - auto tupleShape = phases.getIDataStore()->getTupleShape(); + if(phasesRef.getNumberOfTuples() == 0) + { + return {MakeErrorResult( + -13504, fmt::format("The Feature Phases array '{}' and Average Quaternions array '{}' each contain 0 tuples. They must contain at least the Feature 0 sentinel tuple.", + pFeaturePhasesArrayPathValue.toString(), pAvgQuatsArrayPathValue.toString()))}; + } + + if(crystalStructuresRef.getNumberOfTuples() == 0) + { + return {MakeErrorResult( + -13505, fmt::format("The Crystal Structures array '{}' contains 0 tuples. It must contain at least one Ensemble tuple.", pCrystalStructuresArrayPathValue.toString()))}; + } + + const auto vectorMagnitude = [](const VectorFloat32Parameter::ValueType& vector) { + return std::sqrt(static_cast(vector[0]) * vector[0] + static_cast(vector[1]) * vector[1] + static_cast(vector[2]) * vector[2]); + }; + + if(vectorMagnitude(pLoadingDirectionValue) < std::numeric_limits::epsilon()) + { + return {MakeErrorResult(-13506, fmt::format("Loading Direction <{}, {}, {}> has zero length. Its components must define a non-zero direction.", pLoadingDirectionValue[0], + pLoadingDirectionValue[1], pLoadingDirectionValue[2]))}; + } + + const auto tupleShape = phasesRef.getIDataStore()->getTupleShape(); + + // All five output arrays are created with an explicit "0" fill value. The algorithm skips any + // Feature whose crystal structure is outside the supported Laue-group range, and Feature 0 is a + // sentinel slot that is never computed. Legacy SIMPL FindSchmids allocated with initValue 0. + const std::string k_ZeroFill = "0"; // Create output Schmids Array { - auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pSchmidsArrayNameValue); + auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pSchmidsArrayNameValue, "", k_ZeroFill); resultOutputActions.value().appendAction(std::move(createArrayAction)); } // Create output SlipSystems Array { - auto createArrayAction = std::make_unique(DataType::int32, tupleShape, std::vector{1}, pSlipSystemsArrayNameValue); + auto createArrayAction = std::make_unique(DataType::int32, tupleShape, std::vector{1}, pSlipSystemsArrayNameValue, "", k_ZeroFill); resultOutputActions.value().appendAction(std::move(createArrayAction)); } - // Create output SlipSystems Array + // Create output Poles Array { - auto createArrayAction = std::make_unique(DataType::int32, tupleShape, std::vector{3}, pPolesArrayNameValue); + auto createArrayAction = std::make_unique(DataType::int32, tupleShape, std::vector{3}, pPolesArrayNameValue, "", k_ZeroFill); resultOutputActions.value().appendAction(std::move(createArrayAction)); } - // Create output SlipSystems Array + // Create output Phis Array if(pStoreAngleComponentsValue) { - auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pPhisArrayNameValue); + auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pPhisArrayNameValue, "", k_ZeroFill); resultOutputActions.value().appendAction(std::move(createArrayAction)); } // Create output Lambdas Array if(pStoreAngleComponentsValue) { - auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pLambdasArrayNameValue); + auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pLambdasArrayNameValue, "", k_ZeroFill); resultOutputActions.value().appendAction(std::move(createArrayAction)); } if(pOverrideSystemValue) { + if(vectorMagnitude(pSlipPlaneValue) < std::numeric_limits::epsilon()) + { + return {MakeErrorResult( + -13507, fmt::format("Slip Plane <{}, {}, {}> has zero length. Its components must define a non-zero plane normal.", pSlipPlaneValue[0], pSlipPlaneValue[1], pSlipPlaneValue[2]))}; + } + if(vectorMagnitude(pSlipDirectionValue) < std::numeric_limits::epsilon()) + { + return {MakeErrorResult(-13508, fmt::format("Slip Direction <{}, {}, {}> has zero length. Its components must define a non-zero direction.", pSlipDirectionValue[0], + pSlipDirectionValue[1], pSlipDirectionValue[2]))}; + } + // make sure direction lies in plane - float cosVec = pSlipPlaneValue[0] * pSlipDirectionValue[0] + pSlipPlaneValue[1] * pSlipDirectionValue[1] + pSlipPlaneValue[2] * pSlipDirectionValue[2]; - if(0.0F != cosVec) + const float32 dotProduct = pSlipPlaneValue[0] * pSlipDirectionValue[0] + pSlipPlaneValue[1] * pSlipDirectionValue[1] + pSlipPlaneValue[2] * pSlipDirectionValue[2]; + if(0.0F != dotProduct) { - return {MakeErrorResult(-13500, "Slip Plane and Slip Direction must be normal")}; + return { + MakeErrorResult(-13500, fmt::format("Slip Plane <{}, {}, {}> and Slip Direction <{}, {}, {}> have a dot product of {}. The vectors must be perpendicular.", pSlipPlaneValue[0], + pSlipPlaneValue[1], pSlipPlaneValue[2], pSlipDirectionValue[0], pSlipDirectionValue[1], pSlipDirectionValue[2], dotProduct))}; } } @@ -197,12 +249,12 @@ Result<> ComputeSchmidsFilter::executeImpl(DataStructure& dataStructure, const A inputValues.FeaturePhasesArrayPath = filterArgs.value(k_FeaturePhasesArrayPath_Key); inputValues.AvgQuatsArrayPath = filterArgs.value(k_AvgQuatsArrayPath_Key); inputValues.CrystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); - DataPath cellFeatDataPath = inputValues.FeaturePhasesArrayPath.getParent(); - inputValues.SchmidsArrayName = cellFeatDataPath.createChildPath(filterArgs.value(k_SchmidsArrayName_Key)); - inputValues.SlipSystemsArrayName = cellFeatDataPath.createChildPath(filterArgs.value(k_SlipSystemsArrayName_Key)); - inputValues.PolesArrayName = cellFeatDataPath.createChildPath(filterArgs.value(k_PolesArrayName_Key)); - inputValues.PhisArrayName = cellFeatDataPath.createChildPath(filterArgs.value(k_PhisArrayName_Key)); - inputValues.LambdasArrayName = cellFeatDataPath.createChildPath(filterArgs.value(k_LambdasArrayName_Key)); + DataPath cellFeatureDataPath = inputValues.FeaturePhasesArrayPath.getParent(); + inputValues.SchmidsArrayName = cellFeatureDataPath.createChildPath(filterArgs.value(k_SchmidsArrayName_Key)); + inputValues.SlipSystemsArrayName = cellFeatureDataPath.createChildPath(filterArgs.value(k_SlipSystemsArrayName_Key)); + inputValues.PolesArrayName = cellFeatureDataPath.createChildPath(filterArgs.value(k_PolesArrayName_Key)); + inputValues.PhisArrayName = cellFeatureDataPath.createChildPath(filterArgs.value(k_PhisArrayName_Key)); + inputValues.LambdasArrayName = cellFeatureDataPath.createChildPath(filterArgs.value(k_LambdasArrayName_Key)); return ComputeSchmids(dataStructure, messageHandler, shouldCancel, &inputValues)(); } diff --git a/src/Plugins/OrientationAnalysis/test/ComputeSchmidsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeSchmidsTest.cpp index 7258f3cc55..ddcd6149b2 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeSchmidsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeSchmidsTest.cpp @@ -2,17 +2,24 @@ #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/BoolParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" + +#include #include +#include #include -#include +#include namespace fs = std::filesystem; using namespace nx::core; @@ -26,84 +33,752 @@ const std::string k_SlipSystemsArrayName("SlipSystems"); const std::string k_PolesArrayName("Poles"); const std::string k_PhisArrayName("Schmid_Phis"); const std::string k_LambdasArrayName("Schmid_Lambdas"); -const std::string k_CalculatedArrayPrefix("Calculated_"); } // namespace -TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter", "[OrientationAnalysis][ComputeSchmidsFilter]") +// ===================================================================================== +// V&V hand-built fixtures (Class 1 Analytical + Class 4 Invariant) +// ===================================================================================== +// +// ORACLE PROVENANCE +// ----------------- +// Every expected number below was derived by hand from the ALGORITHM SOURCE, before +// the filter was ever run, and independently cross-checked at 80 significant digits by +// ww_work/ComputeSchmids/oracle.py (mpmath). No value here was read back from a filter +// run or from a legacy DREAM3D run. +// +// The chain being modelled is: +// +// 1. simplnx ComputeSchmids.cpp: +// sampleLoading = normalize(LoadingDirection) +// om = QuaternionD(qx, qy, qz, qw).toOrientationMatrix() +// crystalLoading = om * sampleLoading +// Poles[3i+k] = static_cast(crystalLoading[k] * 100.0) // TRUNCATES toward zero +// +// 2. EbsdLib Quaternion::toOrientationMatrix() (row-major, epsijk = +1 so NO transpose; +// OrientationFwd.hpp defines DREAM3D_PASSIVE_ROTATION => epsijkd == 1.0): +// qq = w*w - (x*x + y*y + z*z) +// om = [ qq+2x*x, 2(xy - wz), 2(xz + wy) ] +// [ 2(yx + wz), qq+2y*y, 2(yz - wx) ] +// [ 2(zx - wy), 2(zy + wx), qq+2z*z ] +// +// 3. EbsdLib CubicOps::getSchmidFactorAndSS(load, m, angleComps, slipsys) -- the AUTO path. +// With L = crystalLoading and mag = |L|: +// theta1..4 = |Lx+Ly+Lz|, |Lx+Ly-Lz|, |Lx-Ly+Lz|, |-Lx+Ly+Lz| all / (mag * sqrt(3)) +// lambda1..6 = |Lx+Ly|, |Lx+Lz|, |Lx-Ly|, |Lx-Lz|, |Ly+Lz|, |Ly-Lz| all / (mag * sqrt(2)) +// theta_i is cos(phi) against a {111} plane normal; lambda_j is cos(lambda) against a +// <110> slip direction. The twelve FCC systems are enumerated, in source order, as +// (theta, lambda) index pairs: +// idx: 0 1 2 3 4 5 +// t1*l6 t1*l4 t1*l3 t2*l3 t2*l2 t2*l5 +// idx: 6 7 8 9 10 11 +// t3*l1 t3*l5 t3*l4 t4*l1 t4*l2 t4*l6 +// slipsys is seeded to 0 with schmidfactor = idx0 and each later index is taken only on a +// STRICT '>' -- so ties resolve to the LOWEST index. angleComps carries the (theta, lambda) +// of the winner, i.e. COSINES on this path (see the SC-3 note on the override path). +// +// SC-2 NOTE (EbsdLib <= 3.1.0): the sqrt(3)/sqrt(2) normalizers above were the float literals +// 1.732f / 1.414f, which inflate every Schmid factor by the uniform factor +// sqrt(6) / (1.732f * 1.414f) = 1.00018035284 (+0.0180353 %) +// and let m exceed the physical cubic maximum of 0.5 (0.500090176 at the maximizing +// direction). Fixed in EbsdLib 3.1.1; the values asserted below are the +// EXACT-arithmetic post-fix values and will fail by ~7.4e-5 against EbsdLib <= 3.1.0. +namespace +{ +constexpr float64 k_Tol = 1.0e-6; + +const std::string k_GeomName("DataContainer"); +const std::string k_FeatureAMName("Cell Feature Data"); +const std::string k_EnsembleAMName("Cell Ensemble Data"); +const std::string k_PhasesName("Phases"); +const std::string k_AvgQuatsName("AvgQuats"); +const std::string k_CrystalStructuresName("CrystalStructures"); + +const DataPath k_FeatureAMPath({k_GeomName, k_FeatureAMName}); +const DataPath k_EnsembleAMPath({k_GeomName, k_EnsembleAMName}); + +/** + * @brief One hand-built fixture: N features (feature 0 is the conventional sentinel) and + * numEnsembles ensemble slots. Ensemble 0 is the 999 "unknown" sentinel, ensemble 1 is + * Cubic_High. Every feature defaults to phase 1 with the identity quaternion. + */ +struct SchmidFixture +{ + DataStructure ds; + Int32Array* featurePhasesPtr = nullptr; + Float32Array* avgQuatsPtr = nullptr; + UInt32Array* crystalStructuresPtr = nullptr; +}; + +SchmidFixture MakeFixture(usize numFeatures, usize numEnsembles = 2) +{ + SchmidFixture fixture; + auto* imageGeomPtr = ImageGeom::Create(fixture.ds, k_GeomName); + imageGeomPtr->setDimensions({1, 1, 1}); + imageGeomPtr->setSpacing({1.0F, 1.0F, 1.0F}); + imageGeomPtr->setOrigin({0.0F, 0.0F, 0.0F}); + + auto* featureAMPtr = AttributeMatrix::Create(fixture.ds, k_FeatureAMName, ShapeType{numFeatures}, imageGeomPtr->getId()); + auto* ensembleAMPtr = AttributeMatrix::Create(fixture.ds, k_EnsembleAMName, ShapeType{numEnsembles}, imageGeomPtr->getId()); + + auto featurePhasesStore = DataStoreUtilities::CreateDataStore({numFeatures}, {1}, IDataAction::Mode::Execute); + fixture.featurePhasesPtr = Int32Array::Create(fixture.ds, k_PhasesName, featurePhasesStore, featureAMPtr->getId()); + auto avgQuatsStore = DataStoreUtilities::CreateDataStore({numFeatures}, {4}, IDataAction::Mode::Execute); + fixture.avgQuatsPtr = Float32Array::Create(fixture.ds, k_AvgQuatsName, avgQuatsStore, featureAMPtr->getId()); + auto crystalStructuresStore = DataStoreUtilities::CreateDataStore({numEnsembles}, {1}, IDataAction::Mode::Execute); + fixture.crystalStructuresPtr = UInt32Array::Create(fixture.ds, k_CrystalStructuresName, crystalStructuresStore, ensembleAMPtr->getId()); + + for(usize featureIdx = 0; featureIdx < numFeatures; ++featureIdx) + { + (*fixture.featurePhasesPtr)[featureIdx] = 1; + (*fixture.avgQuatsPtr)[featureIdx * 4 + 0] = 0.0F; // x + (*fixture.avgQuatsPtr)[featureIdx * 4 + 1] = 0.0F; // y + (*fixture.avgQuatsPtr)[featureIdx * 4 + 2] = 0.0F; // z + (*fixture.avgQuatsPtr)[featureIdx * 4 + 3] = 1.0F; // w -> identity + } + if(numFeatures > 0) + { + (*fixture.featurePhasesPtr)[0] = 0; // feature 0 is the sentinel; the algorithm starts its loop at 1 + } + for(usize ensembleIdx = 0; ensembleIdx < numEnsembles; ++ensembleIdx) + { + (*fixture.crystalStructuresPtr)[ensembleIdx] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + } + if(numEnsembles > 1) + { + (*fixture.crystalStructuresPtr)[1] = ebsdlib::CrystalStructure::Cubic_High; // EbsdLib LaueOps index 1 + } + return fixture; +} + +void SetQuat(SchmidFixture& fixture, usize featureIdx, const std::array& quat) +{ + (*fixture.avgQuatsPtr)[featureIdx * 4 + 0] = quat[0]; + (*fixture.avgQuatsPtr)[featureIdx * 4 + 1] = quat[1]; + (*fixture.avgQuatsPtr)[featureIdx * 4 + 2] = quat[2]; + (*fixture.avgQuatsPtr)[featureIdx * 4 + 3] = quat[3]; +} + +Arguments MakeArgs(const std::vector& loading, bool storeAngleComps, bool overrideSystem, const std::vector& plane = {0.0F, 0.0F, 1.0F}, + const std::vector& direction = {1.0F, 0.0F, 0.0F}) +{ + Arguments args; + args.insertOrAssign(ComputeSchmidsFilter::k_LoadingDirection_Key, std::make_any(loading)); + args.insertOrAssign(ComputeSchmidsFilter::k_StoreAngleComponents_Key, std::make_any(storeAngleComps)); + args.insertOrAssign(ComputeSchmidsFilter::k_OverrideSystem_Key, std::make_any(overrideSystem)); + args.insertOrAssign(ComputeSchmidsFilter::k_SlipPlane_Key, std::make_any(plane)); + args.insertOrAssign(ComputeSchmidsFilter::k_SlipDirection_Key, std::make_any(direction)); + args.insertOrAssign(ComputeSchmidsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(k_FeatureAMPath.createChildPath(k_PhasesName))); + args.insertOrAssign(ComputeSchmidsFilter::k_AvgQuatsArrayPath_Key, std::make_any(k_FeatureAMPath.createChildPath(k_AvgQuatsName))); + args.insertOrAssign(ComputeSchmidsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_EnsembleAMPath.createChildPath(k_CrystalStructuresName))); + args.insertOrAssign(ComputeSchmidsFilter::k_SchmidsArrayName_Key, std::make_any(k_SchmidsArrayName)); + args.insertOrAssign(ComputeSchmidsFilter::k_SlipSystemsArrayName_Key, std::make_any(k_SlipSystemsArrayName)); + args.insertOrAssign(ComputeSchmidsFilter::k_PolesArrayName_Key, std::make_any(k_PolesArrayName)); + args.insertOrAssign(ComputeSchmidsFilter::k_PhisArrayName_Key, std::make_any(k_PhisArrayName)); + args.insertOrAssign(ComputeSchmidsFilter::k_LambdasArrayName_Key, std::make_any(k_LambdasArrayName)); + return args; +} + +/** + * @brief Asserts the five outputs of one feature against the oracle. Angle components are + * checked only when phisRef/lambdasRef are >= 0 (a negative sentinel means "do not check"). + */ +void CheckFeature(const DataStructure& ds, usize featureIdx, float64 expectedM, int32 expectedSlipSystem, const std::array& expectedPoles, float64 expectedPhi, float64 expectedLambda) +{ + REQUIRE_NOTHROW(ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName))); + REQUIRE_NOTHROW(ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SlipSystemsArrayName))); + REQUIRE_NOTHROW(ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PolesArrayName))); + const auto& schmidsRef = ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName)); + const auto& slipSystemsRef = ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SlipSystemsArrayName)); + const auto& polesRef = ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PolesArrayName)); + + CAPTURE(featureIdx); + CHECK(static_cast(schmidsRef[featureIdx]) == Approx(expectedM).margin(k_Tol)); + CHECK(slipSystemsRef[featureIdx] == expectedSlipSystem); + for(usize compIdx = 0; compIdx < 3; ++compIdx) + { + CAPTURE(compIdx); + CHECK(polesRef[featureIdx * 3 + compIdx] == expectedPoles[compIdx]); + } + + if(expectedPhi >= 0.0) + { + REQUIRE_NOTHROW(ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName))); + REQUIRE_NOTHROW(ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName))); + const auto& phisRef = ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName)); + const auto& lambdasRef = ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName)); + CHECK(static_cast(phisRef[featureIdx]) == Approx(expectedPhi).margin(k_Tol)); + CHECK(static_cast(lambdasRef[featureIdx]) == Approx(expectedLambda).margin(k_Tol)); + } +} +} // namespace + +// ----------------------------------------------------------------------------- +// Class 1 (Analytical): auto slip-system path, identity quaternion, Cubic_High. +// +// om == identity, so crystalLoading == normalize(LoadingDirection) EXACTLY (the quaternion +// (0,0,0,1) gives qq == 1 and every off-diagonal term is an exact zero). +// +// [0,0,1] L = (0,0,1) +// theta1..4 = 1/sqrt(3) each (|0+0+1| = |0+0-1| = |0-0+1| = |-0+0+1| = 1) +// lambda1 = 0, lambda2 = 1/sqrt(2), lambda3 = 0, lambda4..6 = 1/sqrt(2) +// schmid = [1/sqrt6, 1/sqrt6, 0, 0, 1/sqrt6, 1/sqrt6, 0, 1/sqrt6, 1/sqrt6, 0, 1/sqrt6, 1/sqrt6] +// EIGHT-way tie at 1/sqrt(6) -- indices 0, 1, 4, 5, 7, 8, 10, 11; STRICT '>' keeps index 0. +// m = 1/sqrt(6) = 0.4082482904638630, cos(phi) = theta1 = 1/sqrt(3), cos(lambda) = lambda6 = 1/sqrt(2) +// Poles = trunc(100 * (0,0,1)) = (0, 0, 100) +// +// [1,1,1] L = (1,1,1)/sqrt(3), so Lx == Ly == Lz == s +// theta1 = 3s/sqrt3 = 1 ; theta2 = theta3 = theta4 = s/sqrt3 = 1/3 (bit-identical: s+s-s == s) +// lambda1 = lambda2 = lambda5 = 2s/sqrt2 = 2/sqrt6 ; lambda3 = lambda4 = lambda6 = 0 +// schmid = [0,0,0,0, 2/(3 sqrt6) x6 at idx 4,5,6,7 and 9,10, 0 elsewhere] -- a SIX-way tie. +// first non-zero maximum is index 4 (theta2*lambda2). +// m = 2/(3*sqrt(6)) = 0.2721655269759087, cos(phi) = 1/3, cos(lambda) = 2/sqrt(6) = 0.8164965809277260 +// Poles = trunc(100 * 0.5773502691896258) = (57, 57, 57) +// +// [0,1,1] L = (0,1,1)/sqrt(2), Lx = 0, Ly == Lz == u +// theta1 = theta4 = 2u/sqrt3 = 2/sqrt6 ; theta2 = theta3 = 0 +// lambda1..4 = 0.5 ; lambda5 = 1 ; lambda6 = 0 +// schmid[0] = 0 (theta1*lambda6); schmid[1] = (2/sqrt6)(1/2) = 1/sqrt6 -> first strict win at index 1. +// indices 2, 9, 10 tie at 1/sqrt6 and are rejected by the strict '>'. +// m = 1/sqrt(6) = 0.4082482904638630, cos(phi) = 2/sqrt(6) = 0.8164965809277260, cos(lambda) = 0.5 +// This fixture is the one that SEPARATES Phis from Lambdas (0.8165 vs 0.5). +// Poles = trunc(100 * 0.7071067811865476) = (0, 70, 70) +// +// [1,2,3] L = (1,2,3)/sqrt(14) -- the enumeration-order pin: a UNIQUE maximum, no ties. +// theta1..4 = 6, 0, 2, 4 (all / sqrt(42)); lambda1..6 = 3, 4, 1, 2, 5, 1 (all / sqrt(28)) +// schmid * sqrt(1176) = [6, 12, 6, 0, 0, 0, 6, 10, 4, 12, 16, 4] -> unique max 16 at index 10. +// m = 16/sqrt(1176) = 8/sqrt(294) = 0.4665694748158435 +// cos(phi) = 4/sqrt(42) = 0.6172133998483701, cos(lambda) = 4/sqrt(28) = 0.7559289460184544 +// Poles = trunc(100 * (0.2672612419, 0.5345224838, 0.8017837257)) = (26, 53, 80) +// Every component is far from an integer boundary, so the truncation is exact, not marginal. +// +// [3,6,9] is [1,2,3] scaled by 3: the filter normalizes the loading direction, so every +// output must be identical (scale invariance, Class 4). +// ----------------------------------------------------------------------------- +TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: Class 1 analytical oracle, auto slip system", "[OrientationAnalysis][ComputeSchmidsFilter][VV]") +{ + UnitTest::LoadPlugins(); + + struct OracleRow + { + const char* label; + std::vector loading; + float64 m; + int32 slipSystem; + std::array poles; + float64 cosPhi; + float64 cosLambda; + }; + + // clang-format off + const std::vector rows = { + {"[0,0,1]", {0.0F, 0.0F, 1.0F}, 0.4082482904638630, 0, {0, 0, 100}, 0.5773502691896258, 0.7071067811865476}, + {"[1,1,1]", {1.0F, 1.0F, 1.0F}, 0.2721655269759087, 4, {57, 57, 57}, 0.3333333333333333, 0.8164965809277260}, + {"[0,1,1]", {0.0F, 1.0F, 1.0F}, 0.4082482904638630, 1, {0, 70, 70}, 0.8164965809277260, 0.5000000000000000}, + {"[1,2,3]", {1.0F, 2.0F, 3.0F}, 0.4665694748158435, 10, {26, 53, 80}, 0.6172133998483701, 0.7559289460184544}, + {"[3,6,9]", {3.0F, 6.0F, 9.0F}, 0.4665694748158435, 10, {26, 53, 80}, 0.6172133998483701, 0.7559289460184544}, + }; + // clang-format on + + for(const auto& row : rows) + { + DYNAMIC_SECTION("loading " << row.label) + { + SchmidFixture fixture = MakeFixture(2); + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs(row.loading, true, false); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeature(fixture.ds, 1, row.m, row.slipSystem, row.poles, row.cosPhi, row.cosLambda); + + // Class 4 invariants. + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName))); + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName))); + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName))); + const auto& schmidsRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName)); + const auto& phisRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName)); + const auto& lambdasRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName)); + // (a) The Schmid factor of a cubic crystal cannot exceed 0.5 and cannot be negative. + // EbsdLib <= 3.1.0 violated the upper bound (max 0.500090176); this assertion is only + // satisfiable against the fixed normalizers. + CHECK(schmidsRef[1] >= 0.0F); + CHECK(schmidsRef[1] <= 0.5F); + // (b) On the auto path angleComps are COSINES, so m must be their product exactly. + CHECK(static_cast(schmidsRef[1]) == Approx(static_cast(phisRef[1]) * static_cast(lambdasRef[1])).margin(k_Tol)); + // (c) Both cosines are direction cosines: in [0, 1]. + CHECK(phisRef[1] >= 0.0F); + CHECK(phisRef[1] <= 1.0F); + CHECK(lambdasRef[1] >= 0.0F); + CHECK(lambdasRef[1] <= 1.0F); + + // Feature 0 is the sentinel slot; the algorithm writes explicit zeros there. + CheckFeature(fixture.ds, 0, 0.0, 0, {0, 0, 0}, 0.0, 0.0); + + UnitTest::CheckArraysInheritTupleDims(fixture.ds); + } + } +} + +// ----------------------------------------------------------------------------- +// Class 1 + Class 4: the quaternion -> orientation-matrix convention, and equivariance. +// +// All three quaternions below are EXACTLY representable in float32 and produce an EXACT +// orientation matrix (every entry is 0 or +/-1), so there is no floating-point slack in the +// expected Poles. Loading is [1,2,3] for all three. +// +// q = (0,0,0,1) identity om = I +// crystalLoading = ( 1, 2, 3)/sqrt(14) -> SS 10, Poles ( 26, 53, 80) +// +// q = (0,0,1,0) 180 deg about Z qq = -1 -> om = diag(-1,-1,1) +// crystalLoading = (-1,-2, 3)/sqrt(14) +// theta1..4 = 0, 6, 4, 2 ; lambda1..6 = 3, 2, 1, 4, 1, 5 (over sqrt(28)) +// schmid*sqrt(1176) = [0,0,0, 6,12,6, 12,4,16, 6,4,10] -> unique max 16 at idx 8 +// -> SS 8, Poles (-26, -53, 80). NEGATIVE poles: this fixture is what +// pins truncation-toward-zero (a round() would give -27 and -53). +// +// q = (.5,.5,.5,.5) 120 deg about [111]; qq = -0.5 +// om = [[0,0,1],[1,0,0],[0,1,0]] (an ASYMMETRIC permutation matrix) +// crystalLoading = ( 3, 1, 2)/sqrt(14) +// theta1..4 = 6, 2, 4, 0 ; lambda1..6 = 4, 5, 2, 1, 3, 1 (over sqrt(28)) +// schmid*sqrt(1176) = [6,6,12, 4,10,6, 16,12,4, 0,0,0] -> unique max 16 at idx 6 +// -> SS 6, Poles (80, 26, 53). +// Transposing om would give crystalLoading = (2,3,1)/sqrt(14), hence +// SS 5 and Poles (53, 80, 26) -- so this row DISCRIMINATES the transpose. +// +// q = (1,0,0,0) 180 deg about X qq = -1 -> om = diag(1,-1,-1) +// crystalLoading = ( 1,-2,-3)/sqrt(14) +// theta1..4 = 4, 2, 0, 6 ; lambda1..6 = 1, 2, 3, 4, 5, 1 (over sqrt(28)) +// schmid*sqrt(1176) = [4,16,12, 6,4,10, 0,0,0, 6,12,6] -> unique max 16 at idx 1 +// -> SS 1, Poles (26, -53, -80). +// The three rows above all have quaternion x == y, so none of them notices a +// swapped x/y read -- a gap the mutation run found. This row closes it: swap +// x and y and the quaternion becomes (0,1,0,0), a 180 deg rotation about Y, +// giving om = diag(-1,1,-1), SS 4 and Poles (-26, 53, -80). +// +// All three rotations are cubic symmetry operations, so the Schmid factor and both angle +// components must be IDENTICAL across the three (Class 4 symmetry invariance) even though the +// winning slip-system index and the Poles differ. +// ----------------------------------------------------------------------------- +TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: orientation-matrix convention and equivariance", "[OrientationAnalysis][ComputeSchmidsFilter][VV]") { UnitTest::LoadPlugins(); - 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 QuatRow + { + const char* label; + std::array quat; // (x, y, z, w) + int32 slipSystem; + std::array poles; + }; + + const float64 k_ExpectedM = 0.4665694748158435; // 8/sqrt(294) + const float64 k_ExpectedPhi = 0.6172133998483701; // 4/sqrt(42) + const float64 k_ExpectedLambda = 0.7559289460184544; // 4/sqrt(28) + + const std::vector rows = { + {"identity (0,0,0,1)", {0.0F, 0.0F, 0.0F, 1.0F}, 10, {26, 53, 80}}, + {"180 deg about Z (0,0,1,0)", {0.0F, 0.0F, 1.0F, 0.0F}, 8, {-26, -53, 80}}, + {"120 deg about [111] (.5,.5,.5,.5)", {0.5F, 0.5F, 0.5F, 0.5F}, 6, {80, 26, 53}}, + {"180 deg about X (1,0,0,0)", {1.0F, 0.0F, 0.0F, 0.0F}, 1, {26, -53, -80}}, + }; + + for(const auto& row : rows) + { + DYNAMIC_SECTION("quat " << row.label) + { + SchmidFixture fixture = MakeFixture(2); + SetQuat(fixture, 1, row.quat); + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeature(fixture.ds, 1, k_ExpectedM, row.slipSystem, row.poles, k_ExpectedPhi, k_ExpectedLambda); + + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName))); + const auto& schmidsRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName)); + CHECK(schmidsRef[1] <= 0.5F); + + UnitTest::CheckArraysInheritTupleDims(fixture.ds); + } + } +} + +// ----------------------------------------------------------------------------- +// Class 1: the OVERRIDE slip-system path -- a DIFFERENT EbsdLib overload with different +// semantics for BOTH the slip-system index and the angle components. +// +// CubicOps::getSchmidFactorAndSS(load, plane, direction, m, angleComps, slipsys) loops over the +// 24 CubicHigh symmetry operators. For symmetry operator i: +// slipPlane = MatSym[i] * plane (== column 3 of MatSym[i] when plane == (0,0,1)) +// slipDirection = MatSym[i] * direction (== column 1 of MatSym[i] when direction == (1,0,0)) +// operators whose slipPlane[2] < 0 are skipped (duplicate-avoidance), then +// m_i = |L . slipPlane| * |L . slipDirection| +// and slipsys becomes the SYMMETRY-OPERATOR INDEX (0..23) of the strict maximum -- NOT the +// 0..11 FCC slip-system index of the auto path. +// +// SC-3 (documented deviation): angleComps on THIS path are acos() results, i.e. RADIANS, +// whereas the auto path writes raw COSINES into the same two output arrays. The Phis/Lambdas +// arrays therefore change UNITS with the OverrideSystem toggle. +// +// Every cubic symmetry operator is a signed permutation matrix, so with plane == (0,0,1) and +// direction == (1,0,0) the pair (slipPlane, slipDirection) is always a pair of DISTINCT signed +// basis vectors, and m_i = |L_a| * |L_b| for some a != b. +// +// L = (1,2,3)/sqrt(14): the maximum of |L_a||L_b| over a != b is (3/sqrt14)(2/sqrt14) = 6/14 = 3/7. +// SIX operators of the twenty-four attain that maximum -- {3, 6, 8, 9, 11, 14} -- so the +// asserted index is ENUMERATION-ORDER DEPENDENT: the strict '>' keeps the first, operator 3 = +// [[0,-1,0],[1,0,0],[0,0,1]], whose column 3 = (0,0,1) (z >= 0, kept) and column 1 = (0,1,0), +// giving cos(phi) = 3/sqrt(14) and cos(lambda) = 2/sqrt(14). The A/B run confirmed that +// DREAM3D 6.5.171, whose symmetry table holds the same group in a different order, reports 8 +// for the identical physical answer. This assertion therefore pins EbsdLib's table order, not +// a portable physical label. +// m = 3/7 = 0.4285714285714286 ; Phis = acos(3/sqrt14) = 0.6405223126794245 rad (36.699 deg) +// Lambdas = acos(2/sqrt14) = 1.0068536854342678 rad (57.688 deg) +// The two acos values are NOT closed-form rationals or surds; they were recomputed to 60 +// significant digits as atan(sqrt(5)/3) = 0.640522312679424574143... and +// atan(sqrt(10)/2) = 1.006853685434267776537... and rounded to the nearest double. They are +// asserted at k_Tol = 1e-6, which is far looser than that rounding. +// L = (1,1,1)/sqrt(3): all |L_a| equal, so every kept operator gives 1/3; the strict '>' keeps +// the FIRST, operator 0 (the identity). m = 1/3, both angles = acos(1/sqrt3) = 0.9553166181245093 rad. +// L = (0,0,1): slipPlane and slipDirection are distinct basis vectors, so at most one of them +// can be +/-Z; the product is 0 for ALL 24 operators. The strict '>' is never taken, so the +// function returns its initialized state: m = 0, slipsys = 0, angleComps = (0,0). This is the +// "no slip system found" degenerate case. +// ----------------------------------------------------------------------------- +TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: override slip system path", "[OrientationAnalysis][ComputeSchmidsFilter][VV]") +{ + UnitTest::LoadPlugins(); + + struct OverrideRow + { + const char* label; + std::vector loading; + float64 m; + int32 symOpIndex; + std::array poles; + float64 phiRadians; + float64 lambdaRadians; + }; + + // clang-format off + const std::vector rows = { + {"[1,2,3]", {1.0F, 2.0F, 3.0F}, 0.4285714285714286, 3, {26, 53, 80}, 0.6405223126794245, 1.0068536854342678}, + {"[1,1,1]", {1.0F, 1.0F, 1.0F}, 0.3333333333333333, 0, {57, 57, 57}, 0.9553166181245093, 0.9553166181245093}, + {"[0,0,1]", {0.0F, 0.0F, 1.0F}, 0.0000000000000000, 0, {0, 0, 100}, 0.0000000000000000, 0.0000000000000000}, + }; + // clang-format on + + for(const auto& row : rows) + { + DYNAMIC_SECTION("loading " << row.label) + { + SchmidFixture fixture = MakeFixture(2); + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs(row.loading, true, true, {0.0F, 0.0F, 1.0F}, {1.0F, 0.0F, 0.0F}); - // 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 avgQuatsDataPath = cellFeatureDataPath.createChildPath(k_AvgQuats); - DataPath featurePhasesDataPath = cellFeatureDataPath.createChildPath(k_Phases); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + CheckFeature(fixture.ds, 1, row.m, row.symOpIndex, row.poles, row.phiRadians, row.lambdaRadians); + + // Class 4: on the override path m must equal cos(Phis) * cos(Lambdas) because the stored + // components are the ANGLES, not their cosines. This is the assertion that proves the SC-3 + // unit flip is real rather than a documentation misreading. + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName))); + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName))); + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName))); + const auto& schmidsRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SchmidsArrayName)); + const auto& phisRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PhisArrayName)); + const auto& lambdasRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName)); + if(row.m > 0.0) + { + CHECK(static_cast(schmidsRef[1]) == Approx(std::cos(static_cast(phisRef[1])) * std::cos(static_cast(lambdasRef[1]))).margin(k_Tol)); + } + CHECK(schmidsRef[1] <= 0.5F); + + UnitTest::CheckArraysInheritTupleDims(fixture.ds); + } + } + + SECTION("preflight rejects a slip direction that is not in the slip plane") { - // Instantiate the filter, a DataStructure object and an Arguments Object + SchmidFixture fixture = MakeFixture(2); ComputeSchmidsFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(ComputeSchmidsFilter::k_LoadingDirection_Key, std::make_any(std::vector{1.0F, 1.0F, 1.0F})); - args.insertOrAssign(ComputeSchmidsFilter::k_StoreAngleComponents_Key, std::make_any(true)); - args.insertOrAssign(ComputeSchmidsFilter::k_OverrideSystem_Key, std::make_any(false)); - args.insertOrAssign(ComputeSchmidsFilter::k_SlipPlane_Key, std::make_any(std::vector{0.0F, 0.0F, 1.0F})); - args.insertOrAssign(ComputeSchmidsFilter::k_SlipDirection_Key, std::make_any(std::vector{1.0F, 0.0F, 0.0F})); - - args.insertOrAssign(ComputeSchmidsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(featurePhasesDataPath)); - args.insertOrAssign(ComputeSchmidsFilter::k_AvgQuatsArrayPath_Key, std::make_any(avgQuatsDataPath)); - args.insertOrAssign(ComputeSchmidsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresArrayPath)); - - args.insertOrAssign(ComputeSchmidsFilter::k_SchmidsArrayName_Key, std::make_any(k_CalculatedArrayPrefix + k_SchmidsArrayName)); - args.insertOrAssign(ComputeSchmidsFilter::k_SlipSystemsArrayName_Key, std::make_any(k_CalculatedArrayPrefix + k_SlipSystemsArrayName)); - args.insertOrAssign(ComputeSchmidsFilter::k_PolesArrayName_Key, std::make_any(k_CalculatedArrayPrefix + k_PolesArrayName)); - args.insertOrAssign(ComputeSchmidsFilter::k_PhisArrayName_Key, std::make_any(k_CalculatedArrayPrefix + k_PhisArrayName)); - args.insertOrAssign(ComputeSchmidsFilter::k_LambdasArrayName_Key, std::make_any(k_CalculatedArrayPrefix + k_LambdasArrayName)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + // (0,0,1) . (1,0,1) == 1 != 0 + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, true, {0.0F, 0.0F, 1.0F}, {1.0F, 0.0F, 1.0F}); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13500); + } +} + +// ----------------------------------------------------------------------------- +// Option and guard coverage. +// +// SC-1: the five output arrays were created by CreateArrayAction with an EMPTY fill value, where +// legacy SIMPL FindSchmids passed initValue 0. Any feature the loop skips (`laueClass >= +// CrystalStructure::LaueGroupEnd`, ComputeSchmids.cpp:83) is never written, so those rows depend +// entirely on how the store was initialized. The fixture below is large and dirties the heap +// first, which is what settled how much of that exposure is real -- see the note inside it. +// +// SC-4: `crystalStructures[featurePhases[featureIdx]]` was unbounded. A phase id +// beyond the ensemble count, or a negative one, reads out of bounds. +// ----------------------------------------------------------------------------- +TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: options, skip path and phase guards", "[OrientationAnalysis][ComputeSchmidsFilter][VV]") +{ + UnitTest::LoadPlugins(); + + SECTION("StoreAngleComponents == false does not create Phis/Lambdas") + { + SchmidFixture fixture = MakeFixture(2); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, false, false); - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // The three unconditional outputs are unchanged by the toggle. + CheckFeature(fixture.ds, 1, 0.4665694748158435, 10, {26, 53, 80}, -1.0, -1.0); + CHECK(fixture.ds.getDataAs(k_FeatureAMPath.createChildPath(k_PhisArrayName)) == nullptr); + CHECK(fixture.ds.getDataAs(k_FeatureAMPath.createChildPath(k_LambdasArrayName)) == nullptr); + + UnitTest::CheckArraysInheritTupleDims(fixture.ds); } - // Compare the float output arrays with those precalculated from the file + SECTION("Features whose Laue class is beyond LaueGroupEnd get defined zeros") { - std::vector comparisonNames = {k_SchmidsArrayName, k_PhisArrayName, k_LambdasArrayName}; - for(const auto& comparisonName : comparisonNames) + // Ensemble slots: 0 = 999 sentinel, 1 = Cubic_High, 2 = 999 sentinel (>= LaueGroupEnd == 11). + // Feature 1 uses phase 1 (computed); every other feature uses phase 2 (skipped by the + // continue), so the explicit CreateArrayAction fill must define every skipped output row. + constexpr usize k_FeatureCount = 20000; + SchmidFixture fixture = MakeFixture(k_FeatureCount, 3); + for(usize featureIdx = 2; featureIdx < k_FeatureCount; ++featureIdx) { - const DataPath exemplarPath({k_DataContainer, k_CellFeatureData, comparisonName}); - const DataPath calculatedPath({k_DataContainer, k_CellFeatureData, k_CalculatedArrayPrefix + comparisonName}); - const auto& exemplarData = dataStructure.getDataRefAs(exemplarPath); - const auto& calculatedData = dataStructure.getDataRefAs(calculatedPath); - UnitTest::CompareDataArrays(exemplarData, calculatedData, 1); + (*fixture.featurePhasesPtr)[featureIdx] = 2; } - comparisonNames = {k_SlipSystemsArrayName, k_PolesArrayName}; - for(const auto& comparisonName : comparisonNames) + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Feature 1 still gets the full oracle answer. + CheckFeature(fixture.ds, 1, 0.4665694748158435, 10, {26, 53, 80}, 0.6172133998483701, 0.7559289460184544); + // Feature 2 was skipped: every output must be a defined zero. + CheckFeature(fixture.ds, 2, 0.0, 0, {0, 0, 0}, 0.0, 0.0); + + // ...and so must every other row the algorithm never writes: feature 0 (the sentinel) and + // features 2 through 19999. Aggregated into one CHECK per data type so the section stays a + // handful of assertions instead of a hundred thousand. + usize nonZeroFloatValues = 0; + for(const auto& arrayName : {k_SchmidsArrayName, k_PhisArrayName, k_LambdasArrayName}) + { + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(arrayName))); + const auto& arrayRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(arrayName)); + REQUIRE(arrayRef.getSize() == k_FeatureCount); + for(usize featureIdx = 0; featureIdx < k_FeatureCount; ++featureIdx) + { + if(featureIdx != 1 && arrayRef[featureIdx] != 0.0F) + { + ++nonZeroFloatValues; + } + } + } + CHECK(nonZeroFloatValues == 0); + + usize nonZeroSlipSystemValues = 0; { - const DataPath exemplarPath({k_DataContainer, k_CellFeatureData, comparisonName}); - const DataPath calculatedPath({k_DataContainer, k_CellFeatureData, k_CalculatedArrayPrefix + comparisonName}); - const auto& exemplarData = dataStructure.getDataRefAs(exemplarPath); - const auto& calculatedData = dataStructure.getDataRefAs(calculatedPath); - UnitTest::CompareDataArrays(exemplarData, calculatedData, 1); + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SlipSystemsArrayName))); + const auto& slipSystemsRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_SlipSystemsArrayName)); + REQUIRE(slipSystemsRef.getSize() == k_FeatureCount); + for(usize featureIdx = 0; featureIdx < k_FeatureCount; ++featureIdx) + { + if(featureIdx != 1 && slipSystemsRef[featureIdx] != 0) + { + ++nonZeroSlipSystemValues; + } + } } + CHECK(nonZeroSlipSystemValues == 0); + + usize nonZeroPoleValues = 0; + { + REQUIRE_NOTHROW(fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PolesArrayName))); + const auto& polesRef = fixture.ds.getDataRefAs(k_FeatureAMPath.createChildPath(k_PolesArrayName)); + REQUIRE(polesRef.getSize() == k_FeatureCount * 3); + for(usize compIdx = 0; compIdx < k_FeatureCount * 3; ++compIdx) + { + if((compIdx / 3) != 1 && polesRef[compIdx] != 0) + { + ++nonZeroPoleValues; + } + } + } + CHECK(nonZeroPoleValues == 0); + + UnitTest::CheckArraysInheritTupleDims(fixture.ds); + } + + SECTION("Valid Laue classes without slip systems keep zero Schmid outputs and computed Poles") + { + SchmidFixture fixture = MakeFixture(3, 3); + (*fixture.featurePhasesPtr)[2] = 2; + (*fixture.crystalStructuresPtr)[2] = ebsdlib::CrystalStructure::Triclinic; + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeature(fixture.ds, 2, 0.0, 0, {26, 53, 80}, 0.0, 0.0); + UnitTest::CheckArraysInheritTupleDims(fixture.ds); + } + + SECTION("a phase id beyond the ensemble count is an error, not an out-of-bounds read") + { + SchmidFixture fixture = MakeFixture(2); // 2 ensemble slots -> valid phase ids are 0 and 1 + (*fixture.featurePhasesPtr)[1] = 7; + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors().size() == 1); + CHECK(executeResult.result.errors()[0].code == -13501); + // The message must name the offending value and the ensemble count. + CHECK(executeResult.result.errors()[0].message.find('7') != std::string::npos); + CHECK(executeResult.result.errors()[0].message.find('2') != std::string::npos); + } + + SECTION("a negative phase id is an error, not an out-of-bounds read") + { + SchmidFixture fixture = MakeFixture(2); + (*fixture.featurePhasesPtr)[1] = -3; + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors().size() == 1); + CHECK(executeResult.result.errors()[0].code == -13502); + CHECK(executeResult.result.errors()[0].message.find("-3") != std::string::npos); + } +} + +TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: preflight input validation", "[OrientationAnalysis][ComputeSchmidsFilter][VV][preflight]") +{ + UnitTest::LoadPlugins(); + + SECTION("Feature Phases and Average Quaternions must have equal tuple counts") + { + DataStructure dataStructure; + auto* imageGeomPtr = ImageGeom::Create(dataStructure, k_GeomName); + imageGeomPtr->setDimensions({1, 1, 1}); + + auto* phasesAMPtr = AttributeMatrix::Create(dataStructure, "Phases Data", ShapeType{2}, imageGeomPtr->getId()); + auto featurePhasesStore = DataStoreUtilities::CreateDataStore({2}, {1}, IDataAction::Mode::Execute); + Int32Array::Create(dataStructure, k_PhasesName, featurePhasesStore, phasesAMPtr->getId()); + auto* quatsAMPtr = AttributeMatrix::Create(dataStructure, "Quaternions Data", ShapeType{6}, imageGeomPtr->getId()); + auto avgQuatsStore = DataStoreUtilities::CreateDataStore({6}, {4}, IDataAction::Mode::Execute); + Float32Array::Create(dataStructure, k_AvgQuatsName, avgQuatsStore, quatsAMPtr->getId()); + auto* ensembleAMPtr = AttributeMatrix::Create(dataStructure, k_EnsembleAMName, ShapeType{2}, imageGeomPtr->getId()); + auto crystalStructuresStore = DataStoreUtilities::CreateDataStore({2}, {1}, IDataAction::Mode::Execute); + UInt32Array::Create(dataStructure, k_CrystalStructuresName, crystalStructuresStore, ensembleAMPtr->getId()); + + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + args.insertOrAssign(ComputeSchmidsFilter::k_FeaturePhasesArrayPath_Key, std::make_any(DataPath({k_GeomName, "Phases Data", k_PhasesName}))); + args.insertOrAssign(ComputeSchmidsFilter::k_AvgQuatsArrayPath_Key, std::make_any(DataPath({k_GeomName, "Quaternions Data", k_AvgQuatsName}))); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13503); } -// Write the DataStructure out to the file system -#ifdef SIMPLNX_WRITE_TEST_OUTPUT - WriteTestDataStructure(dataStructure, fs::path(fmt::format("{}/find_schmids.dream3d", unit_test::k_BinaryTestOutputDir))); -#endif + SECTION("Feature arrays must contain the sentinel tuple") + { + SchmidFixture fixture = MakeFixture(0); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); - UnitTest::CheckArraysInheritTupleDims(dataStructure); + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13504); + } + + SECTION("Crystal Structures must contain at least one ensemble") + { + SchmidFixture fixture = MakeFixture(2, 0); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, false); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13505); + } + + SECTION("Loading Direction must be non-zero") + { + SchmidFixture fixture = MakeFixture(2); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({0.0F, 0.0F, 0.0F}, true, false); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13506); + } + + SECTION("Override Slip Plane must be non-zero") + { + SchmidFixture fixture = MakeFixture(2); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, true, {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13507); + } + + SECTION("Override Slip Direction must be non-zero") + { + SchmidFixture fixture = MakeFixture(2); + ComputeSchmidsFilter filter; + Arguments args = MakeArgs({1.0F, 2.0F, 3.0F}, true, true, {0.0F, 0.0F, 1.0F}, {0.0F, 0.0F, 0.0F}); + + auto preflightResult = filter.preflight(fixture.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors().size() == 1); + CHECK(preflightResult.outputActions.errors()[0].code == -13508); + } } TEST_CASE("OrientationAnalysis::ComputeSchmidsFilter: SIMPL Backwards Compatibility", "[OrientationAnalysis][ComputeSchmidsFilter][BackwardsCompatibility]") diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeSchmidsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeSchmidsFilter.md new file mode 100644 index 0000000000..a80e43f6c6 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeSchmidsFilter.md @@ -0,0 +1,190 @@ +# V&V Report: ComputeSchmidsFilter + +| | | +|--------|--------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| DREAM3D 6.5.171 equivalent | `FindSchmids` (SIMPL UUID `e67ca06a-176f-58fc-a676-d6ee5553511a`) - `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/FindSchmids.{h,cpp}` | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | Pending second-engineer PR review. | + +## At a glance + +| Aspect | Current state | +|---|---| +| Algorithm Relationship | Port with with bug fixes. See 'Dependency' section | +| Oracle (confirmed) | Class 1 (Analytical) - **12 value-asserting fixtures**: 5 auto-path loading directions, 4 exactly-representable quaternions, and 3 override-path cases. Class 4 assertions cover the physical bound `0 <= m <= 0.5`, angle-component relationships, fixed-point `Poles`, scale invariance, and cubic-symmetry invariance. The fixtures are inline and independent of archived filter output. | +| Code paths enumerated | 20 of 21 paths exercised - only the cancel path is untested. | +| Tests today | 6 inline test cases (**598 assertions**, measured): 3 Class 1 / Class 4 oracle cases, 1 options-and-guards case, 1 preflight-validation case, and 1 SIMPL backwards-compatibility case. | +| Exemplar archive | **None for this filter.** The circular `6_6_stats_test_v2.tar.gz` comparison was retired. | +| Legacy comparison | **Run — SIMPLNX vs DREAM3D 6.5.171; independently repeated during review.** One shared six-Feature input was exercised through 10 original pipeline pairs plus 3 zero-vector validation pairs. The independent rerun confirmed **291/291 expected relationships**: auto-path floats matched the exact D1 bias factors, auto-path `SlipSystems` and `Poles` were bit-identical, override-path floats agreed within `1.2e-7` relative, and D5 was traced to equivalent symmetry tables with different ordering and tied maxima. 6 deviations: **D1** truncated cubic normalizers; **D2** legacy `-301` skipped-row sentinel; **D3** undefined library outputs; **D4** mode-dependent angle units, names, and error reporting; **D5** table-relative override index; **D6** malformed-input validation. See the deviations document for root-cause detail. | +| Bug flags | **Four bugs resolved in SIMPLNX or EbsdLib:** `ComputeSchmidsFilter-D1` (truncated cubic normalizers), `ComputeSchmidsFilter-D2` (legacy `-301` skipped-row initialization), `ComputeSchmidsFilter-D3` (undefined orientation-library outputs), and `ComputeSchmidsFilter-D6` (missing malformed-input validation). | +| V&V phase | **COMPLETE** | + +## Summary + +`ComputeSchmidsFilter` computes, for each Feature, the largest Schmid factor over the slip systems of that Feature's crystal structure, given a sample-frame loading direction and the Feature's average orientation. Verification uses inline Class 1 analytical fixtures plus Class 4 invariants; the circular production-scale exemplar was retired. The cycle found the original four defects plus two input-validation gaps during adversarial review and documents six deviations from DREAM3D 6.5.171. + +## Dependency state + +- EbsdLib version 3.1.1 + +## Algorithm Relationship + +**Port with minor algorithm improvements and updates for the SIMPLNX API.** + +*Evidence:* `ComputeSchmids::operator()` preserves the legacy `FindSchmids::execute()` feature iteration, loading-direction transformation, crystal-structure dispatch, and automatic/override slip-system calculations. + +Port-time updates: + +1. **Higher-precision calculations.** Legacy holds the orientation matrix, crystal-frame loading direction, Schmid factor, and angle components in `float`; SIMPLNX computes them in `double` and narrows only when storing the `float32` outputs. This improves numerical accuracy without changing the selected slip system. +2. **SIMPLNX API updates and input validation.** Selection parameters validate the required arrays, output actions declare every created array, and preflight/execute guards reject mismatched tuple counts, empty arrays, invalid phase indices, and zero-length input vectors before unsafe access or normalization. +3. **Improved error messages.** SIMPLNX identifies the offending array paths, tuple counts, vector components, phase values, and valid ranges so users can correct invalid inputs directly. + +**Material PRs since baseline:** none identified for this filter. + +## Oracle + +**Class 1 (Analytical)** with **Class 4 (Invariant)** support, and Class 3 as corroboration only. + +The oracle was derived by transcribing three sources by hand — `ComputeSchmids.cpp`, EbsdLib's `Quaternion::toOrientationMatrix()`, and both `CubicOps::getSchmidFactorAndSS` overloads — into closed-form expressions, evaluating them exactly, and writing the results into the test as inline expected values with the derivation in a comment above each. The derivation is reproduced independently at 80 significant digits by `ww_work/ComputeSchmids/oracle.py`, which parses EbsdLib's own 24-element symmetry table out of the source rather than re-typing it. + +Identity quaternion, `Cubic_High`, auto slip-system path: + +| Loading | m | cos phi (Phis) | cos lambda (Lambdas) | SlipSystems | Poles | +|---|---|---|---|---|---| +| `[0,0,1]` | `1/sqrt(6)` = 0.4082482904638630 | `1/sqrt(3)` = 0.5773502691896258 | `1/sqrt(2)` = 0.7071067811865476 | 0 | (0, 0, 100) | +| `[1,1,1]` | `2/(3 sqrt(6))` = 0.2721655269759087 | `1/3` = 0.3333333333333333 | `2/sqrt(6)` = 0.8164965809277260 | 4 | (57, 57, 57) | +| `[0,1,1]` | `1/sqrt(6)` = 0.4082482904638630 | `2/sqrt(6)` = 0.8164965809277260 | `1/2` = 0.5 | 1 | (0, 70, 70) | +| `[1,2,3]` | `8/sqrt(294)` = 0.4665694748158435 | `4/sqrt(42)` = 0.6172133998483701 | `4/sqrt(28)` = 0.7559289460184544 | 10 | (26, 53, 80) | + +`[0,0,1]` and `[1,1,1]` and `[0,1,1]` all carry ties at the maximum and pin the strict-`>` tie-break to the lowest index. `[1,2,3]` has a unique maximum and pins the enumeration order. `[0,1,1]` is the row that separates `Phis` from `Lambdas` (0.8165 versus 0.5) — on the other three rows a swapped pair would go unnoticed. `[3,6,9]` repeats `[1,2,3]` to check that the loading direction is normalized. + +Rotated quaternions, loading `[1,2,3]`. Each is exactly representable in `float32` and produces an orientation matrix whose entries are all `0` or `+/-1`, so there is no floating-point slack in the expected `Poles`. All four are cubic symmetry operations, so `m` and both angle components must be identical across them while `SlipSystems` and `Poles` move — a Class 4 symmetry invariance riding on the Class 1 values: + +| Quaternion (x,y,z,w) | Orientation matrix | crystalLoading | SlipSystems | Poles | +|---|---|---|---|---| +| `(0,0,0,1)` identity | `I` | `(1,2,3)/sqrt(14)` | 10 | (26, 53, 80) | +| `(0,0,1,0)` 180 deg about Z | `diag(-1,-1,1)` | `(-1,-2,3)/sqrt(14)` | 8 | (-26, -53, 80) | +| `(0.5,0.5,0.5,0.5)` 120 deg about [111] | `[[0,0,1],[1,0,0],[0,1,0]]` | `(3,1,2)/sqrt(14)` | 6 | (80, 26, 53) | +| `(1,0,0,0)` 180 deg about X | `diag(1,-1,-1)` | `(1,-2,-3)/sqrt(14)` | 1 | (26, -53, -80) | + +Override slip-system path (`(001)[100]`, identity quaternion) — a different EbsdLib overload with different semantics for both the index and the angle components: + +| Loading | m | symmetry-operator index | Phis (radians) | Lambdas (radians) | +|---|---|---|---|---| +| `[1,2,3]` | `3/7` = 0.4285714285714286 | 3 | 0.6405223126794245 | 1.0068536854342678 | +| `[1,1,1]` | `1/3` = 0.3333333333333333 | 0 | 0.9553166181245093 | 0.9553166181245093 | +| `[0,0,1]` | 0 | 0 | 0 | 0 | + +The three angle columns on this path are `acos` values, i.e. transcendental rather than surds, so they are not covered by `oracle.py`'s exact-arithmetic table. They were recomputed independently to 60 significant digits — `acos(3/sqrt(14)) = atan(sqrt(5)/3) = 0.640522312679424574143…`, `acos(2/sqrt(14)) = atan(sqrt(10)/2) = 1.006853685434267776537…`, `acos(1/sqrt(3)) = atan(sqrt(2)) = 0.955316618124509278163…` — and rounded to the nearest `double`. The asserted tolerance is `1e-6`, far looser than that rounding. + +Class 4 invariants asserted alongside the Class 1 values: + +- `0 <= m <= 0.5` — the physical bound for a cubic crystal. **This became assertable only after the EbsdLib fix**: EbsdLib <= 3.1.0 reached 0.500090176 at the maximizing direction. Verified over a 400 x 400 direction sweep in `oracle.py`. +- `m == Phis * Lambdas` on the auto path (the stored values are cosines) and `m == cos(Phis) * cos(Lambdas)` on the override path (the stored values are angles). The pair of assertions is what proves the SC-3 unit flip is real behaviour rather than a documentation misreading. +- `Poles[k] == trunc(100 * crystalLoading[k])`, including negative components, which is what distinguishes truncation from rounding. +- Scale invariance: `[1,2,3]` and `[3,6,9]` agree on all five outputs. +- Cubic-symmetry invariance: all four quaternions give the same `m`, `Phis` and `Lambdas`. + +**Class 3 corroboration (not the oracle):** the three canonical FCC anchors fall out of the table — `m = 0.408` for `<001>` and `<011>` loading and `m = 0.272` for `<111>` loading, with `phi = 54.74 deg` / `lambda = 45 deg` and `phi = 70.53 deg` / `lambda = 35.26 deg` respectively (Dieter, *Mechanical Metallurgy* 3rd ed. section 4-3; Schmid & Boas, *Kristallplastizität*). These are quoted as a sanity check on the Class 1 derivation, not as its source. + +**Encoded test references** (all greppable in `src/Plugins/OrientationAnalysis/test/ComputeSchmidsTest.cpp`): + +- `"OrientationAnalysis::ComputeSchmidsFilter: Class 1 analytical oracle, auto slip system"` +- `"OrientationAnalysis::ComputeSchmidsFilter: orientation-matrix convention and equivariance"` +- `"OrientationAnalysis::ComputeSchmidsFilter: override slip system path"` +- `"OrientationAnalysis::ComputeSchmidsFilter: options, skip path and phase guards"` + +## Bugs found and fixed + +This branch fixes all defects in this table. The fixes will be in the DREAM3D-NX release after v7.4.1. + +| Deviation | Defect | Affected released versions | Resolution in this branch | +|---|---|---|---| +| `ComputeSchmidsFilter-D1` | The cubic calculation used truncated normalizers. The calculated Schmid factor could exceed the physical maximum of 0.5. | DREAM.3D 6.5.171; DREAM3D-NX v7.0.0 through v7.4.1. | EbsdLib 3.1.1 uses full-precision constants. | +| `ComputeSchmidsFilter-D2` | DREAM.3D initialized uncomputed angle values to `-301`. The value was an error code that was used as an initialization value. | DREAM.3D 6.5.171 only. DREAM3D-NX was not affected. | SIMPLNX initializes all output arrays to zero. The algorithm also initializes Feature 0. | +| `ComputeSchmidsFilter-D3` | The orientation library did not initialize all output values. The filter could reuse values from the previous Feature. | DREAM.3D 6.5.171; DREAM3D-NX v7.0.0 through v7.4.1. | EbsdLib 3.1.1 initializes all output values. The algorithm also initializes its output variables for each Feature. | +| `ComputeSchmidsFilter-D6` | The filter accepted invalid phase values, empty arrays, arrays with different tuple counts, and zero direction vectors. These inputs could cause invalid output or invalid memory access. | DREAM.3D 6.5.171; DREAM3D-NX v7.0.0 through v7.4.1. | The algorithm rejects invalid phase values. Preflight rejects empty arrays, arrays with different tuple counts, and zero direction vectors. | + +## Code path coverage + +`Source: src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeSchmids.cpp (139 lines).` + +The algorithm is flat: a parameter setup block, a sentinel-row write, then one pass over Features. + +20 of 21 paths exercised. + +| # | Phase | Path | Test case | +|---|---|---|---| +| 1 | *Preflight* | Reject unequal Feature-array tuple counts (`-13503`) | "preflight input validation" / "Feature Phases and Average Quaternions must have equal tuple counts" | +| 2 | *Preflight* | Reject empty Feature arrays (`-13504`) | "preflight input validation" / "Feature arrays must contain the sentinel tuple" | +| 3 | *Preflight* | Reject an empty Crystal Structures array (`-13505`) | "preflight input validation" / "Crystal Structures must contain at least one ensemble" | +| 4 | *Preflight* | Reject a zero Loading Direction (`-13506`) | "preflight input validation" / "Loading Direction must be non-zero" | +| 5 | *Preflight* | Create the three unconditional output arrays with a zero fill | all executing V&V cases | +| 6 | *Preflight* | Create `Phis`/`Lambdas` when `StoreAngleComponents` is true | "Class 1 analytical oracle, auto slip system" | +| 7 | *Preflight* | Skip `Phis`/`Lambdas` when `StoreAngleComponents` is false | "options, skip path and phase guards" / "StoreAngleComponents == false does not create Phis/Lambdas" | +| 8 | *Preflight* | Reject a zero override Slip Plane (`-13507`) | "preflight input validation" / "Override Slip Plane must be non-zero" | +| 9 | *Preflight* | Reject a zero override Slip Direction (`-13508`) | "preflight input validation" / "Override Slip Direction must be non-zero" | +| 10 | *Preflight* | Reject a slip direction not lying in the slip plane (`-13500`) | "override slip system path" / "preflight rejects a slip direction that is not in the slip plane" | +| 11 | *Execute* | Zero the Feature-0 sentinel row | "Class 1 analytical oracle, auto slip system" | +| 12 | *Execute* | Normalize the loading direction | "Class 1 analytical oracle, auto slip system" / loading `[3,6,9]` | +| 13 | *Execute* | Normalize the user slip plane and direction (`OverrideSystem` true) | "override slip system path" | +| 14 | *Execute - per-Feature* | Negative phase id guard (`-13502`) | "options, skip path and phase guards" / "a negative phase id is an error" | +| 15 | *Execute - per-Feature* | Out-of-range phase id guard (`-13501`) | "options, skip path and phase guards" / "a phase id beyond the ensemble count is an error" | +| 16 | *Execute - per-Feature* | Skip a Feature whose Laue class is at or beyond `LaueGroupEnd` | "options, skip path and phase guards" / "Features whose Laue class is beyond LaueGroupEnd get defined zeros" | +| 17 | *Execute - per-Feature* | Valid Laue class with no enumerated slip systems | "options, skip path and phase guards" / "Valid Laue classes without slip systems keep zero Schmid outputs and computed Poles" | +| 18 | *Execute - per-Feature* | Auto slip-system path (`getSchmidFactorAndSS(load, ...)`) | "Class 1 analytical oracle, auto slip system"; "orientation-matrix convention and equivariance" | +| 19 | *Execute - per-Feature* | Override slip-system path (`getSchmidFactorAndSS(load, plane, direction, ...)`) | "override slip system path" | +| 20 | *Execute - per-Feature* | Store or omit angle components | auto, override, and StoreAngleComponents=false sections | +| 21 | *Execute - per-Feature* | `m_ShouldCancel` early return | *Not directly tested. No cancel-signal injection infrastructure exists for algorithm classes; same gap as the rest of this plugin.* | + +## Test inventory + +| Test case | Status | Notes | +|---|---|---| +| `"OrientationAnalysis::ComputeSchmidsFilter"` | retired | Removed during adversarial review because `6_6_stats_test_v2.tar.gz` stores prior ComputeSchmids output and is a circular oracle. Its useful algorithmic behavior is covered by the inline Class 1 and Class 4 fixtures. | +| `"OrientationAnalysis::ComputeSchmidsFilter: Class 1 analytical oracle, auto slip system"` | new-for-V&V | 5 `DYNAMIC_SECTION` loadings x (5 outputs + Class 4 invariants + the Feature-0 sentinel row). 236 assertions. | +| `"OrientationAnalysis::ComputeSchmidsFilter: orientation-matrix convention and equivariance"` | new-for-V&V | 4 `DYNAMIC_SECTION` quaternions pinning the `qu2om` convention, the no-transpose property, negative `Poles` truncation and cubic-symmetry invariance. 109 assertions. | +| `"OrientationAnalysis::ComputeSchmidsFilter: override slip system path"` | new-for-V&V | 3 `DYNAMIC_SECTION` loadings on the second EbsdLib overload, including the degenerate zero-Schmid case, plus the radians-versus-cosines assertion and the `-13500` preflight rejection. 93 assertions. | +| `"OrientationAnalysis::ComputeSchmidsFilter: options, skip path and phase guards"` | new-for-V&V | `StoreAngleComponents == false`; a 20 000-Feature `LaueGroupEnd` skip fixture; a valid no-slip-system Laue class; and the `-13501`/`-13502` phase-id guards. 108 assertions. | +| `"OrientationAnalysis::ComputeSchmidsFilter: preflight input validation"` | new-for-V&V | Mismatched/empty Feature arrays, empty Crystal Structures, and zero loading/override vectors (`-13503` through `-13508`). 19 assertions. | +| `"OrientationAnalysis::ComputeSchmidsFilter: SIMPL Backwards Compatibility"` | kept, untouched | 2 `DYNAMIC_SECTION` conversion fixtures (SIMPL 6.5 UUID, SIMPL 6.4 Filter_Name). 33 assertions. | + +**598 assertions total** (236 + 109 + 93 + 108 + 19 + 33), taken from a `ctest --verbose` run rather than tallied by hand. All 6 pass on the reviewed branch. + +Full `OrientationAnalysis::` regression after the internal-review fixes: **268 of 268 unit tests passed** in the EbsdLib 3.1.1 build. + +## Test sensitivity verification + +Test sensitivity verification introduces one temporary defect at a time and confirms that the applicable test fails. + +Seven temporary defects were evaluated. Each defect caused the expected V&V test to fail. + +| Temporary defect | Test that detected the defect | Result | +|---|---|---| +| Transpose the orientation matrix before rotating the loading direction. | Orientation-matrix convention and equivariance | Detected | +| Swap quaternion components X and Y when the filter reads the quaternion. | Orientation-matrix convention and equivariance | Detected | +| Round the `Poles` values instead of truncating them. | Class 1 analytical oracle, orientation-matrix convention, override path, and options and guards | Detected | +| Change the EbsdLib cubic slip-system tie-break from `>` to `>=`. | Class 1 analytical oracle | Detected | +| Remove both phase-index guards. | Options, skip path, and phase guards | Detected | +| Disable the Feature-array tuple-count validation. | Preflight input validation | Detected | +| Disable all three zero-vector guards. | Preflight input validation | Detected | + +## Exemplar archive + +The existing test archive contained output from an earlier run of this filter. It was a circular oracle and was retired for this filter. The replacement tests use inline Class 1 analytical data and Class 4 invariants. + +## Deviations from DREAM.3D 6.5.171 + +See `vv/deviations/ComputeSchmidsFilter.md` for the root cause, affected users, and recommendation for each deviation. + +| Deviation | Observed difference | +|---|---| +| `ComputeSchmidsFilter-D1` | DREAM.3D 6.5.171 and DREAM3D-NX through v7.4.1 used truncated cubic normalizers. The calculated Schmid factor and angle components were too large. | +| `ComputeSchmidsFilter-D2` | DREAM.3D 6.5.171 initialized uncomputed angle values to `-301`. DREAM3D-NX initializes these values to zero. | +| `ComputeSchmidsFilter-D3` | The orientation libraries did not initialize all Schmid output values. The filter could return undefined or previous Feature values. | +| `ComputeSchmidsFilter-D4` | The angle outputs use cosines on the automatic path and radians on the override path. The default output names and error messages also differ. | +| `ComputeSchmidsFilter-D5` | The override path returns an index from the library symmetry table. Different table orders can return different indices for the same physical result. | +| `ComputeSchmidsFilter-D6` | SIMPLNX rejects invalid phase values, malformed arrays, and zero direction vectors. DREAM.3D 6.5.171 accepted these inputs and could produce invalid output or invalid memory access. | diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json deleted file mode 100644 index b47c1dab5d..0000000000 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "0": { - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Read DREAM.3D Data File", - "Filter_Name": "DataContainerReader", - "Filter_Uuid": "{043cbde5-3878-5718-958f-ae75714df0df}", - "InputFile": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/caxis_ab_input.dream3d", - "OverwriteExistingDataContainers": false, - "InputFileDataContainerArrayProxy": { - "Data Containers": [ - { - "Attribute Matricies": [ - { - "Data Arrays": [ - {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellData", - "Type": 3 - }, - { - "Data Arrays": [ - {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellEnsembleData", - "Type": 11 - } - ], - "Flag": 2, - "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, - "Name": "TC1_Chain" - }, - { - "Attribute Matricies": [ - { - "Data Arrays": [ - {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellData", - "Type": 3 - }, - { - "Data Arrays": [ - {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellEnsembleData", - "Type": 11 - } - ], - "Flag": 2, - "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, - "Name": "TC2_PiFold" - }, - { - "Attribute Matricies": [ - { - "Data Arrays": [ - {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Mask", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellData", - "Type": 3 - }, - { - "Data Arrays": [ - {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellEnsembleData", - "Type": 11 - } - ], - "Flag": 2, - "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, - "Name": "TC3_Mask" - }, - { - "Attribute Matricies": [ - { - "Data Arrays": [ - {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellData", - "Type": 3 - }, - { - "Data Arrays": [ - {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellEnsembleData", - "Type": 11 - } - ], - "Flag": 2, - "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, - "Name": "TC4_Phase0" - }, - { - "Attribute Matricies": [ - { - "Data Arrays": [ - {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2}, - {"Component Dimensions": [1], "Flag": 2, "Name": "Mask", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellData", - "Type": 3 - }, - { - "Data Arrays": [ - {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} - ], - "Flag": 2, - "Name": "CellEnsembleData", - "Type": 11 - } - ], - "Flag": 2, - "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, - "Name": "TC5_3D" - } - ], - "Version": 6 - } - }, - "1": { - "ActiveArrayName": "Active", - "CellFeatureAttributeMatrixName": "CellFeatureData", - "CellPhasesArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, - "CrystalStructuresArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, - "FeatureIdsArrayName": "FeatureIds", - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", - "Filter_Name": "CAxisSegmentFeatures", - "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", - "GoodVoxelsArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, - "MisorientationTolerance": 10.0, - "QuatsArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, - "UseGoodVoxels": 0 - }, - "2": { - "ActiveArrayName": "Active", - "CellFeatureAttributeMatrixName": "CellFeatureData", - "CellPhasesArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, - "CrystalStructuresArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, - "FeatureIdsArrayName": "FeatureIds", - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", - "Filter_Name": "CAxisSegmentFeatures", - "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", - "GoodVoxelsArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, - "MisorientationTolerance": 10.0, - "QuatsArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, - "UseGoodVoxels": 0 - }, - "3": { - "ActiveArrayName": "Active", - "CellFeatureAttributeMatrixName": "CellFeatureData", - "CellPhasesArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, - "CrystalStructuresArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, - "FeatureIdsArrayName": "FeatureIds", - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", - "Filter_Name": "CAxisSegmentFeatures", - "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", - "GoodVoxelsArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, - "MisorientationTolerance": 10.0, - "QuatsArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, - "UseGoodVoxels": 1 - }, - "4": { - "ActiveArrayName": "Active", - "CellFeatureAttributeMatrixName": "CellFeatureData", - "CellPhasesArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, - "CrystalStructuresArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, - "FeatureIdsArrayName": "FeatureIds", - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", - "Filter_Name": "CAxisSegmentFeatures", - "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", - "GoodVoxelsArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, - "MisorientationTolerance": 10.0, - "QuatsArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, - "UseGoodVoxels": 0 - }, - "5": { - "ActiveArrayName": "Active", - "CellFeatureAttributeMatrixName": "CellFeatureData", - "CellPhasesArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, - "CrystalStructuresArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, - "FeatureIdsArrayName": "FeatureIds", - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", - "Filter_Name": "CAxisSegmentFeatures", - "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", - "GoodVoxelsArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, - "MisorientationTolerance": 10.0, - "QuatsArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, - "UseGoodVoxels": 1 - }, - "6": { - "FilterVersion": "6.5.171", - "Filter_Enabled": true, - "Filter_Human_Label": "Write DREAM.3D Data File", - "Filter_Name": "DataContainerWriter", - "Filter_Uuid": "{3fcd4c43-9d75-5b86-aad4-4441bc914f37}", - "OutputFile": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/legacy_out.dream3d", - "WriteTimeSeries": false, - "WriteXdmfFile": false - }, - "PipelineBuilder": { - "Name": "CAxisSegmentFeatures Legacy 6.5.171 A/B", - "Number_Filters": 7, - "Version": 6 - } -} diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py deleted file mode 100644 index 8da2d22baa..0000000000 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -"""Author the shared legacy-format input file for the CAxisSegmentFeatures A/B comparison. - -Builds four pure-Phi Bunge fixtures (the same Class 1 analytical fixtures encoded in -CAxisSegmentFeaturesTest.cpp) as separate DataContainers in one legacy v7 .dream3d file that -BOTH the DREAM3D 6.5.171 PipelineRunner and nxrunner read: - - TC1_Chain 8x1x1 Phi = [0, 5, 8, 45, 50, 120, 124, 90], tol 10 -> partition {0,1,2}{3,4}{5,6}{7} - TC2_PiFold 3x1x1 Phi = [2, 176, 88], tol 10 -> partition {0,1}{2} - TC3_Mask 5x1x1 Phi = [0, 20, 22, 0, 90], mask [0,1,1,0,1] -> {1,2}{4}, cells 0,3 -> id 0 - TC4_Phase0 4x1x1 Phi = [0,0,0,0], phases [0,1,1,1] -> {1,2,3}, cell 0 -> id 0 - TC5_3D 3x2x2 Phi = [0,50,55, 4,90,53, 8,95,130, 6,176,120] -> see below - mask [0,1,1,1,1,0, 1,1,1,1,1,1] - -TC5_3D exercises the y- and z-stride branches of the 3-D flood fill against legacy (TC1-TC4 are -all 1-D lines). With x-fastest linearization (idx = x + 3y + 6z) and tol 10 the expected partition -is: {1,2} via x; {3,6,9,10} spanning a z hop (3->9: |4-6|=2), a y hop (9->6: |6-8|=2), and a -pi-fold x hop (9->10: fold(6,176)=10); singletons {4} and {7}; {8,11} via a y hop at z=1 -(|130-120|=10). Masked cells 0 and 5 keep id 0 (cell 0 also pins the validated-first-seed path). -Expected FeatureIds (canonical NX labeling): [0,1,1,2,3,0,2,4,5,2,2,5] -> 5 features. - -A pure rotation about x by Phi tilts the c-axis by Phi in the sample y-z plane, so the c-axis -angular distance between two cells is exactly min(|dPhi|, 180 - |dPhi|). Quaternion storage is -{x, y, z, w} = {sin(Phi/2), 0, 0, cos(Phi/2)} for both SIMPL and NX. - -Usage: python3 make_input.py (writes ../results/caxis_ab_input.dream3d) -""" -import math -import os -import sys - -import numpy as np - -# The legacy_dream3d writer module ships with the compare-legacy-dream3d skill; point -# LEGACY_DREAM3D_SKILL_DIR at a checkout of it if yours lives elsewhere. -sys.path.insert(0, os.environ.get("LEGACY_DREAM3D_SKILL_DIR", "/Users/mjackson/Workspace1/Claude_Support/skills/compare-legacy-dream3d")) -from legacy_dream3d import D3DLegacyWriter # noqa: E402 - -HEX_HIGH = 0 # EbsdLib CrystalStructure enum value for Hexagonal_High -UNKNOWN = 999 # EbsdLib CrystalStructure "unknown" sentinel (ensemble 0) - - -def quat_from_phi_deg(phi_deg): - half = math.radians(phi_deg) * 0.5 - return [math.sin(half), 0.0, 0.0, math.cos(half)] - - -def add_case(w, dc_name, phis, phases=None, mask=None, num_ensembles=2, dims=None): - n = len(phis) - if dims is None: - dims = (n, 1, 1) # (x, y, z) - assert dims[0] * dims[1] * dims[2] == n - w.add_image_geom(dc_name, dims) - w.add_attribute_matrix(dc_name, "CellData", (dims[2], dims[1], dims[0]), "Cell") - quats = np.array([quat_from_phi_deg(p) for p in phis], dtype=np.float32) - w.add_data_array(dc_name, "CellData", "Quats", quats, comp_dims=(4,)) - if phases is None: - phases = [1] * n - w.add_data_array(dc_name, "CellData", "Phases", np.array(phases, dtype=np.int32)) - if mask is not None: - w.add_data_array(dc_name, "CellData", "Mask", np.array(mask, dtype=bool), object_type="DataArray") - w.add_attribute_matrix(dc_name, "CellEnsembleData", (num_ensembles,), "CellEnsemble") - structures = np.full(num_ensembles, HEX_HIGH, dtype=np.uint32) - structures[0] = UNKNOWN - w.add_data_array(dc_name, "CellEnsembleData", "CrystalStructures", structures) - - -def main(): - out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "results") - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.abspath(os.path.join(out_dir, "caxis_ab_input.dream3d")) - - with D3DLegacyWriter(out_path) as w: - add_case(w, "TC1_Chain", [0.0, 5.0, 8.0, 45.0, 50.0, 120.0, 124.0, 90.0]) - add_case(w, "TC2_PiFold", [2.0, 176.0, 88.0]) - add_case(w, "TC3_Mask", [0.0, 20.0, 22.0, 0.0, 90.0], mask=[0, 1, 1, 0, 1]) - add_case(w, "TC4_Phase0", [0.0, 0.0, 0.0, 0.0], phases=[0, 1, 1, 1]) - add_case(w, "TC5_3D", [0.0, 50.0, 55.0, 4.0, 90.0, 53.0, 8.0, 95.0, 130.0, 6.0, 176.0, 120.0], mask=[0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1], dims=(3, 2, 2)) - - print(f"wrote {out_path}") - - -if __name__ == "__main__": - main() diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline deleted file mode 100644 index db8279ab4c..0000000000 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline +++ /dev/null @@ -1,137 +0,0 @@ -{ - "isDisabled": false, - "name": "nx.d3dpipeline", - "pinnedParams": [], - "pipeline": [ - { - "args": { - "import_data_object": { - "value": { - "data_paths": [], - "file_path": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/caxis_ab_input.dream3d", - "path_import_policy": 0 - }, - "version": 2 - }, - "parameters_version": 1 - }, - "comments": "Read the SAME legacy-format input file the 6.5.171 pipeline reads.", - "filter": {"name": "nx::core::ReadDREAM3DFilter", "uuid": "0dbd31c7-19e0-4077-83ef-f4a6459a0e2d"}, - "isDisabled": false - }, - { - "args": { - "input_image_geometry_path": {"value": "TC1_Chain", "version": 1}, - "misorientation_tolerance": {"value": 10.0, "version": 1}, - "use_mask": {"value": false, "version": 1}, - "quats_array_path": {"value": "TC1_Chain/CellData/Quats", "version": 1}, - "cell_phases_array_path": {"value": "TC1_Chain/CellData/Phases", "version": 1}, - "mask_array_path": {"value": "", "version": 1}, - "crystal_structures_array_path": {"value": "TC1_Chain/CellEnsembleData/CrystalStructures", "version": 1}, - "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, - "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, - "active_array_name": {"value": "Active", "version": 1}, - "randomize_feature_ids": {"value": false, "version": 1}, - "neighbor_scheme_index": {"value": 0, "version": 1}, - "parameters_version": 2 - }, - "comments": "TC1 chain, face, no mask. Expected partition {0,1,2}{3,4}{5,6}{7}.", - "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, - "isDisabled": false - }, - { - "args": { - "input_image_geometry_path": {"value": "TC2_PiFold", "version": 1}, - "misorientation_tolerance": {"value": 10.0, "version": 1}, - "use_mask": {"value": false, "version": 1}, - "quats_array_path": {"value": "TC2_PiFold/CellData/Quats", "version": 1}, - "cell_phases_array_path": {"value": "TC2_PiFold/CellData/Phases", "version": 1}, - "mask_array_path": {"value": "", "version": 1}, - "crystal_structures_array_path": {"value": "TC2_PiFold/CellEnsembleData/CrystalStructures", "version": 1}, - "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, - "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, - "active_array_name": {"value": "Active", "version": 1}, - "randomize_feature_ids": {"value": false, "version": 1}, - "neighbor_scheme_index": {"value": 0, "version": 1}, - "parameters_version": 2 - }, - "comments": "TC2 pi-fold antiparallel c-axes. Expected partition {0,1}{2}.", - "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, - "isDisabled": false - }, - { - "args": { - "input_image_geometry_path": {"value": "TC3_Mask", "version": 1}, - "misorientation_tolerance": {"value": 10.0, "version": 1}, - "use_mask": {"value": true, "version": 1}, - "quats_array_path": {"value": "TC3_Mask/CellData/Quats", "version": 1}, - "cell_phases_array_path": {"value": "TC3_Mask/CellData/Phases", "version": 1}, - "mask_array_path": {"value": "TC3_Mask/CellData/Mask", "version": 1}, - "crystal_structures_array_path": {"value": "TC3_Mask/CellEnsembleData/CrystalStructures", "version": 1}, - "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, - "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, - "active_array_name": {"value": "Active", "version": 1}, - "randomize_feature_ids": {"value": false, "version": 1}, - "neighbor_scheme_index": {"value": 0, "version": 1}, - "parameters_version": 2 - }, - "comments": "TC3 bool mask excludes voxels 0 and 3. Expected {1,2}{4}, masked cells id 0.", - "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, - "isDisabled": false - }, - { - "args": { - "input_image_geometry_path": {"value": "TC4_Phase0", "version": 1}, - "misorientation_tolerance": {"value": 10.0, "version": 1}, - "use_mask": {"value": false, "version": 1}, - "quats_array_path": {"value": "TC4_Phase0/CellData/Quats", "version": 1}, - "cell_phases_array_path": {"value": "TC4_Phase0/CellData/Phases", "version": 1}, - "mask_array_path": {"value": "", "version": 1}, - "crystal_structures_array_path": {"value": "TC4_Phase0/CellEnsembleData/CrystalStructures", "version": 1}, - "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, - "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, - "active_array_name": {"value": "Active", "version": 1}, - "randomize_feature_ids": {"value": false, "version": 1}, - "neighbor_scheme_index": {"value": 0, "version": 1}, - "parameters_version": 2 - }, - "comments": "TC4 unindexed (phase 0) cell 0. Expected {1,2,3}, cell 0 id 0.", - "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, - "isDisabled": false - }, - { - "args": { - "input_image_geometry_path": {"value": "TC5_3D", "version": 1}, - "misorientation_tolerance": {"value": 10.0, "version": 1}, - "use_mask": {"value": true, "version": 1}, - "quats_array_path": {"value": "TC5_3D/CellData/Quats", "version": 1}, - "cell_phases_array_path": {"value": "TC5_3D/CellData/Phases", "version": 1}, - "mask_array_path": {"value": "TC5_3D/CellData/Mask", "version": 1}, - "crystal_structures_array_path": {"value": "TC5_3D/CellEnsembleData/CrystalStructures", "version": 1}, - "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, - "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, - "active_array_name": {"value": "Active", "version": 1}, - "randomize_feature_ids": {"value": false, "version": 1}, - "neighbor_scheme_index": {"value": 0, "version": 1}, - "parameters_version": 2 - }, - "comments": "TC5 3x2x2 (x-fastest), bool mask excludes voxels 0 and 5; features span y/z strides and a pi-fold x hop. Expected {1,2}{3,6,9,10}{4}{7}{8,11}, masked cells id 0.", - "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, - "isDisabled": false - }, - { - "args": { - "export_file_path": {"value": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/nx_out.dream3d", "version": 1}, - "write_xdmf_file": {"value": false, "version": 1}, - "use_compression": {"value": true, "version": 1}, - "compression_level": {"value": 5, "version": 1}, - "parameters_version": 2 - }, - "comments": "", - "filter": {"name": "nx::core::WriteDREAM3DFilter", "uuid": "b3a95784-2ced-41ec-8d3d-0242ac130003"}, - "isDisabled": false - } - ], - "version": 1, - "workflowParams": [] -} diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore deleted file mode 100644 index 90f47baddc..0000000000 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Regenerable A/B comparison outputs — reproduce via ../pipelines (see comparison.md) -*.dream3d -*.xdmf diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md deleted file mode 100644 index 8b4893f1e9..0000000000 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md +++ /dev/null @@ -1,65 +0,0 @@ -# Legacy Comparison: CAxisSegmentFeaturesFilter - -Date: 2026-07-22 (TC1–TC4); TC5_3D added and full suite rerun 2026-07-24 - -## Test Cases - -Five pure-Phi Bunge fixtures (TC1–TC4 identical to the Class 1 analytical fixtures in -`test/CAxisSegmentFeaturesTest.cpp`; TC5_3D exercises the y/z stride branches of the 3-D flood -fill, which the 1-D fixtures cannot), tolerance 10°, face neighbors: - -| Case | Grid | Phi per cell (deg) | Extras | Expected partition | -|---|---|---|---|---| -| TC1_Chain | 8×1×1 | 0, 5, 8, 45, 50, 120, 124, 90 | — | {0,1,2} {3,4} {5,6} {7} | -| TC2_PiFold | 3×1×1 | 2, 176, 88 | π-fold branch | {0,1} {2} | -| TC3_Mask | 5×1×1 | 0, 20, 22, 0, 90 | bool mask [0,1,1,0,1] | {1,2} {4}; cells 0,3 → id 0 | -| TC4_Phase0 | 4×1×1 | 0, 0, 0, 0 | phases [0,1,1,1] | {1,2,3}; cell 0 → id 0 | -| TC5_3D | 3×2×2 | 0, 50, 55, 4, 90, 53, 8, 95, 130, 6, 176, 120 | bool mask [0,1,1,1,1,0,1,1,1,1,1,1] | {1,2} {3,6,9,10} {4} {7} {8,11}; cells 0,5 → id 0 | - -TC5_3D uses x-fastest linearization (idx = x + 3y + 6z). Feature {3,6,9,10} spans a z-stride hop -(3→9: |4−6| = 2°), a y-stride hop (9→6: |6−8| = 2°), and a π-fold x hop (9→10: fold(6°,176°) = 10°); -{8,11} spans a y-stride hop at z = 1 (|130−120| = 10°). Masked voxel 0 additionally pins the -validated-first-seed (D1) path in 3-D against legacy. - -## Input Data - -One legacy v7 `.dream3d` file authored by `../pipelines/make_input.py` (legacy_dream3d writer), -read by BOTH runners, so the Quats/Phases/Mask/CrystalStructures inputs are byte-identical. - -## Runners - -- Legacy: `/Users/mjackson/Applications/DREAM3D.app/Contents/Bin/PipelineRunner` (official 6.5.171) -- NX: `/Users/mjackson/Workspace5/DREAM3D-Build/NX-Com-Qt69-Vtk95-Rel/Bin/nxrunner`, simplnx at the V&V commit (post D1/D4/D5 fixes) - -## Results - -Full-suite rerun 2026-07-24 (legacy FeatureIds differ run-to-run by construction, D2): - -| Case | Legacy FeatureIds | NX FeatureIds | Partition match | Feature count match | -|---|---|---|---|---| -| TC1_Chain | [1,1,1,4,4,2,2,3] | [1,1,1,2,2,3,3,4] | YES | YES (4+1 Active tuples) | -| TC2_PiFold | [1,1,2] | [1,1,2] | YES | YES (2+1) | -| TC3_Mask | [0,2,2,0,1] | [0,1,1,0,2] | YES | YES (2+1) | -| TC4_Phase0 | [0,1,1,1] | [0,1,1,1] | YES | YES (1+1) | -| TC5_3D | [0,4,4,2,1,0,2,5,3,2,2,3] | [0,1,1,2,3,0,2,4,5,2,2,5] | YES | YES (5+1) | - -**All five cases match at the segmentation-partition level, and feature counts are identical.** -Raw legacy ids are a random permutation of NX ids because 6.5.171 unconditionally randomizes -FeatureIds with a clock-derived seed (`CAxisSegmentFeaturesFilter-D2`); bit-identical FeatureIds are -not attainable against 6.5.171 by construction. NX ids additionally match the Class 1 oracle -expectation exactly (TC5_3D against an independently computed reference flood fill). - -## Fixes Applied - -None in this comparison round — SIMPLNX had already been reconciled against the Class 1/4 -oracle earlier in the V&V cycle, which fixed three SIMPLNX defects (D1 unvalidated first seed, -D4 spurious rejection of unindexed/masked cells, D5 RectGrid crash). No legacy patch: legacy -output is not wrong on any shared code path. - -## Notes - -- Legacy 6.5.171 cannot disable FeatureIds randomization (not exposed as a parameter), so the - comparison is partition-level by necessity. Partition equality is the meaningful invariant. -- NX-only behavior not comparable against 6.5.171: 26-neighbor "All Connected" scheme, RectGrid - geometry input, uint8 masks, crystal-structure validation (D3). -- Reproduce: `python3 ../pipelines/make_input.py`, then run both pipelines in `../pipelines/`. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeSchmidsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeSchmidsFilter.md new file mode 100644 index 0000000000..6f7141bc99 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeSchmidsFilter.md @@ -0,0 +1,168 @@ +# Deviations from DREAM3D 6.5.171: ComputeSchmidsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent. + +Entries are referenced by stable ID (`ComputeSchmidsFilter-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. + +--- + +## ComputeSchmidsFilter-D1 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D1` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** Every Schmid factor and both angle components produced by DREAM3D 6.5.171 on the default (auto) slip-system path are systematically too large by a fixed factor. The inflation is uniform, so it is invisible in any relative comparison and only shows up against an absolute reference — or against the physical bound. Concretely, for the maximizing loading direction of a cubic crystal 6.5.171 reports a Schmid factor of **0.500090176**, which is impossible: the Schmid factor cannot exceed 0.5. The reported `cos phi` and `cos lambda` can likewise exceed 1.0. + +The three inflation factors, per output array: + +| Array | 6.5.171 value / correct value | Exact expression | +|---|---|---| +| `SchmidPhis` (`Schmid_Phis`) | 1.0000293384785181 (+0.0029338 %) | `sqrt(3) / 1.732f` | +| `SchmidLambdas` (`Schmid_Lambdas`) | 1.0001510099261918 (+0.0151010 %) | `sqrt(2) / 1.414f` | +| `Schmids` | 1.0001803528351113 (+0.0180353 %) | `sqrt(6) / (1.732f * 1.414f)` | + +Measured against 6.5.171 on hand-built fixtures, the observed ratios matched these predictions to better than `1.1e-8` relative. + +`SlipSystems` and `Poles` are **not** affected. The inflation is a single positive scale factor applied to all twelve candidate slip systems, so the argmax cannot move; and `Poles` is a function of the crystal-frame loading direction, which the normalizers never touch. Both were bit-identical between 6.5.171 and SIMPLNX on every auto-path fixture. + +**Root cause:** Library. `OrientationLib/LaueOps/CubicOps.cpp:850-868` in 6.5.171 normalizes the `{111}` plane-normal dot products by the literal `1.732f` and the `<110>` slip-direction dot products by `1.414f`, instead of by `sqrt(3)` and `sqrt(2)`. Both literals are four-significant-digit truncations and both are smaller than the constants they stand in for, so every direction cosine comes out too large. EbsdLib carried the identical defect through version 3.1.0, which means **SIMPLNX built against EbsdLib <= 3.1.0 reproduces this deviation exactly** — this is a shared library defect, not a 6.5.171-only one. + +Fixed in EbsdLib 3.1.1 by replacing both literals with the full-precision `ebsdlib::constants::k_Sqrt3D` and `k_Sqrt2D`. The `double` type matches the surrounding arithmetic, which is entirely `double` — those two `float` literals were the only precision loss in the function. + +Note that the *override* slip-system path (`Override Default Slip System` = on) was never affected in either implementation: it normalizes the user-supplied plane and direction with `std::sqrt` and never used the truncated literals. The A/B run confirms this — override-path floats agree between the two builds to `1.2e-7` relative with no systematic bias, which is the control that isolates the normalizer as the sole cause of the auto-path divergence. + +**Affected users:** Everyone who has run Compute Schmid Factors, or legacy Find Schmid Factors, with `Override Default Slip System` off — that is, essentially every user of this filter. Absolute Schmid-factor values are wrong in the fourth decimal place. Relative comparisons *between* Features, rankings, histograms, and the reported slip system are all unaffected, so most downstream analyses reach the same conclusion. + +**Recommendation:** **Trust SIMPLNX**, built against EbsdLib >= 3.1.1. The corrected values match an exact-arithmetic oracle to `1e-6` on all twelve value-asserting Class 1 fixtures and satisfy the physical bound `0 <= m <= 0.5`, which the pre-fix values provably violate. + +No surgical patch to the legacy source was produced for this deviation, deliberately. The defective arithmetic lives in `OrientationLib`, not in `FindSchmids`, so a filter-level patch could not bring the two into alignment and a library-level patch would exceed the "smallest possible diff, one filter per patch" scope of the alignment-validation protocol. The alignment proof used instead is direct: fixed-EbsdLib SIMPLNX reproduces the exact-arithmetic oracle. + +--- + +## ComputeSchmidsFilter-D2 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D2` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** For Features the filter skips entirely — Feature 0, which is the conventional unassigned-Feature sentinel, and any Feature whose crystal-structure value is at or beyond `LaueGroupEnd` — the two angle-component arrays read **`-301.0`** in 6.5.171 and **`0.0`** in SIMPLNX. `Schmids`, `SlipSystems` and `Poles` read `0` in both. + +Valid Laue classes that do not enumerate slip systems are a different path. Their Schmid factor, slip-system index and angle components are zero after the D3 fix, but `Poles` is still computed from the transformed loading direction. + +**Root cause:** Bug in 6.5.171. `FindSchmids.cpp:234` and `:242` create the `SchmidPhis` and `SchmidLambdas` arrays with `createNonPrereqArrayFromPath, AbstractFilter, float>(this, tempPath, -301, cDims)`. The third argument of that template is the array's `initValue`, not an error code; `-301` is an error code that was pasted into the wrong parameter slot. The same call for `Schmids`, `SlipSystems` and `Poles` correctly passes `0`. Because the execute loop writes the angle components only inside `if(xtal < LaueGroupEnd)`, uncomputed rows keep the `-301` sentinel. + +SIMPLNX created all five arrays with `CreateArrayAction`'s default empty fill value, where 6.5.171 passed `initValue 0` for three of them. Preflight now passes an explicit `"0"` fill for all five arrays, and the algorithm additionally writes the Feature-0 row explicitly. + +**How much exposure that actually carried, measured rather than assumed.** The obvious reading — that without a fill the uncomputed rows hold indeterminate memory — does not hold for the default store. `CoreDataIOManager::addDataStoreFnc()` constructs every `DataStore` with `static_cast(0)` as its `initValue` irrespective of the action's `fillValue` string, and `DataStore`'s constructor unconditionally `std::fill_n`s the buffer with it (`DataStore.hpp:66-69`). Removing the explicit fill and re-running a 20 000-Feature fixture with same-size heap blocks poisoned to `0xAB` and freed immediately before the run still yielded all zeros; a standalone probe confirmed that a same-size raw allocation did return the poison. The zeros were therefore written by SIMPLNX rather than inherited from the allocator. This is a **latent port regression**: the explicit initialization requirement was genuinely dropped even though the tested store masked it. + +**Affected users:** Anyone reading Feature 0 or a Feature whose phase maps to an unknown or out-of-range crystal-structure value. Under 6.5.171 those skipped angle rows are a recognisable but undocumented `-301` sentinel. Under SIMPLNX they read `0` — before this cycle by way of the store factory's hard-coded zero rather than by the filter's own intent, and now by both. + +**Recommendation:** **Trust SIMPLNX.** Zero is the meaningful "not computed" value here and is consistent with what the other three output arrays already do for the same rows. Filter uncomputed Features by phase or by `Schmids == 0`, not by looking for `-301`. + +--- + +## ComputeSchmidsFilter-D3 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D3` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** Two classes of undefined output from the orientation library's Schmid-factor routines, present in both 6.5.171's `OrientationLib` and EbsdLib through 3.1.0. + +1. `HexagonalLowOps::getSchmidFactorAndSS(load, schmidfactor, angleComps, slipsys)` **read** `schmidfactor` before writing it. The function declares its locals and then makes `if(schmid1 > schmidfactor)` the first statement that touches `schmidfactor`, so the entire comparison chain was driven by an indeterminate value. `slipsys` was likewise never initialized. Depending on the garbage, a Hexagonal-Low Feature could get a Schmid factor of zero with an arbitrary slip-system index, or could accept a candidate against a garbage incumbent. + +2. `HexagonalOps` seeded `schmidfactor` but not `slipsys` or `angleComps`; and the seven Laue classes that enumerate no slip systems (`TrigonalOps`, `TrigonalLowOps`, `TetragonalOps`, `TetragonalLowOps`, `OrthoRhombicOps`, `MonoclinicOps`, `TriclinicOps`) set `schmidfactor` and `slipsys` but left `angleComps` **untouched**. + +Case 2 is the more damaging in practice because it is silent and produces plausible-looking output. A caller that hoists one `angleComps[2]` buffer outside its per-Feature loop — the natural way to write the loop, and what both `FindSchmids` and `ComputeSchmids` did — gets the **previous Feature's** angle components attributed to the current Feature whenever the current Feature's Laue class is one of the seven stubs. The Schmid factor correctly reads 0 while the two angle columns read as real measurements carried over from an unrelated Feature. + +**Root cause:** Bug, in the library. Fixed in EbsdLib 3.1.1 by seeding all four outputs at the top of every affected overload. That changes no value on any path that already computed a result; it only replaces indeterminate or stale output with a defined zero. `CubicOps` and `CubicLowOps` already wrote all four unconditionally and were left alone, as were all eleven plane/direction overloads, which already zeroed their outputs on entry. + +Independently of the library fix, SIMPLNX now declares `schmid`, `angleComps` and `slipSystem` **inside** the per-Feature loop, so the stale-carryover half of this defect cannot occur regardless of which EbsdLib the filter is linked against. + +One consequence of the fix, and three observations deliberately **not** changed, are recorded here so the decisions are on the record. + +**A new `slipsys = 0` "no candidate" sentinel for the hexagonal classes.** `HexagonalOps` and `HexagonalLowOps` assign `slipsys` values **1 through 6** when a candidate wins the strict `>`. Seeding `slipsys = 0` therefore introduces a value *outside* that documented range, and it is now the reportable signal that no candidate ever beat the seeded `schmidfactor = 0.0` — the hexagonal analogue of the override path's `slipsys = 0`. Callers reading a hexagonal `SlipSystems` value must treat `0` as "no slip system found", not as a slip-system number. Before the fix the same situation produced whatever the caller had passed in. Recorded in `docs/ComputeSchmidsFilter.md`. + +**Slip-system numbering base.** `HexagonalOps` and `HexagonalLowOps` number their reported slip systems from **1**, while `CubicOps` numbers from **0**. Changing either would break existing user data. Documented, unfixed. + +**Affected users:** Anyone whose data contains a Hexagonal-Low phase (defect 1), and anyone whose data mixes a Cubic or Hexagonal phase with any of Triclinic, Monoclinic, Orthorhombic, Tetragonal-Low/High or Trigonal-Low/High while `Store Angle Components of Schmid Factor` is on (defect 2). + +**Recommendation:** **Trust SIMPLNX** built against EbsdLib >= 3.1.1. For SIMPLNX built against EbsdLib <= 3.1.0, the per-iteration reinitialization added in this cycle already removes the stale-carryover exposure; the `HexagonalLowOps` uninitialized read requires the library fix. + +--- + +## ComputeSchmidsFilter-D4 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D4` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** Three interface-level differences that are stable across both implementations but easy to trip over. + +1. **The angle-component arrays change units with a boolean toggle.** With `Override Default Slip System` **off**, `Phis` and `Lambdas` hold the direction **cosines** `cos phi` and `cos lambda` — dimensionless, in `[0, 1]`. With it **on**, the same two arrays hold the **angles** `phi` and `lambda` themselves, in **radians**. The two code paths call different overloads: the auto path stores the raw dot products, the override path stores `std::acos` of them. Present identically in 6.5.171 and SIMPLNX. Consequence: with the toggle off the Schmid factor equals the product of the two stored values; with it on it equals the product of their cosines. SIMPLNX parameter descriptions now state these mode-dependent units explicitly. + +2. **The default angle-array names differ between versions.** 6.5.171 defaults to `SchmidPhis` and `SchmidLambdas`; SIMPLNX defaults to `Schmid_Phis` and `Schmid_Lambdas`. Pipelines converted from SIMPL carry their explicit names across correctly, so this bites only when comparing outputs or hand-writing a downstream path. + +3. **The preflight rejection differs.** A slip direction that does not lie in the slip plane is rejected at preflight by both. 6.5.171 reports `-1001` with `"Slip Plane and Slip Direction must be normal"`; SIMPLNX reports `-13500` and includes both vectors and their actual dot product so the user can correct the input. Verified on a dedicated negative fixture. + +**Root cause:** Algorithmic choice / order of operations, for (1); naming and error-code renumbering during the port for (2) and (3). + +**Affected users:** For (1), anyone who toggles `Override Default Slip System` and consumes `Phis`/`Lambdas` — the values silently change units and any downstream arithmetic that assumed cosines is off by a `cos`. For (2) and (3), anyone diffing outputs across versions or matching on error codes. + +**Recommendation:** **Either acceptable within tolerance** — the behaviour is the same in both versions and nothing is numerically wrong. The unit flip is documented in `docs/ComputeSchmidsFilter.md` with an explicit table, and unit tests assert both relationships (`m == Phis * Lambdas` on the auto path, `m == cos(Phis) * cos(Lambdas)` on the override path) so the distinction cannot silently regress. Convert explicitly rather than assuming a unit. + +--- + +## ComputeSchmidsFilter-D5 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D5` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** With `Override Default Slip System` **on**, the `SlipSystems` output disagrees between the two versions for the same input and the same physical answer. On the `(001)[100]` system under `[1,2,3]` loading, 6.5.171 reports **8** and SIMPLNX reports **3**, while the Schmid factor (`3/7` exactly), `Phis`, `Lambdas` and `Poles` all agree. + +This deviation was **not predicted** before the comparison ran. The pre-run prediction asserted `SlipSystems` would be identical on every fixture, reasoning that a uniform positive scale factor cannot move an argmax. That reasoning is correct and does hold on all five auto-path fixtures — the divergence has a different cause entirely, found by adjudicating the unexpected difference rather than by explaining it away. + +**Root cause:** Algorithmic choice, specifically an indexing convention. On the override path the reported value is not a slip-system number at all: it is the index of the **symmetry operator**, within that library's 24-element cubic symmetry table, that maps the user-supplied plane and direction onto the maximizing variant. Two things follow. + +- The two tables hold the same group in a **different order**. `OrientationLib`'s `CubicMatSym` and EbsdLib's `CubicHigh::k_MatSym` differ at **18 of their 24 positions**, but as *sets* they are identical: every one of EbsdLib's 24 matrices occurs exactly once in `OrientationLib`'s table, so there is a well-defined reordering permutation between them. +- **Six operators tie at the maximum, and they are the same six group elements in both libraries.** EbsdLib's tie set `{3, 6, 8, 9, 11, 14}` maps element-by-element through that permutation exactly onto `OrientationLib`'s `{8, 9, 10, 12, 14, 16}` — the pairing is `3->9`, `6->8`, `8->16`, `9->12`, `11->10`, `14->14`. Both implementations are therefore choosing among the *same* six maximizing operators, and each reports whichever member its own enumeration order reaches first under the strict `>`: EbsdLib reaches its index 3 first, `OrientationLib` reaches its index 8 first, and `OrientationLib`'s 8 *is* EbsdLib's 6. That is the whole mechanism — one tie set, two enumeration orders. Verified directly against both source tables in exact rational arithmetic. + +The two *reported* matrices happen also to be transposes of one another, but that is a coincidence of which member each order reaches first and carries no weight here: transposition does not preserve the Schmid factor when the slip plane and the slip direction differ, so it could not by itself explain the equal maximum. + +Neither answer is wrong. The value is a handle into a specific library's table, not a portable physical label, and it is arbitrary among equivalent maximizing operators. + +This is distinct from the auto path, where `SlipSystems` **is** a genuine slip-system index into the crystal structure's built-in list (0-11 for Cubic-High) and **was** bit-identical between the two versions on every fixture. + +**Affected users:** Anyone who uses `Override Default Slip System` and records, filters on, or compares the `SlipSystems` value — particularly anyone migrating stored results or scripts from DREAM3D 6.5.x. + +**Recommendation:** **Either acceptable** — both values identify a correct maximizing symmetry operator. Where a choice is needed, **trust SIMPLNX**, because the SIMPLNX index is the one addressable through EbsdLib's own `getMatSymOp()` accessor, so a user can retrieve the actual matrix the index refers to. Do not treat this value as portable across versions, and do not treat it as uniquely determined even within one version. `docs/ComputeSchmidsFilter.md` now states both caveats, and the unit test that asserts index 3 carries a comment recording that it pins EbsdLib's table order rather than a physical label. + +--- + +## ComputeSchmidsFilter-D6 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeSchmidsFilter-D6` | +| **Filter UUID** | `b4681855-0a3d-4237-97f2-5aec509115c4` | +| **Status** | active | + +**Symptom:** SIMPLNX now rejects malformed inputs before allocating or normalizing data: unequal tuple counts between `Feature Phases` and `Average Quaternions`, empty Feature or Ensemble arrays, a zero loading direction, and zero override slip-plane or slip-direction vectors. DREAM3D 6.5.171 accepts the zero-vector cases. In the independent A/B run, a zero loading direction produced `NaN` Schmid factors and angle components in 6.5.171, while zero override vectors silently produced all-zero Schmid outputs. Before this guard was added, SIMPLNX behaved the same way. A mismatched pair of Feature arrays was more severe in SIMPLNX: the algorithm indexed past both the shorter input and shorter output arrays, corrupting memory and causing `nxrunner` to exit with signal status 139 while writing the result. + +**Root cause:** Bug shared with legacy, plus an added SIMPLNX validation. Both implementations normalized vectors without first checking their magnitude and assumed the two Feature arrays had identical tuple counts. SIMPLNX preflight now returns `-13503` through `-13508` with the offending paths, tuple counts, or vector components. The parameter version is 2 because the parameter descriptions now state the non-zero-vector constraints and mode-dependent output semantics. + +**Affected users:** Pipelines with malformed Feature-array selections or zero direction vectors. Valid pipelines are unaffected. Invalid pipelines that previously completed can now fail preflight, but the previous outputs were undefined, non-finite, or the result of memory corruption. + +**Recommendation:** **Trust SIMPLNX.** Correct the selected array paths so `Feature Phases` and `Average Quaternions` have equal non-zero tuple counts, provide at least one Crystal Structures tuple, and use non-zero direction vectors. Do not consume the legacy zero-vector outputs. diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json index bcb0fc0334..e7bb6f8efe 100644 --- a/vcpkg-configuration.json +++ b/vcpkg-configuration.json @@ -8,7 +8,7 @@ { "kind": "git", "repository": "https://github.com/bluequartzsoftware/simplnx-registry", - "baseline": "8e5010ce1bac7cb2a2a8722944913e4a56884439", + "baseline": "7c57db23a5b5ed8eb3dd784a5649404ea9e19534", "packages": [ "benchmark", "blosc", diff --git a/vcpkg.json b/vcpkg.json index 9477e819d7..1487410660 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -83,7 +83,7 @@ "dependencies": [ { "name": "ebsdlib", - "version>=": "3.1.0" + "version>=": "3.1.2" } ] },