From f2b3646c43057673cb51f1899ee111287e306406 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 14:18:30 -0400 Subject: [PATCH 1/9] FILT: Add Compute C-Axis Reference Misalignments filter Add a filter that calculates the angle between the hexagonal c-axis of each orientation and a reference direction in the sample frame. Use the angle to find "hard" grains (c-axis near the loading direction) and "soft" grains (c-axis near perpendicular to it). * The input is a quaternion array with a Phases array of the same size and a Crystal Structures array. The filter works on cell Quats, feature AvgQuats, or other quaternion arrays. The output array is created next to the input array. * The filter accepts only Hexagonal_High and Hexagonal_Low phases. Other phases and masked tuples get NaN. A warning gives the number of tuples with a known non-hexagonal phase. Tuples with UnknownCrystalStructure do not add to this number. * Preflight rejects a reference direction that has zero length or a value that is not finite. Preflight also rejects quaternion, phase and mask arrays that have different tuple counts. * The filter reads quaternions, phases and mask values in chunks of a fixed size with bulk I/O. It does not allocate temporary arrays of the full size. * The tests compare the results with the closed-form c-axis for Bunge Euler angles (sin p1 sin P, -cos p1 sin P, cos P). The tests use fixed cases, references that are not unit vectors, and a sweep with a fixed random seed. The tests also cover masks, mixed phases, chunk boundaries and errors, with both in-memory store scenarios. Co-authored-by: nyoungbq Signed-off-by: Michael Jackson --- .../OrientationAnalysis/CMakeLists.txt | 2 + ...omputeCAxisReferenceMisalignmentsFilter.md | 67 ++++ .../ComputeCAxisReferenceMisalignments.cpp | 172 ++++++++ .../ComputeCAxisReferenceMisalignments.hpp | 78 ++++ ...mputeCAxisReferenceMisalignmentsFilter.cpp | 136 +++++++ ...mputeCAxisReferenceMisalignmentsFilter.hpp | 113 ++++++ .../OrientationAnalysis/test/CMakeLists.txt | 1 + ...ComputeCAxisReferenceMisalignmentsTest.cpp | 369 ++++++++++++++++++ 8 files changed, 938 insertions(+) create mode 100644 src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp create mode 100644 src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp create mode 100644 src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp create mode 100644 src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp create mode 100644 src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp diff --git a/src/Plugins/OrientationAnalysis/CMakeLists.txt b/src/Plugins/OrientationAnalysis/CMakeLists.txt index 0732e7d84d..e9ea6bc5c0 100644 --- a/src/Plugins/OrientationAnalysis/CMakeLists.txt +++ b/src/Plugins/OrientationAnalysis/CMakeLists.txt @@ -40,6 +40,7 @@ set(FilterList ComputeAvgOrientationsFilter ComputeBoundaryStrengthsFilter ComputeCAxisLocationsFilter + ComputeCAxisReferenceMisalignmentsFilter ComputeFaceIPFColoringFilter ComputeFeatureFaceMisorientationFilter ComputeFeatureNeighborCAxisMisalignmentsFilter @@ -182,6 +183,7 @@ set(filter_algorithms ComputeAvgOrientations ComputeBoundaryStrengths ComputeCAxisLocations + ComputeCAxisReferenceMisalignments ComputeFaceIPFColoring ComputeFeatureFaceMisorientation ComputeFeatureNeighborCAxisMisalignments diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md new file mode 100644 index 0000000000..bb7a882a6f --- /dev/null +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md @@ -0,0 +1,67 @@ +# Compute C-Axis Reference Misalignments + +## Group (Subgroup) + +Statistics (Crystallography) + +## Description + +Use this filter to separate **hard** and **soft** grains or cells in a hexagonal close-packed (HCP) material. A hard orientation has its crystal c-axis near parallel to the loading direction. A soft orientation has its c-axis near perpendicular to that direction. Set **Reference Direction** to the loading direction in the sample frame. The output gives the angle to that direction. This angle describes orientation relative to the load; it is not a full prediction of mechanical response. + +The filter works on cell orientations, feature average orientations, or any other quaternion array. A **feature** usually represents a grain. Select a Phases array at the same level, with one phase index for each orientation. No geometry or Feature Ids array is required. + +### Parameter Guidance + +**Input Quaternions** must contain four-component float32 quaternions in EbsdLib order: x, y, z, w. Use cell *Quats* for cell results. Use feature *AvgQuats*, for example from [Compute Feature Average Orientations](ComputeAvgOrientationsFilter.md), for grain results. Convert Euler angles with [Convert Orientation Representation](ConvertOrientationsFilter.md) before using this filter. + +**Reference Direction** has three dimensionless components in the sample frame. It need not be a unit vector. For example, *[3, 0, 3]* and *[1, 0, 1]* give the same result. All components must be finite. The vector length must be greater than 1e-12. + +Only **Hexagonal_High** (6/mmm) and **Hexagonal_Low** (6/m) phases are accepted. Cubic, trigonal, unknown, and other non-hexagonal phases receive NaN. One warning reports the number of tuples with known non-hexagonal structures; **UnknownCrystalStructure** (999) tuples do not contribute to this count or cause a warning. An unmasked phase index outside the Crystal Structures array is an error. Correct the Phases array or select the matching ensemble array. + +When **Use Mask Array** is enabled, false or zero mask values receive NaN. True or nonzero values are included. The mask must be a one-component bool or uint8 array with the same tuple count as **Input Quaternions** and Phases. + +The output is a one-component float32 array beside **Input Quaternions**. It keeps the input tuple shape. Valid angles are in **[0, 90] degrees**. An angle near 0 degrees means parallel. An angle near 90 degrees means perpendicular. The c-axis is antipodally symmetric: opposite c-axis directions represent the same axis. This folds angles greater than 90 degrees into the stated range. + +### Related Filters + +[Compute Feature Neighbor C-Axis Misalignments](ComputeFeatureNeighborCAxisMisalignmentsFilter.md) compares neighboring grains. [Compute Feature Reference C-Axis Misalignments](ComputeFeatureReferenceCAxisMisorientationsFilter.md) compares cell c-axes with their feature reference. This filter compares each selected quaternion with one user-supplied sample direction. + +## Algorithm + +Let g be the passive orientation matrix from the input quaternion. The sample-frame c-axis and normalized reference are + +$$ +\mathbf{c} = \frac{g^{T}(0,0,1)^{T}}{\left\|g^{T}(0,0,1)^{T}\right\|}, +\qquad +\hat{\mathbf{r}} = \frac{\mathbf{r}}{\|\mathbf{r}\|}. +$$ + +The output angle is + +$$ +\theta = \frac{180}{\pi}\cos^{-1}\!\left(\operatorname{clamp}\left(|\mathbf{c}\cdot\hat{\mathbf{r}}|,0,1\right)\right). +$$ + +The reference is normalized once. One traversal processes matching chunks of quaternions, phases, and mask values. Completed output chunks are written in bulk. The same implementation supports in-memory and out-of-core arrays. Temporary storage is bounded by a chunk plus the small Crystal Structures array. + +% Auto generated parameter table will be inserted here + +## Example Pipelines + +No example pipeline uses this filter yet. + +## References + +The following papers discuss HCP orientation effects and hard and soft grain combinations in titanium alloys: + +1. Dunne, F. P. E., Rugg, D., and Walker, A. (2007). Lengthscale-dependent, elastically anisotropic, physically-based HCP crystal plasticity: application to cold-dwell fatigue in Ti alloys. [DOI: 10.1016/j.ijplas.2006.10.013](https://doi.org/10.1016/j.ijplas.2006.10.013). +2. Dunne, F. P. E., and Rugg, D. (2008). On the mechanisms of fatigue facet nucleation in titanium alloys. [DOI: 10.1111/j.1460-2695.2008.01284.x](https://doi.org/10.1111/j.1460-2695.2008.01284.x). +3. Dunne, F. P. E., Walker, A., and Rugg, D. (2007). A systematic study of hcp crystal orientation and morphology effects in polycrystal deformation and fatigue. [DOI: 10.1098/rspa.2007.1833](https://doi.org/10.1098/rspa.2007.1833). + +## License & Copyright + +Please see the description file distributed with this **Plugin** + +## DREAM3D-NX Help + +If you need help, need to file a bug report or want to request a new feature, please head over to the [DREAM3DNX-Issues](https://github.com/BlueQuartzSoftware/DREAM3DNX-Issues/discussions) GitHub site where the community of DREAM3D-NX users can help answer your questions. diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp new file mode 100644 index 0000000000..ab0d57c7a9 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -0,0 +1,172 @@ +#include "ComputeCAxisReferenceMisalignments.hpp" + +#include "simplnx/Common/Numbers.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/Utilities/ThrottledMessageHandler.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace nx::core; + +namespace +{ +constexpr usize k_ChunkTuples = 4096; +constexpr std::string_view k_ProgressLabel = "Computing C-Axis Reference Misalignments"; +} // namespace + +ComputeCAxisReferenceMisalignments::ComputeCAxisReferenceMisalignments(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ComputeCAxisReferenceMisalignmentsInputValues* inputValues) +: m_DataStructure(dataStructure) +, m_MessageHandler(messageHandler) +, m_ShouldCancel(shouldCancel) +, m_InputValues(inputValues) +{ +} + +Result<> ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(const std::vector& referenceDirection) +{ + if(referenceDirection.size() != 3) + { + return MakeErrorResult(-98710, fmt::format("Reference Direction has {} components. Enter exactly three sample-frame components [X, Y, Z].", referenceDirection.size())); + } + const float64 norm = std::hypot(static_cast(referenceDirection[0]), static_cast(referenceDirection[1]), static_cast(referenceDirection[2])); + if(!std::isfinite(referenceDirection[0]) || !std::isfinite(referenceDirection[1]) || !std::isfinite(referenceDirection[2]) || norm <= k_MinReferenceDirectionNorm) + { + return MakeErrorResult(-98710, + fmt::format("Reference Direction [{}, {}, {}] must contain finite components and have length greater than {}. Enter a nonzero sample-frame direction, such as [0, 0, 1].", + referenceDirection[0], referenceDirection[1], referenceDirection[2], k_MinReferenceDirectionNorm)); + } + return {}; +} + +Result<> ComputeCAxisReferenceMisalignments::operator()() +{ + if(auto validation = ValidateReferenceDirection(m_InputValues->referenceDirection); validation.invalid()) + { + return validation; + } + const auto& reference = m_InputValues->referenceDirection; + Eigen::Vector3d referenceDirection(reference[0], reference[1], reference[2]); + referenceDirection.normalize(); + + Result<> result; + auto ioSucceeded = [&result](Result<> ioResult) { + result = MergeResults(std::move(result), std::move(ioResult)); + return result.valid(); + }; + + // Cache only the small ensemble array. All tuple-level buffers have a fixed bound. + const auto& crystalStructuresStore = m_DataStructure.getDataRefAs(m_InputValues->crystalStructuresArrayPath).getDataStoreRef(); + std::vector crystalStructures(crystalStructuresStore.getNumberOfTuples()); + if(!ioSucceeded(crystalStructuresStore.copyIntoBuffer(0, nonstd::span(crystalStructures.data(), crystalStructures.size())))) + { + return result; + } + + const auto& quatsStore = m_DataStructure.getDataRefAs(m_InputValues->quatsArrayPath).getDataStoreRef(); + const auto& phasesStore = m_DataStructure.getDataRefAs(m_InputValues->phasesArrayPath).getDataStoreRef(); + auto& outputStore = m_DataStructure.getDataRefAs(m_InputValues->outputArrayPath).getDataStoreRef(); + const usize total = quatsStore.getNumberOfTuples(); + + const BoolArray* boolMask = nullptr; + const UInt8Array* byteMask = nullptr; + if(m_InputValues->useMask) + { + const auto& mask = m_DataStructure.getDataRefAs(m_InputValues->maskArrayPath); + if(mask.getDataType() == DataType::boolean) + { + boolMask = &dynamic_cast(mask); + } + else + { + byteMask = &dynamic_cast(mask); + } + } + + std::vector quats(k_ChunkTuples * 4); + std::vector phases(k_ChunkTuples); + std::vector output(k_ChunkTuples); + std::vector byteMaskBuffer(byteMask != nullptr ? k_ChunkTuples : 0); + // Project-rule exception: std::vector is bit-packed and cannot provide the contiguous bool* that bulk copyIntoBuffer needs. + std::unique_ptr boolMaskBuffer = boolMask != nullptr ? std::make_unique(k_ChunkTuples) : nullptr; + + usize nonHexagonalCount = 0; + ThrottledMessageHandler progress(m_MessageHandler); + for(usize offset = 0; offset < total; offset += k_ChunkTuples) + { + if(m_ShouldCancel) + { + return result; + } + const usize count = std::min(k_ChunkTuples, total - offset); + if(!ioSucceeded(quatsStore.copyIntoBuffer(offset * 4, nonstd::span(quats.data(), count * 4))) || + !ioSucceeded(phasesStore.copyIntoBuffer(offset, nonstd::span(phases.data(), count)))) + { + return result; + } + if(boolMask != nullptr && !ioSucceeded(boolMask->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(boolMaskBuffer.get(), count)))) + { + return result; + } + if(byteMask != nullptr && !ioSucceeded(byteMask->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(byteMaskBuffer.data(), count)))) + { + return result; + } + + for(usize i = 0; i < count; ++i) + { + output[i] = std::numeric_limits::quiet_NaN(); + if((boolMask != nullptr && !boolMaskBuffer[i]) || (byteMask != nullptr && byteMaskBuffer[i] == 0)) + { + continue; + } + const int32 phase = phases[i]; + if(phase < 0 || static_cast(phase) >= crystalStructures.size()) + { + return MergeResults(std::move(result), MakeErrorResult(-98712, fmt::format("Phases array '{}' has value {} at tuple index {}, but Crystal Structures array '{}' has {} tuples. " + "Valid phase indices are in [0, {}). Correct this phase value or select the matching Crystal Structures array.", + m_InputValues->phasesArrayPath.toString(), phase, offset + i, m_InputValues->crystalStructuresArrayPath.toString(), + crystalStructures.size(), crystalStructures.size()))); + } + const uint32 crystalStructure = crystalStructures[phase]; + if(crystalStructure != ebsdlib::CrystalStructure::Hexagonal_High && crystalStructure != ebsdlib::CrystalStructure::Hexagonal_Low) + { + if(crystalStructure != ebsdlib::CrystalStructure::UnknownCrystalStructure) + { + ++nonHexagonalCount; + } + continue; + } + const usize qi = i * 4; + const ebsdlib::OrientationMatrixDType oMatrix = ebsdlib::QuaternionDType(quats[qi], quats[qi + 1], quats[qi + 2], quats[qi + 3]).toOrientationMatrix(); + // The transpose maps crystal [001] into the sample frame. Opposite c-axes represent the same axis. + Eigen::Vector3d cAxis = oMatrix.transpose() * Eigen::Vector3d(0.0, 0.0, 1.0); + cAxis.normalize(); + const float64 cosine = std::clamp(std::abs(cAxis.dot(referenceDirection)), 0.0, 1.0); + output[i] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); + } + if(!ioSucceeded(outputStore.copyFromBuffer(offset, nonstd::span(output.data(), count)))) + { + return result; + } + progress.updateCount(k_ProgressLabel, offset + count, total); + } + progress.flush(); + + if(nonHexagonalCount != 0) + { + result.warnings().push_back({-98713, fmt::format("{} tuples in '{}' have non-hexagonal phases and received NaN. Only Hexagonal_High (6/mmm) and Hexagonal_Low (6/m) are supported. " + "Select hexagonal tuples with the mask if these phases should be excluded.", + nonHexagonalCount, m_InputValues->quatsArrayPath.toString())}); + } + return result; +} diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp new file mode 100644 index 0000000000..0f39cc6a0a --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "OrientationAnalysis/OrientationAnalysis_export.hpp" + +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/Filter/IFilter.hpp" + +#include + +namespace nx::core +{ +/** + * @struct ComputeCAxisReferenceMisalignmentsInputValues + * @brief Identifies quaternion, phase, mask, and output arrays at any tuple level. + */ +struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsInputValues +{ + std::vector referenceDirection; + bool useMask = false; + DataPath quatsArrayPath; + DataPath phasesArrayPath; + DataPath maskArrayPath; + DataPath crystalStructuresArrayPath; + DataPath outputArrayPath; +}; + +/** + * @class ComputeCAxisReferenceMisalignments + * @brief Computes hexagonal c-axis angles to a sample-frame reference direction. + * + * One bounded bulk-I/O traversal serves resident and out-of-core stores. Masked + * and non-hexagonal tuples receive NaN. Antipodal symmetry folds angles to [0, 90] degrees. + */ +class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments +{ +public: + /** @brief Reference directions at or below this dimensionless norm are treated as zero. */ + static constexpr float64 k_MinReferenceDirectionNorm = 1.0e-12; + + /** + * @brief Initializes the executor. All arguments must outlive this instance. + * @param dataStructure Provides the selected arrays and pre-created output. + * @param messageHandler Receives progress updates. + * @param shouldCancel Signals cancellation at a chunk boundary. + * @param inputValues Identifies arrays and the reference direction. + */ + ComputeCAxisReferenceMisalignments(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ComputeCAxisReferenceMisalignmentsInputValues* inputValues); + /** @brief Destroys the executor. */ + ~ComputeCAxisReferenceMisalignments() noexcept = default; + + ComputeCAxisReferenceMisalignments(const ComputeCAxisReferenceMisalignments&) = delete; + ComputeCAxisReferenceMisalignments(ComputeCAxisReferenceMisalignments&&) noexcept = delete; + ComputeCAxisReferenceMisalignments& operator=(const ComputeCAxisReferenceMisalignments&) = delete; + ComputeCAxisReferenceMisalignments& operator=(ComputeCAxisReferenceMisalignments&&) noexcept = delete; + + /** + * @brief Validates the reference for both preflight and direct algorithm calls. + * @param referenceDirection Three finite sample-frame components. + * @return An error if the vector has an invalid size or an invalid or near-zero norm. + */ + static Result<> ValidateReferenceDirection(const std::vector& referenceDirection); + + /** + * @brief Writes one angle per quaternion tuple using checked bulk I/O. + * @return Errors for invalid references, phase indices, or I/O. A warning counts + * non-hexagonal tuples. Cancellation leaves only completed chunks written. + */ + Result<> operator()(); + +private: + DataStructure& m_DataStructure; + const IFilter::MessageHandler& m_MessageHandler; + const std::atomic_bool& m_ShouldCancel; + const ComputeCAxisReferenceMisalignmentsInputValues* m_InputValues = nullptr; +}; +} // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp new file mode 100644 index 0000000000..131814f461 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp @@ -0,0 +1,136 @@ +#include "ComputeCAxisReferenceMisalignmentsFilter.hpp" + +#include "OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp" + +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Parameters/ArraySelectionParameter.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" + +using namespace nx::core; + +namespace nx::core +{ +//------------------------------------------------------------------------------ +std::string ComputeCAxisReferenceMisalignmentsFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string ComputeCAxisReferenceMisalignmentsFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid ComputeCAxisReferenceMisalignmentsFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string ComputeCAxisReferenceMisalignmentsFilter::humanName() const +{ + return "Compute C-Axis Reference Misalignments"; +} + +//------------------------------------------------------------------------------ +std::vector ComputeCAxisReferenceMisalignmentsFilter::defaultTags() const +{ + return {className(), "Processing", "Crystallography", "C-Axis", "Misalignment", "Hexagonal"}; +} + +//------------------------------------------------------------------------------ +Parameters ComputeCAxisReferenceMisalignmentsFilter::parameters() const +{ + Parameters params; + + params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); + params.insert(std::make_unique(k_ReferenceDir_Key, "Reference Direction", "Sample-frame direction to compare with the crystal c-axis. Must be finite and nonzero.", + std::vector{0.0F, 0.0F, 1.0F}, std::vector{"X", "Y", "Z"})); + + params.insertSeparator(Parameters::Separator{"Optional Data Mask"}); + params.insertLinkableParameter(std::make_unique(k_UseMask_Key, "Use Mask Array", "Whether to assign NaN to tuples where the mask is false or zero", false)); + params.insert(std::make_unique(k_MaskArrayPath_Key, "Mask Array", "Boolean or uint8 array. True or nonzero tuples are included.", DataPath(), + ArraySelectionParameter::AllowedTypes{DataType::boolean, DataType::uint8}, ArraySelectionParameter::AllowedComponentShapes{{1}})); + // Associate the Linkable Parameter(s) to the children parameters that they control + params.linkParameters(k_UseMask_Key, k_MaskArrayPath_Key, true); + + params.insertSeparator(Parameters::Separator{"Input Data"}); + params.insert(std::make_unique(k_QuatsArrayPath_Key, "Input Quaternions (, w)", + "Quaternions in (, w) order. Select cell Quats, feature AvgQuats, or another quaternion array.", DataPath{}, + ArraySelectionParameter::AllowedTypes{DataType::float32}, ArraySelectionParameter::AllowedComponentShapes{{4}})); + params.insert(std::make_unique(k_PhasesArrayPath_Key, "Phases", "Ensemble index for each quaternion tuple. Must have the same tuple count as Input Quaternions.", DataPath{}, + ArraySelectionParameter::AllowedTypes{DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{1}})); + params.insertSeparator(Parameters::Separator{"Input Ensemble Data"}); + params.insert(std::make_unique(k_CrystalStructuresArrayPath_Key, "Crystal Structures", "Enumeration representing the crystal structure for each Ensemble", + DataPath({"Ensemble Data", "CrystalStructures"}), ArraySelectionParameter::AllowedTypes{DataType::uint32}, + ArraySelectionParameter::AllowedComponentShapes{{1}})); + + params.insertSeparator(Parameters::Separator{"Output Data"}); + params.insert(std::make_unique(k_CAxisReferenceMisalignmentsArrayName_Key, "C-Axis Reference Misalignments", + "Output angles in degrees, in [0, 90]. Created beside Input Quaternions.", "CAxisReferenceMisalignments")); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType ComputeCAxisReferenceMisalignmentsFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer ComputeCAxisReferenceMisalignmentsFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult ComputeCAxisReferenceMisalignmentsFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + + const auto referenceDirection = filterArgs.value(k_ReferenceDir_Key); + if(auto result = ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(referenceDirection); result.invalid()) + { + return {ConvertInvalidResult(std::move(result))}; + } + const auto quatsPath = filterArgs.value(k_QuatsArrayPath_Key); + const auto phasesPath = filterArgs.value(k_PhasesArrayPath_Key); + const auto outputPath = quatsPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); + std::vector paths = {quatsPath, phasesPath}; + if(filterArgs.value(k_UseMask_Key)) + { + paths.push_back(filterArgs.value(k_MaskArrayPath_Key)); + } + if(auto tupleCheck = dataStructure.validateNumberOfTuples(paths); !tupleCheck) + { + return {MakeErrorResult( + -98711, fmt::format("Input Quaternions, Phases, and the enabled Mask must have the same number of tuples. Select matching arrays at the same data level.\n{}", tupleCheck.error()))}; + } + const auto& quats = dataStructure.getDataRefAs(quatsPath); + OutputActions actions; + actions.appendAction(std::make_unique(DataType::float32, quats.getIDataStoreRef().getTupleShape(), std::vector{1}, outputPath)); + return {std::move(actions)}; +} + +//------------------------------------------------------------------------------ +Result<> ComputeCAxisReferenceMisalignmentsFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + ComputeCAxisReferenceMisalignmentsInputValues inputValues; + inputValues.referenceDirection = filterArgs.value(k_ReferenceDir_Key); + inputValues.useMask = filterArgs.value(k_UseMask_Key); + inputValues.quatsArrayPath = filterArgs.value(k_QuatsArrayPath_Key); + inputValues.phasesArrayPath = filterArgs.value(k_PhasesArrayPath_Key); + inputValues.maskArrayPath = filterArgs.value(k_MaskArrayPath_Key); + inputValues.crystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); + inputValues.outputArrayPath = inputValues.quatsArrayPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); + return ComputeCAxisReferenceMisalignments(dataStructure, messageHandler, shouldCancel, &inputValues)(); +} +} // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp new file mode 100644 index 0000000000..20f9b0f55f --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include "OrientationAnalysis/OrientationAnalysis_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class ComputeCAxisReferenceMisalignmentsFilter + * @brief Computes the antipodal angle between each hexagonal c-axis and a sample-frame reference direction. + */ +class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsFilter : public IFilter +{ +public: + /** @brief Constructs the filter with default parameters. */ + ComputeCAxisReferenceMisalignmentsFilter() = default; + /** @brief Destroys the filter. */ + ~ComputeCAxisReferenceMisalignmentsFilter() noexcept override = default; + + ComputeCAxisReferenceMisalignmentsFilter(const ComputeCAxisReferenceMisalignmentsFilter&) = delete; + ComputeCAxisReferenceMisalignmentsFilter(ComputeCAxisReferenceMisalignmentsFilter&&) noexcept = delete; + + ComputeCAxisReferenceMisalignmentsFilter& operator=(const ComputeCAxisReferenceMisalignmentsFilter&) = delete; + ComputeCAxisReferenceMisalignmentsFilter& operator=(ComputeCAxisReferenceMisalignmentsFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_ReferenceDir_Key = "reference_dir"; + static constexpr StringLiteral k_UseMask_Key = "use_mask"; + static constexpr StringLiteral k_MaskArrayPath_Key = "mask_array_path"; + static constexpr StringLiteral k_QuatsArrayPath_Key = "quats_array_path"; + static constexpr StringLiteral k_PhasesArrayPath_Key = "phases_array_path"; + static constexpr StringLiteral k_CrystalStructuresArrayPath_Key = "crystal_structures_array_path"; + static constexpr StringLiteral k_CAxisReferenceMisalignmentsArrayName_Key = "c_axis_reference_misalignments_array_name"; + + /** + * @brief Returns the name of the filter. + * @return Registered filter name. + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return C++ class name. + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return Unique filter identifier. + */ + Uuid uuid() const override; + + /** + * @brief Returns the human-readable name of the filter. + * @return Name shown in the user interface. + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return Search tags for crystallographic processing. + */ + std::vector defaultTags() const override; + + /** + * @brief Defines the parameters and mask linkage. + * @return Reference, mask, input selection, and output name parameters. + */ + Parameters parameters() const override; + + /** + * @brief Identifies the parameter schema version. + * @return Version 1. + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return A new filter instance. + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Validates the reference and tuple counts, then declares the sibling output. + * @param dataStructure Contains the selected arrays. + * @param filterArgs Supplies parameter values. + * @param messageHandler Receives filter messages. + * @param shouldCancel Signals cancellation. + * @param executionContext Supplies the execution environment. + * @return A float32 array creation action, or an error for invalid inputs. + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Runs the shared chunked algorithm after output creation. + * @param dataStructure Contains input arrays and the output array. + * @param filterArgs Supplies parameter values. + * @param pipelineNode Identifies this filter in the pipeline. + * @param messageHandler Receives progress updates. + * @param shouldCancel Signals cancellation at a chunk boundary. + * @param executionContext Supplies the execution environment. + * @return Algorithm errors and warnings. Errors or cancellation can leave partial output. + */ + Result<> executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; +}; +} // namespace nx::core + +SIMPLNX_DEF_FILTER_TRAITS(nx::core, ComputeCAxisReferenceMisalignmentsFilter, "5d320484-c6be-41b0-89df-a8218acd4cc1"); diff --git a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt index 3360d17f3d..28e1577566 100644 --- a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt +++ b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt @@ -15,6 +15,7 @@ set(${PLUGIN_NAME}UnitTest_SRCS ComputeAvgOrientationsTest.cpp ComputeBoundaryStrengthsTest.cpp ComputeCAxisLocationsTest.cpp + ComputeCAxisReferenceMisalignmentsTest.cpp ComputeFaceIPFColoringTest.cpp ComputeFeatureFaceMisorientationTest.cpp ComputeFeatureNeighborCAxisMisalignmentsTest.cpp diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp new file mode 100644 index 0000000000..67f66ea633 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -0,0 +1,369 @@ +#include "OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp" + +#include "simplnx/Common/Numbers.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace nx::core; + +namespace +{ +using Filter = ComputeCAxisReferenceMisalignmentsFilter; +using Euler = std::array; +constexpr double k_Radians = numbers::pi / 180.0; +constexpr float32 k_NaN = std::numeric_limits::quiet_NaN(); +const DataPath k_Quats({"Orientations", "Quats"}); +const DataPath k_Phases({"Orientations", "Phases"}); +const DataPath k_Mask({"Orientations", "Mask"}); +const DataPath k_CrystalStructures({"Ensembles", "CrystalStructures"}); +const DataPath k_Output({"Orientations", "CAxisReferenceMisalignments"}); + +template +DataArray& CreateArray(DataStructure& ds, const DataPath& path, const ShapeType& shape, usize components, DataObject::IdType parent) +{ + auto store = DataStoreUtilities::CreateDataStore(ds, path, shape, {components}); + auto* array = DataArray::Create(ds, path.getTargetName(), store, parent); + REQUIRE(array != nullptr); + return *array; +} + +DataStructure MakeData(const std::vector& eulers, const ShapeType& shape) +{ + DataStructure ds; + auto* am = AttributeMatrix::Create(ds, "Orientations", shape); + REQUIRE(am != nullptr); + auto& quats = CreateArray(ds, k_Quats, shape, 4, am->getId()); + auto& phases = CreateArray(ds, k_Phases, shape, 1, am->getId()); + REQUIRE(quats.getNumberOfTuples() == eulers.size()); + for(usize i = 0; i < eulers.size(); ++i) + { + const auto& eu = eulers[i]; + const auto quat = ebsdlib::EulerDType(eu[0] * k_Radians, eu[1] * k_Radians, eu[2] * k_Radians).toQuaternion(); + for(usize j = 0; j < 4; ++j) + { + quats[4 * i + j] = static_cast(quat[j]); + } + phases[i] = 1; + } + auto* ensembles = AttributeMatrix::Create(ds, "Ensembles", {5}); + REQUIRE(ensembles != nullptr); + auto& cs = CreateArray(ds, k_CrystalStructures, {5}, 1, ensembles->getId()); + cs[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + cs[1] = ebsdlib::CrystalStructure::Hexagonal_High; + cs[2] = ebsdlib::CrystalStructure::Hexagonal_Low; + cs[3] = ebsdlib::CrystalStructure::Cubic_High; + cs[4] = ebsdlib::CrystalStructure::Trigonal_High; + return ds; +} + +Arguments MakeArgs(const std::vector& reference = {0, 0, 1}, bool useMask = false) +{ + Arguments args; + args.insertOrAssign(Filter::k_ReferenceDir_Key, std::make_any(reference)); + args.insertOrAssign(Filter::k_UseMask_Key, std::make_any(useMask)); + args.insertOrAssign(Filter::k_MaskArrayPath_Key, std::make_any(k_Mask)); + args.insertOrAssign(Filter::k_QuatsArrayPath_Key, std::make_any(k_Quats)); + args.insertOrAssign(Filter::k_PhasesArrayPath_Key, std::make_any(k_Phases)); + args.insertOrAssign(Filter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructures)); + return args; +} + +// The textbook third row of the Bunge matrix is independent of quaternion conversion. +double ExpectedAngle(const Euler& eu, const std::vector& reference) +{ + const double phi1 = eu[0] * k_Radians; + const double phi = eu[1] * k_Radians; + const std::array c = {std::sin(phi1) * std::sin(phi), -std::cos(phi1) * std::sin(phi), std::cos(phi)}; + const double norm = std::hypot(static_cast(reference[0]), static_cast(reference[1]), static_cast(reference[2])); + const double dot = (c[0] * reference[0] + c[1] * reference[1] + c[2] * reference[2]) / norm; + return std::acos(std::clamp(std::abs(dot), 0.0, 1.0)) / k_Radians; +} + +void CheckOutput(const DataStructure& ds, const std::vector& eulers, const std::vector& reference, const std::vector& excluded = {}) +{ + REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + const auto& output = ds.getDataRefAs(k_Output); + REQUIRE(output.getNumberOfComponents() == 1); + REQUIRE(output.getIDataStoreRef().getTupleShape() == ds.getDataRefAs(k_Quats).getIDataStoreRef().getTupleShape()); + for(usize i = 0; i < eulers.size(); ++i) + { + CAPTURE(i); + if(std::find(excluded.begin(), excluded.end(), i) != excluded.end()) + { + REQUIRE(std::isnan(output[i])); + } + else + { + REQUIRE(std::isfinite(output[i])); + REQUIRE(output[i] >= 0.0F); + REQUIRE(output[i] <= 90.0F); + REQUIRE(output[i] == Approx(ExpectedAngle(eulers[i], reference)).margin(2.0e-4)); + } + } +} + +// The shared algorithm has no dispatch event. Use the scenario's store guard, +// but do not call executeFilter(), which requires a Direct/Scanline dispatch. +void ExecuteAndCheck(DataStructure& ds, const Arguments& args, const UnitTest::AlgorithmTestScope& scope) +{ + Filter filter; + auto preflight = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + auto result = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + REQUIRE(result.result.warnings().empty()); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); + scope.requireExpectedStore(ds.getDataRefAs(k_Output)); +} +} // namespace + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cell and feature analytical oracle", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool featureLevel = GENERATE(false, true); + const double azimuthOffset = GENERATE(0.0, 43.0, 172.0); + std::vector eulers = {{0, 0, 71}, {22, 30, 15}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + for(auto& eu : eulers) + { + eu[0] = std::fmod(eu[0] + azimuthOffset, 360.0); + eu[2] = std::fmod(eu[2] + 2.0 * azimuthOffset, 360.0); + } + auto ds = MakeData(eulers, featureLevel ? ShapeType{5} : ShapeType{1, 1, 5}); + ExecuteAndCheck(ds, MakeArgs(), scope); + CheckOutput(ds, eulers, {0, 0, 1}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonunit reference oracle", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const auto reference = GENERATE(std::vector{3, 0, 3}, std::vector{1, 2, -2}, std::vector{-1, -2, 2}); + const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + auto ds = MakeData(eulers, {5}); + ExecuteAndCheck(ds, MakeArgs(reference), scope); + CheckOutput(ds, eulers, reference); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: fixed seed sweep", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + std::mt19937 generator(1729); + std::uniform_real_distribution azimuth(0.0, 360.0); + std::uniform_real_distribution inclination(0.0, 180.0); + std::uniform_real_distribution direction(-10.0F, 10.0F); + std::vector eulers(384); + for(auto& eu : eulers) + { + eu = {azimuth(generator), inclination(generator), azimuth(generator)}; + } + for(usize run = 0; run < 8; ++run) + { + const std::vector reference = {direction(generator), direction(generator), direction(generator)}; + auto ds = MakeData(eulers, {8, 8, 6}); + ExecuteAndCheck(ds, MakeArgs(reference), scope); + CheckOutput(ds, eulers, reference); + UnitTest::CheckArraysInheritTupleDims(ds); + } +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masks and chunk boundaries", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool byteMask = GENERATE(false, true); + // Cross two 4096-tuple boundaries and exercise a partial final chunk. + std::vector eulers(8201); + for(usize i = 0; i < eulers.size(); ++i) + { + eulers[i] = {static_cast(i % 360), static_cast(i % 181), static_cast((i * 7) % 360)}; + } + auto ds = MakeData(eulers, {eulers.size()}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + const std::vector excluded = {0, 4095, 4096, 8192, 8200}; + if(byteMask) + { + auto& mask = CreateArray(ds, k_Mask, {eulers.size()}, 1, parent); + mask.fill(255); + for(auto i : excluded) + { + mask[i] = 0; + } + } + else + { + auto& mask = CreateArray(ds, k_Mask, {eulers.size()}, 1, parent); + mask.fill(true); + for(auto i : excluded) + { + mask[i] = false; + } + } + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + ds.getDataRefAs(k_Phases)[4096] = -1; // Masking takes precedence over phase validation. + ExecuteAndCheck(ds, MakeArgs({1, 2, -2}, true), scope); + CheckOutput(ds, eulers, {1, 2, -2}, excluded); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: hexagonal phases only", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const std::vector eulers(5, {23, 37, 89}); + auto ds = MakeData(eulers, {5}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + auto& phases = ds.getDataRefAs(k_Phases); + phases[2] = 2; + phases[3] = 0; + usize expectedNonHexagonalCount = 0; + std::vector excluded = {3}; + SECTION("Unknown crystal structure produces NaN without a warning") + { + } + SECTION("Cubic phase produces NaN and counts only the known non-hexagonal tuple") + { + phases[1] = 3; + expectedNonHexagonalCount = 1; + excluded.push_back(1); + } + SECTION("Cubic and trigonal phases count both known non-hexagonal tuples") + { + phases[1] = 3; + phases[4] = 4; + expectedNonHexagonalCount = 2; + excluded = {1, 3, 4}; + } + auto result = Filter().execute(ds, MakeArgs()); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + CheckOutput(ds, eulers, {0, 0, 1}, excluded); + if(expectedNonHexagonalCount == 0) + { + REQUIRE(result.result.warnings().empty()); + } + else + { + REQUIRE(result.result.warnings().size() == 1); + REQUIRE(result.result.warnings()[0].code == -98713); + REQUIRE(result.result.warnings()[0].message.find(std::to_string(expectedNonHexagonalCount) + " tuples") == 0); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: invalid reference", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const auto reference = + GENERATE(std::vector{0, 0, 0}, std::vector{k_NaN, 0, 1}, std::vector{0, std::numeric_limits::infinity(), 1}, std::vector{0, 0, 1.0e-13F}); + auto ds = MakeData({{0, 30, 0}}, {1}); + auto result = Filter().preflight(ds, MakeArgs(reference)); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors()[0].code == -98710); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: tuple mismatch", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool mismatchMask = GENERATE(false, true); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + auto* other = AttributeMatrix::Create(ds, "Other", {3}); + REQUIRE(other != nullptr); + const DataPath otherPath({"Other", "Mismatched"}); + auto args = MakeArgs({0, 0, 1}, mismatchMask); + if(mismatchMask) + { + CreateArray(ds, otherPath, {3}, 1, other->getId()); + args.insertOrAssign(Filter::k_MaskArrayPath_Key, std::make_any(otherPath)); + } + else + { + CreateArray(ds, otherPath, {3}, 1, other->getId()); + args.insertOrAssign(Filter::k_PhasesArrayPath_Key, std::make_any(otherPath)); + } + auto result = Filter().preflight(ds, args); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors()[0].code == -98711); + const auto& message = result.outputActions.errors()[0].message; + REQUIRE(message.find("Quats") != std::string::npos); + REQUIRE(message.find("Mismatched") != std::string::npos); + REQUIRE(message.find("2") != std::string::npos); + REQUIRE(message.find("3") != std::string::npos); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: phase index error", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const int32 phase = GENERATE(-1, 5, std::numeric_limits::max()); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + ds.getDataRefAs(k_Phases)[1] = phase; + auto result = Filter().execute(ds, MakeArgs()); + REQUIRE(result.result.invalid()); + REQUIRE(result.result.errors()[0].code == -98712); + const auto& message = result.result.errors()[0].message; + REQUIRE(message.find("Phases") != std::string::npos); + REQUIRE(message.find("CrystalStructures") != std::string::npos); + REQUIRE(message.find(std::to_string(phase)) != std::string::npos); + REQUIRE(message.find("tuple index 1") != std::string::npos); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: rejects Euler angle input", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + const DataPath eulersPath({"Orientations", "EulerAngles"}); + CreateArray(ds, eulersPath, {2}, 3, parent); + auto args = MakeArgs(); + args.insertOrAssign(Filter::k_QuatsArrayPath_Key, std::make_any(eulersPath)); + auto result = Filter().preflight(ds, args); + REQUIRE(result.outputActions.invalid()); + REQUIRE(ds.getDataAs(k_Output) == nullptr); + UnitTest::CheckArraysInheritTupleDims(ds); +} From 72416c8f3d5cb84899b46bfefb20747c9bb83ad3 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 14:49:32 -0400 Subject: [PATCH 2/9] BUG: Set NaN for zero and non-finite quaternions in C-Axis Reference Misalignments * A zero quaternion on an unmasked hexagonal tuple gave a c-axis of zero length. The normalized c-axis was not valid, and the filter wrote 90 degrees. The filter now writes NaN when the c-axis length is zero or not finite. * Add tests for zero quaternions at chunk boundaries, non-finite quaternion components, quaternion scale, empty input and cancellation. * The documentation now states that zero and non-finite quaternions get NaN. Signed-off-by: Michael Jackson --- ...omputeCAxisReferenceMisalignmentsFilter.md | 2 + .../ComputeCAxisReferenceMisalignments.cpp | 7 +- ...ComputeCAxisReferenceMisalignmentsTest.cpp | 112 ++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md index bb7a882a6f..716efeb50d 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md @@ -14,6 +14,8 @@ The filter works on cell orientations, feature average orientations, or any othe **Input Quaternions** must contain four-component float32 quaternions in EbsdLib order: x, y, z, w. Use cell *Quats* for cell results. Use feature *AvgQuats*, for example from [Compute Feature Average Orientations](ComputeAvgOrientationsFilter.md), for grain results. Convert Euler angles with [Convert Orientation Representation](ConvertOrientationsFilter.md) before using this filter. +A zero quaternion or a quaternion with a NaN or infinite component receives NaN. It does not define a valid orientation. Finite, nonzero quaternions need not be unit length because the computed c-axis is normalized. + **Reference Direction** has three dimensionless components in the sample frame. It need not be a unit vector. For example, *[3, 0, 3]* and *[1, 0, 1]* give the same result. All components must be finite. The vector length must be greater than 1e-12. Only **Hexagonal_High** (6/mmm) and **Hexagonal_Low** (6/m) phases are accepted. Cubic, trigonal, unknown, and other non-hexagonal phases receive NaN. One warning reports the number of tuples with known non-hexagonal structures; **UnknownCrystalStructure** (999) tuples do not contribute to this count or cause a warning. An unmasked phase index outside the Crystal Structures array is an error. Correct the Phases array or select the matching ensemble array. diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp index ab0d57c7a9..f43b00b889 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -150,7 +150,12 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() const ebsdlib::OrientationMatrixDType oMatrix = ebsdlib::QuaternionDType(quats[qi], quats[qi + 1], quats[qi + 2], quats[qi + 3]).toOrientationMatrix(); // The transpose maps crystal [001] into the sample frame. Opposite c-axes represent the same axis. Eigen::Vector3d cAxis = oMatrix.transpose() * Eigen::Vector3d(0.0, 0.0, 1.0); - cAxis.normalize(); + const float64 cAxisNorm = cAxis.norm(); + if(!std::isfinite(cAxisNorm) || cAxisNorm == 0.0) + { + continue; // An undefined orientation must not become a finite angle. + } + cAxis /= cAxisNorm; const float64 cosine = std::clamp(std::abs(cAxis.dot(referenceDirection)), 0.0, 1.0); output[i] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); } diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp index 67f66ea633..037d9be445 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -367,3 +368,114 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: rejects Eule REQUIRE(ds.getDataAs(k_Output) == nullptr); UnitTest::CheckArraysInheritTupleDims(ds); } + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: zero quaternion is undefined", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const usize count = GENERATE(1, 4096, 4097, 8192); + CAPTURE(count); + const std::vector eulers(count, {23, 37, 89}); + auto ds = MakeData(eulers, {count}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + auto& quats = ds.getDataRefAs(k_Quats); + for(usize component = 0; component < 4; ++component) + { + quats[4 * (count - 1) + component] = 0.0F; + } + ExecuteAndCheck(ds, MakeArgs(), scope); + // A zero quaternion defines no orientation, so it cannot have a finite angle. + CAPTURE(ds.getDataRefAs(k_Output)[count - 1]); + CheckOutput(ds, eulers, {0, 0, 1}, {count - 1}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonfinite quaternions", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const usize component = GENERATE(0, 1, 2, 3); + const float32 value = GENERATE(as{}, k_NaN, std::numeric_limits::infinity(), -std::numeric_limits::infinity()); + CAPTURE(component, value); + REQUIRE_FALSE(std::isfinite(value)); + const std::vector eulers(2, {23, 37, 89}); + auto ds = MakeData(eulers, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + ds.getDataRefAs(k_Quats)[component] = value; + ExecuteAndCheck(ds, MakeArgs(), scope); + CheckOutput(ds, eulers, {0, 0, 1}, {0}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: quaternion scale invariance", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const float32 scale = GENERATE(-2.0F, 0.5F, 1.0e-30F, 1.0e30F); + CAPTURE(scale); + const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + auto ds = MakeData(eulers, {eulers.size()}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + auto& quats = ds.getDataRefAs(k_Quats); + for(usize index = 0; index < quats.getSize(); ++index) + { + quats[index] *= scale; + } + ExecuteAndCheck(ds, MakeArgs({1, 2, -2}), scope); + CheckOutput(ds, eulers, {1, 2, -2}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: empty input", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + auto ds = MakeData({}, {0}); + ExecuteAndCheck(ds, MakeArgs(), scope); + CheckOutput(ds, {}, {0, 0, 1}); + REQUIRE(ds.getDataRefAs(k_Output).getNumberOfTuples() == 0); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cancellation", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool cancelBeforeExecute = GENERATE(false, true); + CAPTURE(cancelBeforeExecute); + std::atomic_bool shouldCancel = cancelBeforeExecute; + const std::vector eulers(4097, {23, 37, 89}); + auto ds = MakeData(eulers, {eulers.size()}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + // If cancellation is ignored at the next boundary, this produces -98712. + ds.getDataRefAs(k_Phases)[4096] = -1; + const IFilter::MessageHandler handler{[&shouldCancel](const IFilter::Message& message) { + if(message.type == IFilter::Message::Type::Progress) + { + shouldCancel = true; + } + }}; + auto result = Filter().execute(ds, MakeArgs(), nullptr, handler, shouldCancel); + SIMPLNX_RESULT_REQUIRE_INVALID(result.result); + REQUIRE(result.result.errors()[0].code == -1); + if(cancelBeforeExecute) + { + REQUIRE(ds.getDataAs(k_Output) == nullptr); + } + else + { + REQUIRE(shouldCancel.load()); + CheckOutput(ds, std::vector(4096, eulers.front()), {0, 0, 1}); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} From 1e83d5cbba8399afa93dc34a50d918ae3571c2eb Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 14:59:36 -0400 Subject: [PATCH 3/9] TEST: Check that masked non-hexagonal tuples do not add to the warning * Warning -98713 tells users to mask non-hexagonal tuples. No test checked that masked tuples with a non-hexagonal phase do not add to the warning count. A change that counted them passed all tests. * Add a test with bool and uint8 masks. All masked non-hexagonal tuples give no warning, and one unmasked cubic tuple gives a count of 1. Signed-off-by: Michael Jackson --- ...ComputeCAxisReferenceMisalignmentsTest.cpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp index 037d9be445..cc09d8a523 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -281,6 +281,63 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: hexagonal ph UnitTest::CheckArraysInheritTupleDims(ds); } +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masked non-hexagonal tuples are not counted", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool byteMask = GENERATE(false, true); + CAPTURE(byteMask); + const std::vector eulers(6, {23, 37, 89}); + auto ds = MakeData(eulers, {6}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + auto& phases = ds.getDataRefAs(k_Phases); + phases[1] = 3; + phases[2] = 4; + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + if(byteMask) + { + auto& mask = CreateArray(ds, k_Mask, {6}, 1, parent); + mask.fill(1); + mask[1] = 0; + mask[2] = 0; + } + else + { + auto& mask = CreateArray(ds, k_Mask, {6}, 1, parent); + mask.fill(true); + mask[1] = false; + mask[2] = false; + } + usize expectedNonHexagonalCount = 0; + std::vector excluded = {1, 2}; + SECTION("All non-hexagonal tuples masked") + { + } + SECTION("One unmasked cubic tuple is counted") + { + phases[4] = 3; + expectedNonHexagonalCount = 1; + excluded.push_back(4); + } + auto result = Filter().execute(ds, MakeArgs({0, 0, 1}, true)); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + CheckOutput(ds, eulers, {0, 0, 1}, excluded); + if(expectedNonHexagonalCount == 0) + { + REQUIRE(result.result.warnings().empty()); + } + else + { + REQUIRE(result.result.warnings().size() == 1); + REQUIRE(result.result.warnings()[0].code == -98713); + REQUIRE(result.result.warnings()[0].message.find("1 tuples") == 0); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: invalid reference", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { UnitTest::LoadPlugins(); From 58cdc1b0960b9a92b39de9c1580fc8afb0f75654 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 15:04:42 -0400 Subject: [PATCH 4/9] DOC: Update the ComputeCAxisReferenceMisalignments header comments * The class comment now states that zero and non-finite quaternions get NaN, and that quaternions need not be unit length. * The operator() comment now states that the warning counts only unmasked tuples with known non-hexagonal structures, and that UnknownCrystalStructure tuples do not add to the count. Signed-off-by: Michael Jackson --- .../ComputeCAxisReferenceMisalignments.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp index 0f39cc6a0a..7d29594712 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -29,8 +29,10 @@ struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsInputValues * @class ComputeCAxisReferenceMisalignments * @brief Computes hexagonal c-axis angles to a sample-frame reference direction. * - * One bounded bulk-I/O traversal serves resident and out-of-core stores. Masked - * and non-hexagonal tuples receive NaN. Antipodal symmetry folds angles to [0, 90] degrees. + * One bounded bulk-I/O traversal serves resident and out-of-core stores. + * Masked tuples, non-hexagonal tuples, and zero or nonfinite quaternions receive NaN. + * Finite, nonzero quaternions need not be unit length because the computed c-axis is normalized. + * Antipodal symmetry folds angles to [0, 90] degrees. */ class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments { @@ -64,8 +66,10 @@ class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments /** * @brief Writes one angle per quaternion tuple using checked bulk I/O. - * @return Errors for invalid references, phase indices, or I/O. A warning counts - * non-hexagonal tuples. Cancellation leaves only completed chunks written. + * @return Errors for invalid references, phase indices, or I/O. + * A warning counts only unmasked tuples with known non-hexagonal structures. + * UnknownCrystalStructure does not contribute to the count. + * Cancellation leaves only completed chunks written. */ Result<> operator()(); From 3f9127bb1bf8920feecf90ef5d374d9c3c946750 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 15:10:32 -0400 Subject: [PATCH 5/9] STY: Use the clang-tidy member style and correct the warning description * Rename the members of ComputeCAxisReferenceMisalignmentsInputValues to the CamelCase public-member style that .clang-tidy requires and that the other C-axis algorithms use. * The documentation now states that the warning counts only unmasked tuples with a known non-hexagonal structure. Split long sentences and use "Crystal Structures array" for the ensemble array. * The input-values comment now names all of its values and states which arrays share a tuple count. Signed-off-by: Michael Jackson --- ...omputeCAxisReferenceMisalignmentsFilter.md | 6 +++--- .../ComputeCAxisReferenceMisalignments.cpp | 20 +++++++++---------- .../ComputeCAxisReferenceMisalignments.hpp | 19 ++++++++++-------- ...mputeCAxisReferenceMisalignmentsFilter.cpp | 14 ++++++------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md index 716efeb50d..489fdd1940 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md @@ -6,7 +6,7 @@ Statistics (Crystallography) ## Description -Use this filter to separate **hard** and **soft** grains or cells in a hexagonal close-packed (HCP) material. A hard orientation has its crystal c-axis near parallel to the loading direction. A soft orientation has its c-axis near perpendicular to that direction. Set **Reference Direction** to the loading direction in the sample frame. The output gives the angle to that direction. This angle describes orientation relative to the load; it is not a full prediction of mechanical response. +Use this filter to separate **hard** and **soft** grains or cells in a hexagonal close-packed (HCP) material. A hard orientation has its crystal c-axis nearly parallel to the loading direction. A soft orientation has its c-axis nearly perpendicular to that direction. Set **Reference Direction** to the loading direction in the sample frame. The output gives the angle to that direction. This angle describes orientation relative to the load. It is not a full prediction of mechanical response. The filter works on cell orientations, feature average orientations, or any other quaternion array. A **feature** usually represents a grain. Select a Phases array at the same level, with one phase index for each orientation. No geometry or Feature Ids array is required. @@ -18,9 +18,9 @@ A zero quaternion or a quaternion with a NaN or infinite component receives NaN. **Reference Direction** has three dimensionless components in the sample frame. It need not be a unit vector. For example, *[3, 0, 3]* and *[1, 0, 1]* give the same result. All components must be finite. The vector length must be greater than 1e-12. -Only **Hexagonal_High** (6/mmm) and **Hexagonal_Low** (6/m) phases are accepted. Cubic, trigonal, unknown, and other non-hexagonal phases receive NaN. One warning reports the number of tuples with known non-hexagonal structures; **UnknownCrystalStructure** (999) tuples do not contribute to this count or cause a warning. An unmasked phase index outside the Crystal Structures array is an error. Correct the Phases array or select the matching ensemble array. +Only **Hexagonal_High** (6/mmm) and **Hexagonal_Low** (6/m) phases are accepted. Cubic, trigonal, unknown, and other non-hexagonal phases receive NaN. A warning gives the number of unmasked tuples that have a known non-hexagonal structure. **UnknownCrystalStructure** (999) tuples are not counted. They do not cause a warning. An unmasked phase index outside the Crystal Structures array is an error. Correct the Phases array or select the matching Crystal Structures array. -When **Use Mask Array** is enabled, false or zero mask values receive NaN. True or nonzero values are included. The mask must be a one-component bool or uint8 array with the same tuple count as **Input Quaternions** and Phases. +When **Use Mask Array** is enabled, tuples with a false or zero mask value receive NaN. Tuples with a true or nonzero mask value are included. The mask must be a one-component bool or uint8 array with the same tuple count as **Input Quaternions** and Phases. The output is a one-component float32 array beside **Input Quaternions**. It keeps the input tuple shape. Valid angles are in **[0, 90] degrees**. An angle near 0 degrees means parallel. An angle near 90 degrees means perpendicular. The c-axis is antipodally symmetric: opposite c-axis directions represent the same axis. This folds angles greater than 90 degrees into the stated range. diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp index f43b00b889..e728b9b31d 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -50,11 +50,11 @@ Result<> ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(const st Result<> ComputeCAxisReferenceMisalignments::operator()() { - if(auto validation = ValidateReferenceDirection(m_InputValues->referenceDirection); validation.invalid()) + if(auto validation = ValidateReferenceDirection(m_InputValues->ReferenceDirection); validation.invalid()) { return validation; } - const auto& reference = m_InputValues->referenceDirection; + const auto& reference = m_InputValues->ReferenceDirection; Eigen::Vector3d referenceDirection(reference[0], reference[1], reference[2]); referenceDirection.normalize(); @@ -65,23 +65,23 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() }; // Cache only the small ensemble array. All tuple-level buffers have a fixed bound. - const auto& crystalStructuresStore = m_DataStructure.getDataRefAs(m_InputValues->crystalStructuresArrayPath).getDataStoreRef(); + const auto& crystalStructuresStore = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath).getDataStoreRef(); std::vector crystalStructures(crystalStructuresStore.getNumberOfTuples()); if(!ioSucceeded(crystalStructuresStore.copyIntoBuffer(0, nonstd::span(crystalStructures.data(), crystalStructures.size())))) { return result; } - const auto& quatsStore = m_DataStructure.getDataRefAs(m_InputValues->quatsArrayPath).getDataStoreRef(); - const auto& phasesStore = m_DataStructure.getDataRefAs(m_InputValues->phasesArrayPath).getDataStoreRef(); - auto& outputStore = m_DataStructure.getDataRefAs(m_InputValues->outputArrayPath).getDataStoreRef(); + const auto& quatsStore = m_DataStructure.getDataRefAs(m_InputValues->QuatsArrayPath).getDataStoreRef(); + const auto& phasesStore = m_DataStructure.getDataRefAs(m_InputValues->PhasesArrayPath).getDataStoreRef(); + auto& outputStore = m_DataStructure.getDataRefAs(m_InputValues->OutputArrayPath).getDataStoreRef(); const usize total = quatsStore.getNumberOfTuples(); const BoolArray* boolMask = nullptr; const UInt8Array* byteMask = nullptr; - if(m_InputValues->useMask) + if(m_InputValues->UseMask) { - const auto& mask = m_DataStructure.getDataRefAs(m_InputValues->maskArrayPath); + const auto& mask = m_DataStructure.getDataRefAs(m_InputValues->MaskArrayPath); if(mask.getDataType() == DataType::boolean) { boolMask = &dynamic_cast(mask); @@ -134,7 +134,7 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() { return MergeResults(std::move(result), MakeErrorResult(-98712, fmt::format("Phases array '{}' has value {} at tuple index {}, but Crystal Structures array '{}' has {} tuples. " "Valid phase indices are in [0, {}). Correct this phase value or select the matching Crystal Structures array.", - m_InputValues->phasesArrayPath.toString(), phase, offset + i, m_InputValues->crystalStructuresArrayPath.toString(), + m_InputValues->PhasesArrayPath.toString(), phase, offset + i, m_InputValues->CrystalStructuresArrayPath.toString(), crystalStructures.size(), crystalStructures.size()))); } const uint32 crystalStructure = crystalStructures[phase]; @@ -171,7 +171,7 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() { result.warnings().push_back({-98713, fmt::format("{} tuples in '{}' have non-hexagonal phases and received NaN. Only Hexagonal_High (6/mmm) and Hexagonal_Low (6/m) are supported. " "Select hexagonal tuples with the mask if these phases should be excluded.", - nonHexagonalCount, m_InputValues->quatsArrayPath.toString())}); + nonHexagonalCount, m_InputValues->QuatsArrayPath.toString())}); } return result; } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp index 7d29594712..592c018259 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -12,17 +12,20 @@ namespace nx::core { /** * @struct ComputeCAxisReferenceMisalignmentsInputValues - * @brief Identifies quaternion, phase, mask, and output arrays at any tuple level. + * @brief Holds the reference direction, the mask switch, and the array paths. + * + * Quaternions, Phases, Mask, and output share one tuple count at any level. + * Crystal Structures is the ensemble array that Phases indexes. */ struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsInputValues { - std::vector referenceDirection; - bool useMask = false; - DataPath quatsArrayPath; - DataPath phasesArrayPath; - DataPath maskArrayPath; - DataPath crystalStructuresArrayPath; - DataPath outputArrayPath; + std::vector ReferenceDirection; + bool UseMask = false; + DataPath QuatsArrayPath; + DataPath PhasesArrayPath; + DataPath MaskArrayPath; + DataPath CrystalStructuresArrayPath; + DataPath OutputArrayPath; }; /** diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp index 131814f461..03ee3f7cff 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp @@ -124,13 +124,13 @@ Result<> ComputeCAxisReferenceMisalignmentsFilter::executeImpl(DataStructure& da const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const { ComputeCAxisReferenceMisalignmentsInputValues inputValues; - inputValues.referenceDirection = filterArgs.value(k_ReferenceDir_Key); - inputValues.useMask = filterArgs.value(k_UseMask_Key); - inputValues.quatsArrayPath = filterArgs.value(k_QuatsArrayPath_Key); - inputValues.phasesArrayPath = filterArgs.value(k_PhasesArrayPath_Key); - inputValues.maskArrayPath = filterArgs.value(k_MaskArrayPath_Key); - inputValues.crystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); - inputValues.outputArrayPath = inputValues.quatsArrayPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); + inputValues.ReferenceDirection = filterArgs.value(k_ReferenceDir_Key); + inputValues.UseMask = filterArgs.value(k_UseMask_Key); + inputValues.QuatsArrayPath = filterArgs.value(k_QuatsArrayPath_Key); + inputValues.PhasesArrayPath = filterArgs.value(k_PhasesArrayPath_Key); + inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); + inputValues.CrystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); + inputValues.OutputArrayPath = inputValues.QuatsArrayPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); return ComputeCAxisReferenceMisalignments(dataStructure, messageHandler, shouldCancel, &inputValues)(); } } // namespace nx::core From 621e0631d68d5b46e6b62008a1292ccc843780c7 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 16:10:50 -0400 Subject: [PATCH 6/9] TEST: Run Compute C-Axis Reference Misalignments on out-of-core stores * Add a test that forces out-of-core storage before it creates the arrays. In a DREAM3D-NX out-of-core build, the test requires that the quaternion, phase, crystal structure, mask and output stores use the HDF5-OOC format. In other builds the format check is skipped. * The test uses 12,305 tuples (three full chunks and a partial chunk), bool and uint8 masks, mixed phases and a zero quaternion. It compares the results with the closed-form Euler c-axis and checks the warning count. * The test helpers now write the inputs and read the output with one bulk call for each array. Per-element access on out-of-core stores made the setup take 91 seconds. The filter itself takes 5 ms. Signed-off-by: Michael Jackson --- ...ComputeCAxisReferenceMisalignmentsTest.cpp | 130 ++++++++++++++++-- 1 file changed, 117 insertions(+), 13 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp index cc09d8a523..d0ad8c4ddb 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -1,6 +1,7 @@ #include "OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp" #include "simplnx/Common/Numbers.hpp" +#include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/Parameters/VectorParameter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -43,7 +45,7 @@ DataArray& CreateArray(DataStructure& ds, const DataPath& path, const ShapeTy return *array; } -DataStructure MakeData(const std::vector& eulers, const ShapeType& shape) +DataStructure MakeData(const std::vector& eulers, const ShapeType& shape, std::vector phaseValues = {}, usize zeroQuaternion = std::numeric_limits::max()) { DataStructure ds; auto* am = AttributeMatrix::Create(ds, "Orientations", shape); @@ -51,24 +53,32 @@ DataStructure MakeData(const std::vector& eulers, const ShapeType& shape) auto& quats = CreateArray(ds, k_Quats, shape, 4, am->getId()); auto& phases = CreateArray(ds, k_Phases, shape, 1, am->getId()); REQUIRE(quats.getNumberOfTuples() == eulers.size()); + std::vector quatValues(eulers.size() * 4); + if(phaseValues.empty()) + { + phaseValues.assign(eulers.size(), 1); + } + REQUIRE(phaseValues.size() == eulers.size()); for(usize i = 0; i < eulers.size(); ++i) { const auto& eu = eulers[i]; const auto quat = ebsdlib::EulerDType(eu[0] * k_Radians, eu[1] * k_Radians, eu[2] * k_Radians).toQuaternion(); for(usize j = 0; j < 4; ++j) { - quats[4 * i + j] = static_cast(quat[j]); + quatValues[4 * i + j] = i == zeroQuaternion ? 0.0F : static_cast(quat[j]); } - phases[i] = 1; } + auto quatsWriteResult = quats.getDataStoreRef().copyFromBuffer(0, nonstd::span(quatValues.data(), quatValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(quatsWriteResult); + auto phasesWriteResult = phases.getDataStoreRef().copyFromBuffer(0, nonstd::span(phaseValues.data(), phaseValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(phasesWriteResult); auto* ensembles = AttributeMatrix::Create(ds, "Ensembles", {5}); REQUIRE(ensembles != nullptr); auto& cs = CreateArray(ds, k_CrystalStructures, {5}, 1, ensembles->getId()); - cs[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; - cs[1] = ebsdlib::CrystalStructure::Hexagonal_High; - cs[2] = ebsdlib::CrystalStructure::Hexagonal_Low; - cs[3] = ebsdlib::CrystalStructure::Cubic_High; - cs[4] = ebsdlib::CrystalStructure::Trigonal_High; + const std::vector crystalValues = {ebsdlib::CrystalStructure::UnknownCrystalStructure, ebsdlib::CrystalStructure::Hexagonal_High, ebsdlib::CrystalStructure::Hexagonal_Low, + ebsdlib::CrystalStructure::Cubic_High, ebsdlib::CrystalStructure::Trigonal_High}; + auto crystalWriteResult = cs.getDataStoreRef().copyFromBuffer(0, nonstd::span(crystalValues.data(), crystalValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(crystalWriteResult); return ds; } @@ -102,19 +112,22 @@ void CheckOutput(const DataStructure& ds, const std::vector& eulers, cons const auto& output = ds.getDataRefAs(k_Output); REQUIRE(output.getNumberOfComponents() == 1); REQUIRE(output.getIDataStoreRef().getTupleShape() == ds.getDataRefAs(k_Quats).getIDataStoreRef().getTupleShape()); + std::vector outputValues(output.getSize()); + auto outputReadResult = output.getDataStoreRef().copyIntoBuffer(0, nonstd::span(outputValues.data(), outputValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(outputReadResult); for(usize i = 0; i < eulers.size(); ++i) { CAPTURE(i); if(std::find(excluded.begin(), excluded.end(), i) != excluded.end()) { - REQUIRE(std::isnan(output[i])); + REQUIRE(std::isnan(outputValues[i])); } else { - REQUIRE(std::isfinite(output[i])); - REQUIRE(output[i] >= 0.0F); - REQUIRE(output[i] <= 90.0F); - REQUIRE(output[i] == Approx(ExpectedAngle(eulers[i], reference)).margin(2.0e-4)); + REQUIRE(std::isfinite(outputValues[i])); + REQUIRE(outputValues[i] >= 0.0F); + REQUIRE(outputValues[i] <= 90.0F); + REQUIRE(outputValues[i] == Approx(ExpectedAngle(eulers[i], reference)).margin(2.0e-4)); } } } @@ -235,6 +248,97 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masks and ch UnitTest::CheckArraysInheritTupleDims(ds); } +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: out-of-core stores", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + UnitTest::LoadPlugins(); + const bool byteMask = GENERATE(false, true); + CAPTURE(byteMask); + const UnitTest::PreferencesSentinel preferencesSentinel(DataStorageMode::ForceOutOfCore, 1); + + // Cross three 4096-tuple boundaries and exercise a partial final chunk. + constexpr usize k_NumTuples = 3 * 4096 + 17; + constexpr usize k_ZeroQuaternion = 3 * 4096 + 1; // Unmasked hexagonal tuple in the final chunk. + std::vector eulers(k_NumTuples); + for(usize i = 0; i < eulers.size(); ++i) + { + eulers[i] = {static_cast(i % 360), static_cast(i % 181), static_cast((i * 7) % 360)}; + } + std::vector phaseValues(k_NumTuples); + usize expectedNonHexagonalCount = 0; + std::vector excluded; + for(usize i = 0; i < k_NumTuples; ++i) + { + // Alternate unknown, hexagonal high, hexagonal low, and cubic phases. + phaseValues[i] = static_cast(i % 4); + if(i % 11 != 0 && i % 4 == 3) + { + ++expectedNonHexagonalCount; + } + if(i % 11 == 0 || i % 4 == 0 || i % 4 == 3 || i == k_ZeroQuaternion) + { + excluded.push_back(i); + } + } + auto ds = MakeData(eulers, {k_NumTuples}, phaseValues, k_ZeroQuaternion); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + REQUIRE_NOTHROW(ds.getDataRefAs(k_CrystalStructures)); + const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + auto& quats = ds.getDataRefAs(k_Quats); + auto& phases = ds.getDataRefAs(k_Phases); + const auto& crystalStructures = ds.getDataRefAs(k_CrystalStructures); + if(byteMask) + { + auto& mask = CreateArray(ds, k_Mask, {k_NumTuples}, 1, parent); + std::vector maskValues(k_NumTuples); + for(usize i = 0; i < k_NumTuples; ++i) + { + maskValues[i] = i % 11 == 0 ? 0 : 255; + } + auto maskWriteResult = mask.getDataStoreRef().copyFromBuffer(0, nonstd::span(maskValues.data(), maskValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(maskWriteResult); + } + else + { + auto& mask = CreateArray(ds, k_Mask, {k_NumTuples}, 1, parent); + // Project-rule exception: std::vector cannot provide the contiguous bool* required for bulk I/O. + std::unique_ptr maskValues = std::make_unique(k_NumTuples); + for(usize i = 0; i < k_NumTuples; ++i) + { + maskValues[i] = i % 11 != 0; + } + auto maskWriteResult = mask.getDataStoreRef().copyFromBuffer(0, nonstd::span(maskValues.get(), k_NumTuples)); + SIMPLNX_RESULT_REQUIRE_VALID(maskWriteResult); + } + REQUIRE_NOTHROW(ds.getDataRefAs(k_Mask)); + if(Application::Instance()->getIOManager("HDF5-OOC") != nullptr) + { + REQUIRE(quats.getDataStoreRef().getDataFormat() == "HDF5-OOC"); + REQUIRE(phases.getDataStoreRef().getDataFormat() == "HDF5-OOC"); + REQUIRE(crystalStructures.getDataStoreRef().getDataFormat() == "HDF5-OOC"); + REQUIRE(ds.getDataRefAs(k_Mask).getIDataStoreRef().getDataFormat() == "HDF5-OOC"); + } + + const std::vector reference = {1, 2, -2}; + const auto args = MakeArgs(reference, true); + Filter filter; + auto preflight = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + auto result = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + REQUIRE(result.result.warnings().size() == 1); + REQUIRE(result.result.warnings()[0].code == -98713); + REQUIRE(result.result.warnings()[0].message.find(std::to_string(expectedNonHexagonalCount) + " tuples") == 0); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); + if(Application::Instance()->getIOManager("HDF5-OOC") != nullptr) + { + REQUIRE(ds.getDataRefAs(k_Output).getDataStoreRef().getDataFormat() == "HDF5-OOC"); + } + CheckOutput(ds, eulers, reference, excluded); + UnitTest::CheckArraysInheritTupleDims(ds); +} + TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: hexagonal phases only", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { UnitTest::LoadPlugins(); From f1fb6b86e14c0578ee6e841a4fdb52fe19c4f97e Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 16:21:41 -0400 Subject: [PATCH 7/9] TEST: Vary the phase pattern between chunks in the out-of-core test * The out-of-core test set phases with a period of 4. Because 4 divides the 4096-tuple chunk size, every chunk had the same phases, and a phase chunk read at the wrong offset passed all tests. * Use a period of 6, so each chunk has a different phase pattern. Calculate the expected warning count and the excluded tuples from the phase values. Signed-off-by: Michael Jackson --- .../test/ComputeCAxisReferenceMisalignmentsTest.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp index d0ad8c4ddb..0ba0355458 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -268,13 +268,17 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: out-of-core std::vector excluded; for(usize i = 0; i < k_NumTuples; ++i) { - // Alternate unknown, hexagonal high, hexagonal low, and cubic phases. - phaseValues[i] = static_cast(i % 4); - if(i % 11 != 0 && i % 4 == 3) + // Mix unknown (0), hexagonal high (1), hexagonal low (2), and cubic (3) phases. + // The period 6 does not divide 4096, so each chunk has a different phase pattern + // and a chunk read at the wrong offset changes the result. + phaseValues[i] = static_cast((i % 6) % 4); + const bool masked = i % 11 == 0; + const bool hexagonal = phaseValues[i] == 1 || phaseValues[i] == 2; + if(!masked && phaseValues[i] == 3) { ++expectedNonHexagonalCount; } - if(i % 11 == 0 || i % 4 == 0 || i % 4 == 3 || i == k_ZeroQuaternion) + if(masked || !hexagonal || i == k_ZeroQuaternion) { excluded.push_back(i); } From d1cd2f6befe76bba7625aed260ce694d099d0035 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 27 Oct 2026 16:57:29 -0400 Subject: [PATCH 8/9] ENH: Normalize each quaternion before Compute C-Axis Reference Misalignments uses it * Check the quaternion length first. A zero quaternion or a quaternion with a NaN or infinite component still gets NaN. The check comes before normalize() because normalize() does not check for a zero length. * Normalize the quaternion and move it to the positive hemisphere with getPositiveOrientation() before the orientation matrix is made. The c-axis check after the matrix is no longer necessary. * The documentation and the header now state that quaternions are normalized before use. Signed-off-by: Michael Jackson --- .../ComputeCAxisReferenceMisalignmentsFilter.md | 4 ++-- .../ComputeCAxisReferenceMisalignments.cpp | 16 +++++++++------- .../ComputeCAxisReferenceMisalignments.hpp | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md index 489fdd1940..808766ec4e 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md @@ -14,7 +14,7 @@ The filter works on cell orientations, feature average orientations, or any othe **Input Quaternions** must contain four-component float32 quaternions in EbsdLib order: x, y, z, w. Use cell *Quats* for cell results. Use feature *AvgQuats*, for example from [Compute Feature Average Orientations](ComputeAvgOrientationsFilter.md), for grain results. Convert Euler angles with [Convert Orientation Representation](ConvertOrientationsFilter.md) before using this filter. -A zero quaternion or a quaternion with a NaN or infinite component receives NaN. It does not define a valid orientation. Finite, nonzero quaternions need not be unit length because the computed c-axis is normalized. +A zero quaternion or a quaternion with a NaN or infinite component receives NaN. It does not define a valid orientation. Finite, nonzero quaternions are normalized before use. **Reference Direction** has three dimensionless components in the sample frame. It need not be a unit vector. For example, *[3, 0, 3]* and *[1, 0, 1]* give the same result. All components must be finite. The vector length must be greater than 1e-12. @@ -30,7 +30,7 @@ The output is a one-component float32 array beside **Input Quaternions**. It kee ## Algorithm -Let g be the passive orientation matrix from the input quaternion. The sample-frame c-axis and normalized reference are +Let g be the passive orientation matrix from the normalized input quaternion. The sample-frame c-axis and normalized reference are $$ \mathbf{c} = \frac{g^{T}(0,0,1)^{T}}{\left\|g^{T}(0,0,1)^{T}\right\|}, diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp index e728b9b31d..0b7a335d1d 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -147,15 +147,17 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() continue; } const usize qi = i * 4; - const ebsdlib::OrientationMatrixDType oMatrix = ebsdlib::QuaternionDType(quats[qi], quats[qi + 1], quats[qi + 2], quats[qi + 3]).toOrientationMatrix(); - // The transpose maps crystal [001] into the sample frame. Opposite c-axes represent the same axis. - Eigen::Vector3d cAxis = oMatrix.transpose() * Eigen::Vector3d(0.0, 0.0, 1.0); - const float64 cAxisNorm = cAxis.norm(); - if(!std::isfinite(cAxisNorm) || cAxisNorm == 0.0) + ebsdlib::QuaternionDType quat(quats[qi], quats[qi + 1], quats[qi + 2], quats[qi + 3]); + const float64 quatLength = quat.length(); + if(!std::isfinite(quatLength) || quatLength == 0.0) { - continue; // An undefined orientation must not become a finite angle. + continue; // Output stays NaN: this quaternion is not a rotation. } - cAxis /= cAxisNorm; + quat = quat.normalize().getPositiveOrientation(); + const ebsdlib::OrientationMatrixDType oMatrix = quat.toOrientationMatrix(); + // The transpose maps crystal [001] into the sample frame. Opposite c-axes represent the same axis. + Eigen::Vector3d cAxis = oMatrix.transpose() * Eigen::Vector3d(0.0, 0.0, 1.0); + cAxis.normalize(); const float64 cosine = std::clamp(std::abs(cAxis.dot(referenceDirection)), 0.0, 1.0); output[i] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp index 592c018259..119bb717e7 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -34,7 +34,7 @@ struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsInputValues * * One bounded bulk-I/O traversal serves resident and out-of-core stores. * Masked tuples, non-hexagonal tuples, and zero or nonfinite quaternions receive NaN. - * Finite, nonzero quaternions need not be unit length because the computed c-axis is normalized. + * Finite, nonzero quaternions are normalized before use. * Antipodal symmetry folds angles to [0, 90] degrees. */ class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments From 63896767df5bed2486cbd48a89731320ac6a2f0c Mon Sep 17 00:00:00 2001 From: Joey Kleingers Date: Tue, 27 Oct 2026 16:04:22 -0400 Subject: [PATCH 9/9] REV: Validate inputs and tighten diagnostics for C-axis misalignments The algorithm and preflight call one input check, `ValidateInputs()`, so a direct algorithm call and preflight reject the same invalid arrays without throwing. Each diagnostic has its own named code, and its message names the path, the actual value and an action. **1. Input validation** - `Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp` declares the codes -98710 to -98726 and `ValidateInputs()`. - `Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp` implements the checks. - `Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp` calls it in preflight and converts the result with `ConvertResultTo`. * `ValidateInputs()` checks the reference direction, the type and component shape of the quaternion, Phases, Crystal Structures and enabled mask arrays, and the tuple count of Phases and the mask. It uses lookups that do not throw. * The algorithm also checks the output array before it reads data. * Preflight rejects a StringArray selection with an error instead of `std::bad_cast`. * Preflight rejects an output name that is the name of an input array (-98726). * The reference-direction size check has its own code (-98714). * The filter tag is "Statistics", like the other c-axis filters. **2. Diagnostics** * Type errors say whether the path is missing or give its type. Messages quote paths and types and give an action. * The non-hexagonal warning tells the user how to suppress it. * The Doxygen blocks use the multi-line layout, and local names follow the naming policy. **3. Tests** - `test/ComputeCAxisReferenceMisalignmentsTest.cpp` * New tests call the algorithm directly for each input check, check StringArray and output-name rejection in preflight, and run with the inputs inside a DataGroup. * Every error test asserts its code. Tests that share a code also assert the message. * All tests run through the store scenarios where they execute the filter, use `float64`, and do not call `UnitTest::LoadPlugins()`. Verification: the 20 ComputeCAxisReferenceMisalignments tests and the 80 CAxis and ComputeIPFColors tests pass in a Release in-core build on macOS arm64. clang-format 19.1.1 reports no changes. Signed-off-by: Joey Kleingers --- .../ComputeCAxisReferenceMisalignments.cpp | 250 +++++- .../ComputeCAxisReferenceMisalignments.hpp | 100 ++- ...mputeCAxisReferenceMisalignmentsFilter.cpp | 69 +- ...mputeCAxisReferenceMisalignmentsFilter.hpp | 8 +- ...ComputeCAxisReferenceMisalignmentsTest.cpp | 747 ++++++++++++------ 5 files changed, 865 insertions(+), 309 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp index 0b7a335d1d..21122184d8 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -1,17 +1,22 @@ #include "ComputeCAxisReferenceMisalignments.hpp" #include "simplnx/Common/Numbers.hpp" +#include "simplnx/Common/TypesUtility.hpp" #include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/Utilities/DataArrayUtilities.hpp" #include "simplnx/Utilities/ThrottledMessageHandler.hpp" #include #include #include +#include + #include #include #include #include +#include #include #include @@ -21,8 +26,108 @@ namespace { constexpr usize k_ChunkTuples = 4096; constexpr std::string_view k_ProgressLabel = "Computing C-Axis Reference Misalignments"; + +/** + * @brief Names the type of a selected object for a type error. + * @param object Selected object. + * @return For a DataArray, its element type (for example "float64"); otherwise the object type name (for example "StringArray"). + */ +std::string DescribeObjectType(const DataObject& object) +{ + if(const auto* dataArrayPtr = dynamic_cast(&object); dataArrayPtr != nullptr) + { + return DataTypeToString(dataArrayPtr->getDataType()).str(); + } + return object.getTypeName(); +} + +/** + * @brief Makes the error for a path that does not name an array of the required type. + * @param dataStructure Holds the selected object, if any. + * @param path Selected path. + * @param label User-facing name of the input, for the message. + * @param requiredType Accepted element types, quoted, for the message (for example "'bool' or 'uint8'"). + * @param errorCode Code of the error. + * @return An error that says whether the path is missing or names the type it has. + */ +Result<> MakeArrayTypeError(const DataStructure& dataStructure, const DataPath& path, std::string_view label, std::string_view requiredType, int32 errorCode) +{ + const auto* objectPtr = dataStructure.getData(path); + if(objectPtr == nullptr) + { + return MakeErrorResult(errorCode, fmt::format("{} path '{}' does not exist. Select an array of type {}.", label, path.toString(), requiredType)); + } + return MakeErrorResult( + errorCode, fmt::format("{} path '{}' has type '{}', but type {} is required. Select an array of type {}.", label, path.toString(), DescribeObjectType(*objectPtr), requiredType, requiredType)); +} + +/** + * @brief Checks that an array has exactly the required component shape. + * @param array Array to check. + * @param path Path of the array, for the message. + * @param label User-facing name of the input, for the message. + * @param componentShape Required component shape. + * @param errorCode Code returned when the shape differs. + * @return An error naming both shapes, or a valid result. + */ +Result<> ValidateComponentShape(const IDataArray& array, const DataPath& path, std::string_view label, const ShapeType& componentShape, int32 errorCode) +{ + if(array.getComponentShape() != componentShape) + { + return MakeErrorResult(errorCode, fmt::format("{} array '{}' has component shape {{{}}}, but component shape {{{}}} is required. Select an array with component shape {{{}}}.", label, + path.toString(), fmt::join(array.getComponentShape(), ", "), fmt::join(componentShape, ", "), fmt::join(componentShape, ", "))); + } + return {}; +} + +/** + * @brief Checks, without throwing, that a path resolves to an ArrayType with the required component shape. + * @tparam ArrayType Required concrete DataArray type. + * @param dataStructure Holds the array. + * @param path Path of the array. + * @param label User-facing name of the input, for the messages. + * @param typeName Required element type, quoted, for the messages (for example "'float32'"). + * @param componentShape Required component shape. + * @param typeErrorCode Code returned when the path is missing or has another type. + * @param shapeErrorCode Code returned when the component shape differs. + * @return The first failed check, or a valid result. + */ +template +Result<> ValidateArray(const DataStructure& dataStructure, const DataPath& path, std::string_view label, std::string_view typeName, const ShapeType& componentShape, int32 typeErrorCode, + int32 shapeErrorCode) +{ + const auto* arrayPtr = dataStructure.getDataAs(path); + if(arrayPtr == nullptr) + { + return MakeArrayTypeError(dataStructure, path, label, typeName, typeErrorCode); + } + return ValidateComponentShape(*arrayPtr, path, label, componentShape, shapeErrorCode); +} + +/** + * @brief Checks that a sibling input has the tuple count of the quaternion array. + * @param dataStructure Holds both arrays. + * @param array Sibling input. + * @param path Path of the sibling input. + * @param label User-facing name of the sibling input, for the message. + * @param quatsPath Path of the quaternion array. + * @param quatsTupleCount Tuple count of the quaternion array. + * @param errorCode Code returned when the counts differ. + * @return An error naming both paths and both counts, or a valid result. + */ +Result<> ValidateTupleCount(const DataStructure& dataStructure, const IDataArray& array, const DataPath& path, std::string_view label, const DataPath& quatsPath, usize quatsTupleCount, + int32 errorCode) +{ + if(!CheckArraysHaveSameTupleCount(dataStructure, {quatsPath, path})) + { + return MakeErrorResult(errorCode, fmt::format("{} array '{}' has {} tuples, but Input Quaternions array '{}' has {} tuples. Select arrays with one tuple per quaternion.", label, path.toString(), + array.getNumberOfTuples(), quatsPath.toString(), quatsTupleCount)); + } + return {}; +} } // namespace +// ----------------------------------------------------------------------------- ComputeCAxisReferenceMisalignments::ComputeCAxisReferenceMisalignments(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ComputeCAxisReferenceMisalignmentsInputValues* inputValues) : m_DataStructure(dataStructure) @@ -32,30 +137,96 @@ ComputeCAxisReferenceMisalignments::ComputeCAxisReferenceMisalignments(DataStruc { } +// ----------------------------------------------------------------------------- Result<> ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(const std::vector& referenceDirection) { if(referenceDirection.size() != 3) { - return MakeErrorResult(-98710, fmt::format("Reference Direction has {} components. Enter exactly three sample-frame components [X, Y, Z].", referenceDirection.size())); + return MakeErrorResult(k_ReferenceDirectionSizeError, fmt::format("Reference Direction [{}] has {} components. Enter exactly three sample-frame components [X, Y, Z].", + fmt::join(referenceDirection, ", "), referenceDirection.size())); } const float64 norm = std::hypot(static_cast(referenceDirection[0]), static_cast(referenceDirection[1]), static_cast(referenceDirection[2])); if(!std::isfinite(referenceDirection[0]) || !std::isfinite(referenceDirection[1]) || !std::isfinite(referenceDirection[2]) || norm <= k_MinReferenceDirectionNorm) { - return MakeErrorResult(-98710, + return MakeErrorResult(k_InvalidReferenceDirectionError, fmt::format("Reference Direction [{}, {}, {}] must contain finite components and have length greater than {}. Enter a nonzero sample-frame direction, such as [0, 0, 1].", referenceDirection[0], referenceDirection[1], referenceDirection[2], k_MinReferenceDirectionNorm)); } return {}; } +// ----------------------------------------------------------------------------- +Result<> ComputeCAxisReferenceMisalignments::ValidateInputs(const DataStructure& dataStructure, const ComputeCAxisReferenceMisalignmentsInputValues& inputValues) +{ + if(auto result = ValidateReferenceDirection(inputValues.ReferenceDirection); result.invalid()) + { + return result; + } + + const DataPath& quatsPathRef = inputValues.QuatsArrayPath; + if(auto result = ValidateArray(dataStructure, quatsPathRef, "Input Quaternions", "'float32'", ShapeType{4}, k_InvalidQuatsArrayError, k_QuatsComponentShapeError); result.invalid()) + { + return result; + } + const usize quatsTupleCount = dataStructure.getDataRefAs(quatsPathRef).getNumberOfTuples(); + + const DataPath& phasesPathRef = inputValues.PhasesArrayPath; + if(auto result = ValidateArray(dataStructure, phasesPathRef, "Phases", "'int32'", ShapeType{1}, k_InvalidPhasesArrayError, k_PhasesComponentShapeError); result.invalid()) + { + return result; + } + if(auto result = ValidateTupleCount(dataStructure, dataStructure.getDataRefAs(phasesPathRef), phasesPathRef, "Phases", quatsPathRef, quatsTupleCount, k_TupleCountMismatchError); + result.invalid()) + { + return result; + } + + if(inputValues.UseMask) + { + const DataPath& maskPathRef = inputValues.MaskArrayPath; + const auto* maskPtr = dataStructure.getDataAs(maskPathRef); + if(maskPtr == nullptr || (maskPtr->getDataType() != DataType::boolean && maskPtr->getDataType() != DataType::uint8)) + { + return MakeArrayTypeError(dataStructure, maskPathRef, "Mask", "'bool' or 'uint8'", k_InvalidMaskArrayError); + } + if(auto result = ValidateComponentShape(*maskPtr, maskPathRef, "Mask", ShapeType{1}, k_MaskComponentShapeError); result.invalid()) + { + return result; + } + if(auto result = ValidateTupleCount(dataStructure, *maskPtr, maskPathRef, "Mask", quatsPathRef, quatsTupleCount, k_TupleCountMismatchError); result.invalid()) + { + return result; + } + } + + return ValidateArray(dataStructure, inputValues.CrystalStructuresArrayPath, "Crystal Structures", "'uint32'", ShapeType{1}, k_InvalidCrystalStructuresArrayError, + k_CrystalStructuresComponentShapeError); +} + +// ----------------------------------------------------------------------------- Result<> ComputeCAxisReferenceMisalignments::operator()() { - if(auto validation = ValidateReferenceDirection(m_InputValues->ReferenceDirection); validation.invalid()) + if(auto validation = ValidateInputs(m_DataStructure, *m_InputValues); validation.invalid()) { return validation; } - const auto& reference = m_InputValues->ReferenceDirection; - Eigen::Vector3d referenceDirection(reference[0], reference[1], reference[2]); + const auto& quatsStoreRef = m_DataStructure.getDataRefAs(m_InputValues->QuatsArrayPath).getDataStoreRef(); + const usize total = quatsStoreRef.getNumberOfTuples(); + if(auto validation = ValidateArray(m_DataStructure, m_InputValues->OutputArrayPath, "Output", "'float32'", ShapeType{1}, k_InvalidOutputArrayError, k_OutputComponentShapeError); + validation.invalid()) + { + return validation; + } + auto& outputStoreRef = m_DataStructure.getDataRefAs(m_InputValues->OutputArrayPath).getDataStoreRef(); + if(outputStoreRef.getNumberOfTuples() != total) + { + return MakeErrorResult(k_OutputTupleCountMismatchError, + fmt::format("Output array '{}' has {} tuples, but Input Quaternions array '{}' has {} tuples. Create the output array with one tuple per quaternion.", + m_InputValues->OutputArrayPath.toString(), outputStoreRef.getNumberOfTuples(), m_InputValues->QuatsArrayPath.toString(), total)); + } + + const auto& referenceValuesRef = m_InputValues->ReferenceDirection; + Eigen::Vector3d referenceDirection(referenceValuesRef[0], referenceValuesRef[1], referenceValuesRef[2]); referenceDirection.normalize(); Result<> result; @@ -65,39 +236,33 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() }; // Cache only the small ensemble array. All tuple-level buffers have a fixed bound. - const auto& crystalStructuresStore = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath).getDataStoreRef(); - std::vector crystalStructures(crystalStructuresStore.getNumberOfTuples()); - if(!ioSucceeded(crystalStructuresStore.copyIntoBuffer(0, nonstd::span(crystalStructures.data(), crystalStructures.size())))) + const auto& crystalStructuresStoreRef = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath).getDataStoreRef(); + std::vector crystalStructures(crystalStructuresStoreRef.getNumberOfTuples()); + if(!ioSucceeded(crystalStructuresStoreRef.copyIntoBuffer(0, nonstd::span(crystalStructures.data(), crystalStructures.size())))) { return result; } - const auto& quatsStore = m_DataStructure.getDataRefAs(m_InputValues->QuatsArrayPath).getDataStoreRef(); - const auto& phasesStore = m_DataStructure.getDataRefAs(m_InputValues->PhasesArrayPath).getDataStoreRef(); - auto& outputStore = m_DataStructure.getDataRefAs(m_InputValues->OutputArrayPath).getDataStoreRef(); - const usize total = quatsStore.getNumberOfTuples(); + const auto& phasesStoreRef = m_DataStructure.getDataRefAs(m_InputValues->PhasesArrayPath).getDataStoreRef(); - const BoolArray* boolMask = nullptr; - const UInt8Array* byteMask = nullptr; + // ValidateInputs guarantees that an enabled mask is exactly one of these types. + const BoolArray* boolMaskPtr = nullptr; + const UInt8Array* byteMaskPtr = nullptr; if(m_InputValues->UseMask) { - const auto& mask = m_DataStructure.getDataRefAs(m_InputValues->MaskArrayPath); - if(mask.getDataType() == DataType::boolean) - { - boolMask = &dynamic_cast(mask); - } - else + boolMaskPtr = m_DataStructure.getDataAs(m_InputValues->MaskArrayPath); + if(boolMaskPtr == nullptr) { - byteMask = &dynamic_cast(mask); + byteMaskPtr = m_DataStructure.getDataAs(m_InputValues->MaskArrayPath); } } std::vector quats(k_ChunkTuples * 4); std::vector phases(k_ChunkTuples); std::vector output(k_ChunkTuples); - std::vector byteMaskBuffer(byteMask != nullptr ? k_ChunkTuples : 0); + std::vector byteMaskBuffer(byteMaskPtr != nullptr ? k_ChunkTuples : 0); // Project-rule exception: std::vector is bit-packed and cannot provide the contiguous bool* that bulk copyIntoBuffer needs. - std::unique_ptr boolMaskBuffer = boolMask != nullptr ? std::make_unique(k_ChunkTuples) : nullptr; + std::unique_ptr boolMaskBufferPtr = boolMaskPtr != nullptr ? std::make_unique(k_ChunkTuples) : nullptr; usize nonHexagonalCount = 0; ThrottledMessageHandler progress(m_MessageHandler); @@ -108,34 +273,35 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() return result; } const usize count = std::min(k_ChunkTuples, total - offset); - if(!ioSucceeded(quatsStore.copyIntoBuffer(offset * 4, nonstd::span(quats.data(), count * 4))) || - !ioSucceeded(phasesStore.copyIntoBuffer(offset, nonstd::span(phases.data(), count)))) + if(!ioSucceeded(quatsStoreRef.copyIntoBuffer(offset * 4, nonstd::span(quats.data(), count * 4))) || + !ioSucceeded(phasesStoreRef.copyIntoBuffer(offset, nonstd::span(phases.data(), count)))) { return result; } - if(boolMask != nullptr && !ioSucceeded(boolMask->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(boolMaskBuffer.get(), count)))) + if(boolMaskPtr != nullptr && !ioSucceeded(boolMaskPtr->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(boolMaskBufferPtr.get(), count)))) { return result; } - if(byteMask != nullptr && !ioSucceeded(byteMask->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(byteMaskBuffer.data(), count)))) + if(byteMaskPtr != nullptr && !ioSucceeded(byteMaskPtr->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(byteMaskBuffer.data(), count)))) { return result; } - for(usize i = 0; i < count; ++i) + for(usize tupleIdx = 0; tupleIdx < count; ++tupleIdx) { - output[i] = std::numeric_limits::quiet_NaN(); - if((boolMask != nullptr && !boolMaskBuffer[i]) || (byteMask != nullptr && byteMaskBuffer[i] == 0)) + output[tupleIdx] = std::numeric_limits::quiet_NaN(); + if((boolMaskPtr != nullptr && !boolMaskBufferPtr[tupleIdx]) || (byteMaskPtr != nullptr && byteMaskBuffer[tupleIdx] == 0)) { continue; } - const int32 phase = phases[i]; + const int32 phase = phases[tupleIdx]; if(phase < 0 || static_cast(phase) >= crystalStructures.size()) { - return MergeResults(std::move(result), MakeErrorResult(-98712, fmt::format("Phases array '{}' has value {} at tuple index {}, but Crystal Structures array '{}' has {} tuples. " - "Valid phase indices are in [0, {}). Correct this phase value or select the matching Crystal Structures array.", - m_InputValues->PhasesArrayPath.toString(), phase, offset + i, m_InputValues->CrystalStructuresArrayPath.toString(), - crystalStructures.size(), crystalStructures.size()))); + return MergeResults(std::move(result), + MakeErrorResult(k_PhaseOutOfRangeError, fmt::format("Phases array '{}' has value {} at tuple index {}, but Crystal Structures array '{}' has {} tuples. " + "Valid phase indices are in [0, {}). Correct this phase value or select the matching Crystal Structures array.", + m_InputValues->PhasesArrayPath.toString(), phase, offset + tupleIdx, + m_InputValues->CrystalStructuresArrayPath.toString(), crystalStructures.size(), crystalStructures.size()))); } const uint32 crystalStructure = crystalStructures[phase]; if(crystalStructure != ebsdlib::CrystalStructure::Hexagonal_High && crystalStructure != ebsdlib::CrystalStructure::Hexagonal_Low) @@ -146,8 +312,8 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() } continue; } - const usize qi = i * 4; - ebsdlib::QuaternionDType quat(quats[qi], quats[qi + 1], quats[qi + 2], quats[qi + 3]); + const usize quatIdx = tupleIdx * 4; + ebsdlib::QuaternionDType quat(quats[quatIdx], quats[quatIdx + 1], quats[quatIdx + 2], quats[quatIdx + 3]); const float64 quatLength = quat.length(); if(!std::isfinite(quatLength) || quatLength == 0.0) { @@ -159,9 +325,9 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() Eigen::Vector3d cAxis = oMatrix.transpose() * Eigen::Vector3d(0.0, 0.0, 1.0); cAxis.normalize(); const float64 cosine = std::clamp(std::abs(cAxis.dot(referenceDirection)), 0.0, 1.0); - output[i] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); + output[tupleIdx] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); } - if(!ioSucceeded(outputStore.copyFromBuffer(offset, nonstd::span(output.data(), count)))) + if(!ioSucceeded(outputStoreRef.copyFromBuffer(offset, nonstd::span(output.data(), count)))) { return result; } @@ -171,9 +337,9 @@ Result<> ComputeCAxisReferenceMisalignments::operator()() if(nonHexagonalCount != 0) { - result.warnings().push_back({-98713, fmt::format("{} tuples in '{}' have non-hexagonal phases and received NaN. Only Hexagonal_High (6/mmm) and Hexagonal_Low (6/m) are supported. " - "Select hexagonal tuples with the mask if these phases should be excluded.", - nonHexagonalCount, m_InputValues->QuatsArrayPath.toString())}); + result.warnings().push_back({k_NonHexagonalPhasesWarning, fmt::format("{} tuples in '{}' have non-hexagonal phases and received NaN. Only Hexagonal_High (6/mmm) and Hexagonal_Low (6/m) " + "phases are supported. To suppress this warning, enable Use Mask Array with a mask that excludes the non-hexagonal tuples.", + nonHexagonalCount, m_InputValues->QuatsArrayPath.toString())}); } return result; } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp index 119bb717e7..3189560ac3 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -40,7 +40,78 @@ struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsInputValues class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments { public: - /** @brief Reference directions at or below this dimensionless norm are treated as zero. */ + /** + * @brief The reference direction has a non-finite component or a near-zero length. + */ + static constexpr int32 k_InvalidReferenceDirectionError = -98710; + /** + * @brief The Phases array or the enabled mask has a different tuple count than the quaternion array. + */ + static constexpr int32 k_TupleCountMismatchError = -98711; + /** + * @brief An unmasked phase value is not an index into the Crystal Structures array. + */ + static constexpr int32 k_PhaseOutOfRangeError = -98712; + /** + * @brief Warning: some unmasked tuples have a known non-hexagonal crystal structure. + */ + static constexpr int32 k_NonHexagonalPhasesWarning = -98713; + /** + * @brief The reference direction does not have exactly three components. + */ + static constexpr int32 k_ReferenceDirectionSizeError = -98714; + /** + * @brief The quaternion path does not resolve to a float32 array. + */ + static constexpr int32 k_InvalidQuatsArrayError = -98715; + /** + * @brief The quaternion array does not have component shape {4}. + */ + static constexpr int32 k_QuatsComponentShapeError = -98716; + /** + * @brief The Phases path does not resolve to an int32 array. + */ + static constexpr int32 k_InvalidPhasesArrayError = -98717; + /** + * @brief The Phases array does not have component shape {1}. + */ + static constexpr int32 k_PhasesComponentShapeError = -98718; + /** + * @brief The enabled mask path does not resolve to a bool or uint8 array. + */ + static constexpr int32 k_InvalidMaskArrayError = -98719; + /** + * @brief The enabled mask array does not have component shape {1}. + */ + static constexpr int32 k_MaskComponentShapeError = -98720; + /** + * @brief The Crystal Structures path does not resolve to a uint32 array. + */ + static constexpr int32 k_InvalidCrystalStructuresArrayError = -98721; + /** + * @brief The Crystal Structures array does not have component shape {1}. + */ + static constexpr int32 k_CrystalStructuresComponentShapeError = -98722; + /** + * @brief The output path does not resolve to a float32 array. + */ + static constexpr int32 k_InvalidOutputArrayError = -98723; + /** + * @brief The output array does not have component shape {1}. + */ + static constexpr int32 k_OutputComponentShapeError = -98724; + /** + * @brief The output array has a different tuple count than the quaternion array. + */ + static constexpr int32 k_OutputTupleCountMismatchError = -98725; + /** + * @brief Preflight: the output path equals the path of an input array. + */ + static constexpr int32 k_OutputPathCollisionError = -98726; + + /** + * @brief Reference directions at or below this dimensionless norm are treated as zero. + */ static constexpr float64 k_MinReferenceDirectionNorm = 1.0e-12; /** @@ -52,7 +123,9 @@ class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments */ ComputeCAxisReferenceMisalignments(DataStructure& dataStructure, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ComputeCAxisReferenceMisalignmentsInputValues* inputValues); - /** @brief Destroys the executor. */ + /** + * @brief Destroys the executor. + */ ~ComputeCAxisReferenceMisalignments() noexcept = default; ComputeCAxisReferenceMisalignments(const ComputeCAxisReferenceMisalignments&) = delete; @@ -61,15 +134,32 @@ class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments ComputeCAxisReferenceMisalignments& operator=(ComputeCAxisReferenceMisalignments&&) noexcept = delete; /** - * @brief Validates the reference for both preflight and direct algorithm calls. + * @brief Validates the reference direction. ValidateInputs() calls it. * @param referenceDirection Three finite sample-frame components. - * @return An error if the vector has an invalid size or an invalid or near-zero norm. + * @return k_ReferenceDirectionSizeError for a size other than three, or + * k_InvalidReferenceDirectionError for a non-finite component or a near-zero norm. */ static Result<> ValidateReferenceDirection(const std::vector& referenceDirection); + /** + * @brief Makes every input check that preflight data can answer. preflightImpl() and operator()() both call it. + * + * Checks the reference direction, and the types and component shapes of the quaternion, Phases, + * and Crystal Structures arrays. Checks the mask only when UseMask is set. Phases and the enabled + * mask must have one tuple per quaternion. The output is not checked because preflight has not + * created it yet. Array lookups do not throw. + * @param dataStructure Holds the selected arrays. + * @param inputValues Identifies the arrays and the reference direction. OutputArrayPath is not read. + * @return The error of the first failed check, or a valid result. + */ + static Result<> ValidateInputs(const DataStructure& dataStructure, const ComputeCAxisReferenceMisalignmentsInputValues& inputValues); + /** * @brief Writes one angle per quaternion tuple using checked bulk I/O. - * @return Errors for invalid references, phase indices, or I/O. + * + * Validates the reference direction, the input arrays and the output array (float32, + * component shape {1}, one tuple per quaternion tuple) before any read. + * @return Errors for invalid references or arrays, phase indices, or I/O. * A warning counts only unmasked tuples with known non-hexagonal structures. * UnknownCrystalStructure does not contribute to the count. * Cancellation leaves only completed chunks written. diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp index 03ee3f7cff..91ecc93b32 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp @@ -10,8 +10,33 @@ #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" +#include +#include +#include + using namespace nx::core; +namespace +{ +/** + * @brief Builds the algorithm inputs from the filter arguments, so preflight and execute read the same paths. + * @param filterArgs Filter arguments. + * @return Input values, including the output path beside the quaternion array. + */ +ComputeCAxisReferenceMisalignmentsInputValues CreateInputValues(const Arguments& filterArgs) +{ + ComputeCAxisReferenceMisalignmentsInputValues inputValues; + inputValues.ReferenceDirection = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_ReferenceDir_Key); + inputValues.UseMask = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_UseMask_Key); + inputValues.QuatsArrayPath = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_QuatsArrayPath_Key); + inputValues.PhasesArrayPath = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_PhasesArrayPath_Key); + inputValues.MaskArrayPath = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_MaskArrayPath_Key); + inputValues.CrystalStructuresArrayPath = filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_CrystalStructuresArrayPath_Key); + inputValues.OutputArrayPath = inputValues.QuatsArrayPath.replaceName(filterArgs.value(ComputeCAxisReferenceMisalignmentsFilter::k_CAxisReferenceMisalignmentsArrayName_Key)); + return inputValues; +} +} // namespace + namespace nx::core { //------------------------------------------------------------------------------ @@ -41,7 +66,7 @@ std::string ComputeCAxisReferenceMisalignmentsFilter::humanName() const //------------------------------------------------------------------------------ std::vector ComputeCAxisReferenceMisalignmentsFilter::defaultTags() const { - return {className(), "Processing", "Crystallography", "C-Axis", "Misalignment", "Hexagonal"}; + return {className(), "Statistics", "Crystallography", "C-Axis", "Misalignment", "Hexagonal"}; } //------------------------------------------------------------------------------ @@ -94,28 +119,31 @@ IFilter::UniquePointer ComputeCAxisReferenceMisalignmentsFilter::clone() const IFilter::PreflightResult ComputeCAxisReferenceMisalignmentsFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const { - - const auto referenceDirection = filterArgs.value(k_ReferenceDir_Key); - if(auto result = ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(referenceDirection); result.invalid()) + const ComputeCAxisReferenceMisalignmentsInputValues inputValues = CreateInputValues(filterArgs); + Result<> validationResult = ComputeCAxisReferenceMisalignments::ValidateInputs(dataStructure, inputValues); + if(validationResult.invalid()) { - return {ConvertInvalidResult(std::move(result))}; + return {ConvertResultTo(std::move(validationResult), {})}; } - const auto quatsPath = filterArgs.value(k_QuatsArrayPath_Key); - const auto phasesPath = filterArgs.value(k_PhasesArrayPath_Key); - const auto outputPath = quatsPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); - std::vector paths = {quatsPath, phasesPath}; - if(filterArgs.value(k_UseMask_Key)) + std::vector> inputPaths = { + {"Input Quaternions", inputValues.QuatsArrayPath}, {"Phases", inputValues.PhasesArrayPath}, {"Crystal Structures", inputValues.CrystalStructuresArrayPath}}; + if(inputValues.UseMask) { - paths.push_back(filterArgs.value(k_MaskArrayPath_Key)); + inputPaths.emplace_back("Mask", inputValues.MaskArrayPath); } - if(auto tupleCheck = dataStructure.validateNumberOfTuples(paths); !tupleCheck) + for(const auto& inputRef : inputPaths) { - return {MakeErrorResult( - -98711, fmt::format("Input Quaternions, Phases, and the enabled Mask must have the same number of tuples. Select matching arrays at the same data level.\n{}", tupleCheck.error()))}; + if(inputValues.OutputArrayPath == inputRef.second) + { + return {MakeErrorResult(ComputeCAxisReferenceMisalignments::k_OutputPathCollisionError, + fmt::format("The output array path '{}' is the path of the selected {} array. Change the C-Axis Reference Misalignments name '{}' to a " + "name that no input array uses.", + inputValues.OutputArrayPath.toString(), inputRef.first, inputValues.OutputArrayPath.getTargetName()))}; + } } - const auto& quats = dataStructure.getDataRefAs(quatsPath); + const auto& quatsRef = dataStructure.getDataRefAs(inputValues.QuatsArrayPath); OutputActions actions; - actions.appendAction(std::make_unique(DataType::float32, quats.getIDataStoreRef().getTupleShape(), std::vector{1}, outputPath)); + actions.appendAction(std::make_unique(DataType::float32, quatsRef.getIDataStoreRef().getTupleShape(), ShapeType{1}, inputValues.OutputArrayPath)); return {std::move(actions)}; } @@ -123,14 +151,7 @@ IFilter::PreflightResult ComputeCAxisReferenceMisalignmentsFilter::preflightImpl Result<> ComputeCAxisReferenceMisalignmentsFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const { - ComputeCAxisReferenceMisalignmentsInputValues inputValues; - inputValues.ReferenceDirection = filterArgs.value(k_ReferenceDir_Key); - inputValues.UseMask = filterArgs.value(k_UseMask_Key); - inputValues.QuatsArrayPath = filterArgs.value(k_QuatsArrayPath_Key); - inputValues.PhasesArrayPath = filterArgs.value(k_PhasesArrayPath_Key); - inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); - inputValues.CrystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); - inputValues.OutputArrayPath = inputValues.QuatsArrayPath.replaceName(filterArgs.value(k_CAxisReferenceMisalignmentsArrayName_Key)); + const ComputeCAxisReferenceMisalignmentsInputValues inputValues = CreateInputValues(filterArgs); return ComputeCAxisReferenceMisalignments(dataStructure, messageHandler, shouldCancel, &inputValues)(); } } // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp index 20f9b0f55f..c0cb465951 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp @@ -14,9 +14,13 @@ namespace nx::core class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignmentsFilter : public IFilter { public: - /** @brief Constructs the filter with default parameters. */ + /** + * @brief Constructs the filter with default parameters. + */ ComputeCAxisReferenceMisalignmentsFilter() = default; - /** @brief Destroys the filter. */ + /** + * @brief Destroys the filter. + */ ~ComputeCAxisReferenceMisalignmentsFilter() noexcept override = default; ComputeCAxisReferenceMisalignmentsFilter(const ComputeCAxisReferenceMisalignmentsFilter&) = delete; diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp index 0ba0355458..8ca2f68e33 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -1,8 +1,12 @@ +#include "OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp" #include "OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp" #include "simplnx/Common/Numbers.hpp" #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataGroup.hpp" +#include "simplnx/DataStructure/StringArray.hpp" +#include "simplnx/Filter/IParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" #include "simplnx/Utilities/DataStoreUtilities.hpp" @@ -19,16 +23,21 @@ #include #include #include +#include #include +#include +#include #include using namespace nx::core; namespace { -using Filter = ComputeCAxisReferenceMisalignmentsFilter; -using Euler = std::array; -constexpr double k_Radians = numbers::pi / 180.0; +using FilterType = ComputeCAxisReferenceMisalignmentsFilter; +using AlgorithmType = ComputeCAxisReferenceMisalignments; +using InputValuesType = ComputeCAxisReferenceMisalignmentsInputValues; +using EulerType = std::array; +constexpr float64 k_Radians = numbers::pi / 180.0; constexpr float32 k_NaN = std::numeric_limits::quiet_NaN(); const DataPath k_Quats({"Orientations", "Quats"}); const DataPath k_Phases({"Orientations", "Phases"}); @@ -39,127 +48,189 @@ const DataPath k_Output({"Orientations", "CAxisReferenceMisalignments"}); template DataArray& CreateArray(DataStructure& ds, const DataPath& path, const ShapeType& shape, usize components, DataObject::IdType parent) { - auto store = DataStoreUtilities::CreateDataStore(ds, path, shape, {components}); - auto* array = DataArray::Create(ds, path.getTargetName(), store, parent); - REQUIRE(array != nullptr); - return *array; + auto storePtr = DataStoreUtilities::CreateDataStore(ds, path, shape, {components}); + auto* arrayPtr = DataArray::Create(ds, path.getTargetName(), storePtr, parent); + REQUIRE(arrayPtr != nullptr); + return *arrayPtr; } -DataStructure MakeData(const std::vector& eulers, const ShapeType& shape, std::vector phaseValues = {}, usize zeroQuaternion = std::numeric_limits::max()) +// Returns the path below the DataGroup named groupName, or the path itself when groupName is empty. +DataPath InGroup(const std::string& groupName, const DataPath& path) +{ + if(groupName.empty()) + { + return path; + } + std::vector names = {groupName}; + const std::vector pathNames = path.getPathVector(); + names.insert(names.end(), pathNames.begin(), pathNames.end()); + return DataPath(names); +} + +// Builds the Orientations and Ensembles Attribute Matrices at the root, or inside a DataGroup named groupName. +DataStructure MakeData(const std::vector& eulers, const ShapeType& shape, std::vector phaseValues = {}, usize zeroQuaternion = std::numeric_limits::max(), + const std::string& groupName = "") { DataStructure ds; - auto* am = AttributeMatrix::Create(ds, "Orientations", shape); - REQUIRE(am != nullptr); - auto& quats = CreateArray(ds, k_Quats, shape, 4, am->getId()); - auto& phases = CreateArray(ds, k_Phases, shape, 1, am->getId()); - REQUIRE(quats.getNumberOfTuples() == eulers.size()); + std::optional groupId; + if(!groupName.empty()) + { + auto* groupPtr = DataGroup::Create(ds, groupName); + REQUIRE(groupPtr != nullptr); + groupId = groupPtr->getId(); + } + auto* orientationsPtr = AttributeMatrix::Create(ds, "Orientations", shape, groupId); + REQUIRE(orientationsPtr != nullptr); + auto& quatsRef = CreateArray(ds, InGroup(groupName, k_Quats), shape, 4, orientationsPtr->getId()); + auto& phasesRef = CreateArray(ds, InGroup(groupName, k_Phases), shape, 1, orientationsPtr->getId()); + REQUIRE(quatsRef.getNumberOfTuples() == eulers.size()); std::vector quatValues(eulers.size() * 4); if(phaseValues.empty()) { phaseValues.assign(eulers.size(), 1); } REQUIRE(phaseValues.size() == eulers.size()); - for(usize i = 0; i < eulers.size(); ++i) + for(usize tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) { - const auto& eu = eulers[i]; - const auto quat = ebsdlib::EulerDType(eu[0] * k_Radians, eu[1] * k_Radians, eu[2] * k_Radians).toQuaternion(); - for(usize j = 0; j < 4; ++j) + const auto& eulerRef = eulers[tupleIdx]; + const auto quat = ebsdlib::EulerDType(eulerRef[0] * k_Radians, eulerRef[1] * k_Radians, eulerRef[2] * k_Radians).toQuaternion(); + for(usize compIdx = 0; compIdx < 4; ++compIdx) { - quatValues[4 * i + j] = i == zeroQuaternion ? 0.0F : static_cast(quat[j]); + quatValues[4 * tupleIdx + compIdx] = tupleIdx == zeroQuaternion ? 0.0F : static_cast(quat[compIdx]); } } - auto quatsWriteResult = quats.getDataStoreRef().copyFromBuffer(0, nonstd::span(quatValues.data(), quatValues.size())); + auto quatsWriteResult = quatsRef.getDataStoreRef().copyFromBuffer(0, nonstd::span(quatValues.data(), quatValues.size())); SIMPLNX_RESULT_REQUIRE_VALID(quatsWriteResult); - auto phasesWriteResult = phases.getDataStoreRef().copyFromBuffer(0, nonstd::span(phaseValues.data(), phaseValues.size())); + auto phasesWriteResult = phasesRef.getDataStoreRef().copyFromBuffer(0, nonstd::span(phaseValues.data(), phaseValues.size())); SIMPLNX_RESULT_REQUIRE_VALID(phasesWriteResult); - auto* ensembles = AttributeMatrix::Create(ds, "Ensembles", {5}); - REQUIRE(ensembles != nullptr); - auto& cs = CreateArray(ds, k_CrystalStructures, {5}, 1, ensembles->getId()); + auto* ensemblesPtr = AttributeMatrix::Create(ds, "Ensembles", {5}, groupId); + REQUIRE(ensemblesPtr != nullptr); + auto& crystalStructuresRef = CreateArray(ds, InGroup(groupName, k_CrystalStructures), {5}, 1, ensemblesPtr->getId()); const std::vector crystalValues = {ebsdlib::CrystalStructure::UnknownCrystalStructure, ebsdlib::CrystalStructure::Hexagonal_High, ebsdlib::CrystalStructure::Hexagonal_Low, ebsdlib::CrystalStructure::Cubic_High, ebsdlib::CrystalStructure::Trigonal_High}; - auto crystalWriteResult = cs.getDataStoreRef().copyFromBuffer(0, nonstd::span(crystalValues.data(), crystalValues.size())); + auto crystalWriteResult = crystalStructuresRef.getDataStoreRef().copyFromBuffer(0, nonstd::span(crystalValues.data(), crystalValues.size())); SIMPLNX_RESULT_REQUIRE_VALID(crystalWriteResult); return ds; } -Arguments MakeArgs(const std::vector& reference = {0, 0, 1}, bool useMask = false) +Arguments MakeArgs(const std::vector& reference = {0, 0, 1}, bool useMask = false, const std::string& groupName = "") { Arguments args; - args.insertOrAssign(Filter::k_ReferenceDir_Key, std::make_any(reference)); - args.insertOrAssign(Filter::k_UseMask_Key, std::make_any(useMask)); - args.insertOrAssign(Filter::k_MaskArrayPath_Key, std::make_any(k_Mask)); - args.insertOrAssign(Filter::k_QuatsArrayPath_Key, std::make_any(k_Quats)); - args.insertOrAssign(Filter::k_PhasesArrayPath_Key, std::make_any(k_Phases)); - args.insertOrAssign(Filter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructures)); + args.insertOrAssign(FilterType::k_ReferenceDir_Key, std::make_any(reference)); + args.insertOrAssign(FilterType::k_UseMask_Key, std::make_any(useMask)); + args.insertOrAssign(FilterType::k_MaskArrayPath_Key, std::make_any(InGroup(groupName, k_Mask))); + args.insertOrAssign(FilterType::k_QuatsArrayPath_Key, std::make_any(InGroup(groupName, k_Quats))); + args.insertOrAssign(FilterType::k_PhasesArrayPath_Key, std::make_any(InGroup(groupName, k_Phases))); + args.insertOrAssign(FilterType::k_CrystalStructuresArrayPath_Key, std::make_any(InGroup(groupName, k_CrystalStructures))); return args; } // The textbook third row of the Bunge matrix is independent of quaternion conversion. -double ExpectedAngle(const Euler& eu, const std::vector& reference) +float64 ExpectedAngle(const EulerType& euler, const std::vector& reference) { - const double phi1 = eu[0] * k_Radians; - const double phi = eu[1] * k_Radians; - const std::array c = {std::sin(phi1) * std::sin(phi), -std::cos(phi1) * std::sin(phi), std::cos(phi)}; - const double norm = std::hypot(static_cast(reference[0]), static_cast(reference[1]), static_cast(reference[2])); - const double dot = (c[0] * reference[0] + c[1] * reference[1] + c[2] * reference[2]) / norm; + const float64 phi1 = euler[0] * k_Radians; + const float64 phi = euler[1] * k_Radians; + const std::array cAxis = {std::sin(phi1) * std::sin(phi), -std::cos(phi1) * std::sin(phi), std::cos(phi)}; + const float64 norm = std::hypot(static_cast(reference[0]), static_cast(reference[1]), static_cast(reference[2])); + const float64 dot = (cAxis[0] * reference[0] + cAxis[1] * reference[1] + cAxis[2] * reference[2]) / norm; return std::acos(std::clamp(std::abs(dot), 0.0, 1.0)) / k_Radians; } -void CheckOutput(const DataStructure& ds, const std::vector& eulers, const std::vector& reference, const std::vector& excluded = {}) +void CheckOutput(const DataStructure& ds, const std::vector& eulers, const std::vector& reference, const std::vector& excluded = {}, const std::string& groupName = "") { - REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); - REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); - const auto& output = ds.getDataRefAs(k_Output); - REQUIRE(output.getNumberOfComponents() == 1); - REQUIRE(output.getIDataStoreRef().getTupleShape() == ds.getDataRefAs(k_Quats).getIDataStoreRef().getTupleShape()); - std::vector outputValues(output.getSize()); - auto outputReadResult = output.getDataStoreRef().copyIntoBuffer(0, nonstd::span(outputValues.data(), outputValues.size())); + const DataPath outputPath = InGroup(groupName, k_Output); + const DataPath quatsPath = InGroup(groupName, k_Quats); + REQUIRE_NOTHROW(ds.getDataRefAs(outputPath)); + REQUIRE_NOTHROW(ds.getDataRefAs(quatsPath)); + const auto& outputRef = ds.getDataRefAs(outputPath); + REQUIRE(outputRef.getNumberOfComponents() == 1); + REQUIRE(outputRef.getIDataStoreRef().getTupleShape() == ds.getDataRefAs(quatsPath).getIDataStoreRef().getTupleShape()); + std::vector outputValues(outputRef.getSize()); + auto outputReadResult = outputRef.getDataStoreRef().copyIntoBuffer(0, nonstd::span(outputValues.data(), outputValues.size())); SIMPLNX_RESULT_REQUIRE_VALID(outputReadResult); - for(usize i = 0; i < eulers.size(); ++i) + for(usize tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) { - CAPTURE(i); - if(std::find(excluded.begin(), excluded.end(), i) != excluded.end()) + CAPTURE(tupleIdx); + if(std::find(excluded.begin(), excluded.end(), tupleIdx) != excluded.end()) { - REQUIRE(std::isnan(outputValues[i])); + REQUIRE(std::isnan(outputValues[tupleIdx])); } else { - REQUIRE(std::isfinite(outputValues[i])); - REQUIRE(outputValues[i] >= 0.0F); - REQUIRE(outputValues[i] <= 90.0F); - REQUIRE(outputValues[i] == Approx(ExpectedAngle(eulers[i], reference)).margin(2.0e-4)); + REQUIRE(std::isfinite(outputValues[tupleIdx])); + REQUIRE(outputValues[tupleIdx] >= 0.0F); + REQUIRE(outputValues[tupleIdx] <= 90.0F); + REQUIRE(outputValues[tupleIdx] == Approx(ExpectedAngle(eulers[tupleIdx], reference)).margin(2.0e-4)); } } } // The shared algorithm has no dispatch event. Use the scenario's store guard, // but do not call executeFilter(), which requires a Direct/Scanline dispatch. -void ExecuteAndCheck(DataStructure& ds, const Arguments& args, const UnitTest::AlgorithmTestScope& scope) +Result<> ExecuteInScope(DataStructure& ds, const Arguments& args, const UnitTest::AlgorithmTestScope& scope, const std::string& groupName = "") { - Filter filter; + FilterType filter; auto preflight = filter.preflight(ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); auto result = filter.execute(ds, args); SIMPLNX_RESULT_REQUIRE_VALID(result.result); - REQUIRE(result.result.warnings().empty()); - REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); - scope.requireExpectedStore(ds.getDataRefAs(k_Output)); + const DataPath outputPath = InGroup(groupName, k_Output); + REQUIRE_NOTHROW(ds.getDataRefAs(outputPath)); + scope.requireExpectedStore(ds.getDataRefAs(outputPath)); + return std::move(result.result); +} + +void ExecuteAndCheck(DataStructure& ds, const Arguments& args, const UnitTest::AlgorithmTestScope& scope, const std::string& groupName = "") +{ + const Result<> result = ExecuteInScope(ds, args, scope, groupName); + REQUIRE(result.warnings().empty()); +} + +// Valid inputs for a direct algorithm call: two tuples, a uint8 mask, and the pre-created output. +DataStructure MakeDirectCallData() +{ + DataStructure ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + CreateArray(ds, k_Mask, {2}, 1, parentId).fill(1); + CreateArray(ds, k_Output, {2}, 1, parentId); + REQUIRE(AttributeMatrix::Create(ds, "Other", {3}) != nullptr); + return ds; +} + +InputValuesType MakeDirectCallInputValues() +{ + InputValuesType inputValues; + inputValues.ReferenceDirection = {0.0F, 0.0F, 1.0F}; + inputValues.UseMask = true; + inputValues.QuatsArrayPath = k_Quats; + inputValues.PhasesArrayPath = k_Phases; + inputValues.MaskArrayPath = k_Mask; + inputValues.CrystalStructuresArrayPath = k_CrystalStructures; + inputValues.OutputArrayPath = k_Output; + return inputValues; +} + +Result<> RunAlgorithm(DataStructure& ds, const InputValuesType& inputValues) +{ + const std::atomic_bool shouldCancel = false; + const IFilter::MessageHandler messageHandler{}; + return AlgorithmType(ds, messageHandler, shouldCancel, &inputValues)(); } } // namespace TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cell and feature analytical oracle", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const bool featureLevel = GENERATE(false, true); - const double azimuthOffset = GENERATE(0.0, 43.0, 172.0); - std::vector eulers = {{0, 0, 71}, {22, 30, 15}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; - for(auto& eu : eulers) + const float64 azimuthOffset = GENERATE(0.0, 43.0, 172.0); + std::vector eulers = {{0, 0, 71}, {22, 30, 15}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + for(auto& eulerRef : eulers) { - eu[0] = std::fmod(eu[0] + azimuthOffset, 360.0); - eu[2] = std::fmod(eu[2] + 2.0 * azimuthOffset, 360.0); + eulerRef[0] = std::fmod(eulerRef[0] + azimuthOffset, 360.0); + eulerRef[2] = std::fmod(eulerRef[2] + 2.0 * azimuthOffset, 360.0); } auto ds = MakeData(eulers, featureLevel ? ShapeType{5} : ShapeType{1, 1, 5}); ExecuteAndCheck(ds, MakeArgs(), scope); @@ -169,32 +240,45 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cell and fea TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonunit reference oracle", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const auto reference = GENERATE(std::vector{3, 0, 3}, std::vector{1, 2, -2}, std::vector{-1, -2, 2}); - const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; auto ds = MakeData(eulers, {5}); ExecuteAndCheck(ds, MakeArgs(reference), scope); CheckOutput(ds, eulers, reference); UnitTest::CheckArraysInheritTupleDims(ds); } +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: inputs inside a DataGroup", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const std::string groupName = "Group"; + const std::vector eulers = {{22, 30, 15}, {147, 45, 92}, {271, 90, 13}}; + auto ds = MakeData(eulers, {3}, {}, std::numeric_limits::max(), groupName); + ExecuteAndCheck(ds, MakeArgs({1, 2, -2}, false, groupName), scope, groupName); + CheckOutput(ds, eulers, {1, 2, -2}, {}, groupName); + // The output is created beside the nested quaternions, not at the root. + REQUIRE(ds.getDataAs(k_Output) == nullptr); + UnitTest::CheckArraysInheritTupleDims(ds); +} + TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: fixed seed sweep", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); std::mt19937 generator(1729); - std::uniform_real_distribution azimuth(0.0, 360.0); - std::uniform_real_distribution inclination(0.0, 180.0); + std::uniform_real_distribution azimuth(0.0, 360.0); + std::uniform_real_distribution inclination(0.0, 180.0); std::uniform_real_distribution direction(-10.0F, 10.0F); - std::vector eulers(384); - for(auto& eu : eulers) + std::vector eulers(384); + for(auto& eulerRef : eulers) { - eu = {azimuth(generator), inclination(generator), azimuth(generator)}; + eulerRef = {azimuth(generator), inclination(generator), azimuth(generator)}; } for(usize run = 0; run < 8; ++run) { @@ -208,37 +292,36 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: fixed seed s TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masks and chunk boundaries", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const bool byteMask = GENERATE(false, true); // Cross two 4096-tuple boundaries and exercise a partial final chunk. - std::vector eulers(8201); - for(usize i = 0; i < eulers.size(); ++i) + std::vector eulers(8201); + for(usize tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) { - eulers[i] = {static_cast(i % 360), static_cast(i % 181), static_cast((i * 7) % 360)}; + eulers[tupleIdx] = {static_cast(tupleIdx % 360), static_cast(tupleIdx % 181), static_cast((tupleIdx * 7) % 360)}; } auto ds = MakeData(eulers, {eulers.size()}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); - const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); const std::vector excluded = {0, 4095, 4096, 8192, 8200}; if(byteMask) { - auto& mask = CreateArray(ds, k_Mask, {eulers.size()}, 1, parent); - mask.fill(255); - for(auto i : excluded) + auto& maskRef = CreateArray(ds, k_Mask, {eulers.size()}, 1, parentId); + maskRef.fill(255); + for(auto tupleIdx : excluded) { - mask[i] = 0; + maskRef[tupleIdx] = 0; } } else { - auto& mask = CreateArray(ds, k_Mask, {eulers.size()}, 1, parent); - mask.fill(true); - for(auto i : excluded) + auto& maskRef = CreateArray(ds, k_Mask, {eulers.size()}, 1, parentId); + maskRef.fill(true); + for(auto tupleIdx : excluded) { - mask[i] = false; + maskRef[tupleIdx] = false; } } REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); @@ -258,7 +341,7 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: out-of-core // Cross three 4096-tuple boundaries and exercise a partial final chunk. constexpr usize k_NumTuples = 3 * 4096 + 17; constexpr usize k_ZeroQuaternion = 3 * 4096 + 1; // Unmasked hexagonal tuple in the final chunk. - std::vector eulers(k_NumTuples); + std::vector eulers(k_NumTuples); for(usize i = 0; i < eulers.size(); ++i) { eulers[i] = {static_cast(i % 360), static_cast(i % 181), static_cast((i * 7) % 360)}; @@ -326,13 +409,13 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: out-of-core const std::vector reference = {1, 2, -2}; const auto args = MakeArgs(reference, true); - Filter filter; + FilterType filter; auto preflight = filter.preflight(ds, args); SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); auto result = filter.execute(ds, args); SIMPLNX_RESULT_REQUIRE_VALID(result.result); REQUIRE(result.result.warnings().size() == 1); - REQUIRE(result.result.warnings()[0].code == -98713); + REQUIRE(result.result.warnings()[0].code == AlgorithmType::k_NonHexagonalPhasesWarning); REQUIRE(result.result.warnings()[0].message.find(std::to_string(expectedNonHexagonalCount) + " tuples") == 0); REQUIRE_NOTHROW(ds.getDataRefAs(k_Output)); if(Application::Instance()->getIOManager("HDF5-OOC") != nullptr) @@ -345,79 +428,83 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: out-of-core TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: hexagonal phases only", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); - const std::vector eulers(5, {23, 37, 89}); + const std::vector eulers(5, {23, 37, 89}); auto ds = MakeData(eulers, {5}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); - auto& phases = ds.getDataRefAs(k_Phases); - phases[2] = 2; - phases[3] = 0; + auto& phasesRef = ds.getDataRefAs(k_Phases); + phasesRef[2] = 2; + phasesRef[3] = 0; + // A zero quaternion has no c-axis and must not be reported as perpendicular (90 degrees). + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); + auto& quatsRef = ds.getDataRefAs(k_Quats); + for(usize compIdx = 0; compIdx < 4; ++compIdx) + { + quatsRef[compIdx] = 0.0F; + } usize expectedNonHexagonalCount = 0; - std::vector excluded = {3}; + std::vector excluded = {0, 3}; SECTION("Unknown crystal structure produces NaN without a warning") { } SECTION("Cubic phase produces NaN and counts only the known non-hexagonal tuple") { - phases[1] = 3; + phasesRef[1] = 3; expectedNonHexagonalCount = 1; excluded.push_back(1); } SECTION("Cubic and trigonal phases count both known non-hexagonal tuples") { - phases[1] = 3; - phases[4] = 4; + phasesRef[1] = 3; + phasesRef[4] = 4; expectedNonHexagonalCount = 2; - excluded = {1, 3, 4}; + excluded = {0, 1, 3, 4}; } - auto result = Filter().execute(ds, MakeArgs()); - SIMPLNX_RESULT_REQUIRE_VALID(result.result); + const Result<> result = ExecuteInScope(ds, MakeArgs(), scope); CheckOutput(ds, eulers, {0, 0, 1}, excluded); if(expectedNonHexagonalCount == 0) { - REQUIRE(result.result.warnings().empty()); + REQUIRE(result.warnings().empty()); } else { - REQUIRE(result.result.warnings().size() == 1); - REQUIRE(result.result.warnings()[0].code == -98713); - REQUIRE(result.result.warnings()[0].message.find(std::to_string(expectedNonHexagonalCount) + " tuples") == 0); + REQUIRE(result.warnings().size() == 1); + REQUIRE(result.warnings()[0].code == AlgorithmType::k_NonHexagonalPhasesWarning); + REQUIRE(result.warnings()[0].message.find(std::to_string(expectedNonHexagonalCount) + " tuples") == 0); } UnitTest::CheckArraysInheritTupleDims(ds); } TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masked non-hexagonal tuples are not counted", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const bool byteMask = GENERATE(false, true); CAPTURE(byteMask); - const std::vector eulers(6, {23, 37, 89}); + const std::vector eulers(6, {23, 37, 89}); auto ds = MakeData(eulers, {6}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); - auto& phases = ds.getDataRefAs(k_Phases); - phases[1] = 3; - phases[2] = 4; + auto& phasesRef = ds.getDataRefAs(k_Phases); + phasesRef[1] = 3; + phasesRef[2] = 4; REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); - const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); if(byteMask) { - auto& mask = CreateArray(ds, k_Mask, {6}, 1, parent); - mask.fill(1); - mask[1] = 0; - mask[2] = 0; + auto& maskRef = CreateArray(ds, k_Mask, {6}, 1, parentId); + maskRef.fill(1); + maskRef[1] = 0; + maskRef[2] = 0; } else { - auto& mask = CreateArray(ds, k_Mask, {6}, 1, parent); - mask.fill(true); - mask[1] = false; - mask[2] = false; + auto& maskRef = CreateArray(ds, k_Mask, {6}, 1, parentId); + maskRef.fill(true); + maskRef[1] = false; + maskRef[2] = false; } usize expectedNonHexagonalCount = 0; std::vector excluded = {1, 2}; @@ -426,151 +513,59 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: masked non-h } SECTION("One unmasked cubic tuple is counted") { - phases[4] = 3; + phasesRef[4] = 3; expectedNonHexagonalCount = 1; excluded.push_back(4); } - auto result = Filter().execute(ds, MakeArgs({0, 0, 1}, true)); - SIMPLNX_RESULT_REQUIRE_VALID(result.result); + const Result<> result = ExecuteInScope(ds, MakeArgs({0, 0, 1}, true), scope); CheckOutput(ds, eulers, {0, 0, 1}, excluded); if(expectedNonHexagonalCount == 0) { - REQUIRE(result.result.warnings().empty()); - } - else - { - REQUIRE(result.result.warnings().size() == 1); - REQUIRE(result.result.warnings()[0].code == -98713); - REQUIRE(result.result.warnings()[0].message.find("1 tuples") == 0); - } - UnitTest::CheckArraysInheritTupleDims(ds); -} - -TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: invalid reference", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") -{ - UnitTest::LoadPlugins(); - const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); - CAPTURE(scenario); - UnitTest::AlgorithmTestScope scope(scenario); - const auto reference = - GENERATE(std::vector{0, 0, 0}, std::vector{k_NaN, 0, 1}, std::vector{0, std::numeric_limits::infinity(), 1}, std::vector{0, 0, 1.0e-13F}); - auto ds = MakeData({{0, 30, 0}}, {1}); - auto result = Filter().preflight(ds, MakeArgs(reference)); - REQUIRE(result.outputActions.invalid()); - REQUIRE(result.outputActions.errors()[0].code == -98710); - UnitTest::CheckArraysInheritTupleDims(ds); -} - -TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: tuple mismatch", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") -{ - UnitTest::LoadPlugins(); - const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); - CAPTURE(scenario); - UnitTest::AlgorithmTestScope scope(scenario); - const bool mismatchMask = GENERATE(false, true); - auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); - auto* other = AttributeMatrix::Create(ds, "Other", {3}); - REQUIRE(other != nullptr); - const DataPath otherPath({"Other", "Mismatched"}); - auto args = MakeArgs({0, 0, 1}, mismatchMask); - if(mismatchMask) - { - CreateArray(ds, otherPath, {3}, 1, other->getId()); - args.insertOrAssign(Filter::k_MaskArrayPath_Key, std::make_any(otherPath)); + REQUIRE(result.warnings().empty()); } else { - CreateArray(ds, otherPath, {3}, 1, other->getId()); - args.insertOrAssign(Filter::k_PhasesArrayPath_Key, std::make_any(otherPath)); + REQUIRE(result.warnings().size() == 1); + REQUIRE(result.warnings()[0].code == AlgorithmType::k_NonHexagonalPhasesWarning); + REQUIRE(result.warnings()[0].message.find("1 tuples") == 0); } - auto result = Filter().preflight(ds, args); - REQUIRE(result.outputActions.invalid()); - REQUIRE(result.outputActions.errors()[0].code == -98711); - const auto& message = result.outputActions.errors()[0].message; - REQUIRE(message.find("Quats") != std::string::npos); - REQUIRE(message.find("Mismatched") != std::string::npos); - REQUIRE(message.find("2") != std::string::npos); - REQUIRE(message.find("3") != std::string::npos); - UnitTest::CheckArraysInheritTupleDims(ds); -} - -TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: phase index error", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") -{ - UnitTest::LoadPlugins(); - const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); - CAPTURE(scenario); - UnitTest::AlgorithmTestScope scope(scenario); - const int32 phase = GENERATE(-1, 5, std::numeric_limits::max()); - auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); - REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); - ds.getDataRefAs(k_Phases)[1] = phase; - auto result = Filter().execute(ds, MakeArgs()); - REQUIRE(result.result.invalid()); - REQUIRE(result.result.errors()[0].code == -98712); - const auto& message = result.result.errors()[0].message; - REQUIRE(message.find("Phases") != std::string::npos); - REQUIRE(message.find("CrystalStructures") != std::string::npos); - REQUIRE(message.find(std::to_string(phase)) != std::string::npos); - REQUIRE(message.find("tuple index 1") != std::string::npos); - UnitTest::CheckArraysInheritTupleDims(ds); -} - -TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: rejects Euler angle input", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") -{ - UnitTest::LoadPlugins(); - const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); - CAPTURE(scenario); - UnitTest::AlgorithmTestScope scope(scenario); - auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); - REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); - const auto parent = ds.getDataRefAs(k_Quats.getParent()).getId(); - const DataPath eulersPath({"Orientations", "EulerAngles"}); - CreateArray(ds, eulersPath, {2}, 3, parent); - auto args = MakeArgs(); - args.insertOrAssign(Filter::k_QuatsArrayPath_Key, std::make_any(eulersPath)); - auto result = Filter().preflight(ds, args); - REQUIRE(result.outputActions.invalid()); - REQUIRE(ds.getDataAs(k_Output) == nullptr); UnitTest::CheckArraysInheritTupleDims(ds); } TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: zero quaternion is undefined", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const usize count = GENERATE(1, 4096, 4097, 8192); CAPTURE(count); - const std::vector eulers(count, {23, 37, 89}); + const std::vector eulers(count, {23, 37, 89}); auto ds = MakeData(eulers, {count}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); - auto& quats = ds.getDataRefAs(k_Quats); - for(usize component = 0; component < 4; ++component) + auto& quatsRef = ds.getDataRefAs(k_Quats); + for(usize compIdx = 0; compIdx < 4; ++compIdx) { - quats[4 * (count - 1) + component] = 0.0F; + quatsRef[4 * (count - 1) + compIdx] = 0.0F; } ExecuteAndCheck(ds, MakeArgs(), scope); // A zero quaternion defines no orientation, so it cannot have a finite angle. - CAPTURE(ds.getDataRefAs(k_Output)[count - 1]); CheckOutput(ds, eulers, {0, 0, 1}, {count - 1}); UnitTest::CheckArraysInheritTupleDims(ds); } TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonfinite quaternions", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); - const usize component = GENERATE(0, 1, 2, 3); + const usize compIdx = GENERATE(0, 1, 2, 3); const float32 value = GENERATE(as{}, k_NaN, std::numeric_limits::infinity(), -std::numeric_limits::infinity()); - CAPTURE(component, value); + CAPTURE(compIdx, value); REQUIRE_FALSE(std::isfinite(value)); - const std::vector eulers(2, {23, 37, 89}); + const std::vector eulers(2, {23, 37, 89}); auto ds = MakeData(eulers, {2}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); - ds.getDataRefAs(k_Quats)[component] = value; + ds.getDataRefAs(k_Quats)[compIdx] = value; ExecuteAndCheck(ds, MakeArgs(), scope); CheckOutput(ds, eulers, {0, 0, 1}, {0}); UnitTest::CheckArraysInheritTupleDims(ds); @@ -578,19 +573,18 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonfinite qu TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: quaternion scale invariance", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const float32 scale = GENERATE(-2.0F, 0.5F, 1.0e-30F, 1.0e30F); CAPTURE(scale); - const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; + const std::vector eulers = {{0, 0, 71}, {90, 45, 31}, {147, 45, 92}, {271, 90, 13}, {81, 120, 245}}; auto ds = MakeData(eulers, {eulers.size()}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats)); - auto& quats = ds.getDataRefAs(k_Quats); - for(usize index = 0; index < quats.getSize(); ++index) + auto& quatsRef = ds.getDataRefAs(k_Quats); + for(usize valueIdx = 0; valueIdx < quatsRef.getSize(); ++valueIdx) { - quats[index] *= scale; + quatsRef[valueIdx] *= scale; } ExecuteAndCheck(ds, MakeArgs({1, 2, -2}), scope); CheckOutput(ds, eulers, {1, 2, -2}); @@ -599,7 +593,6 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: quaternion s TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: empty input", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); @@ -612,26 +605,26 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: empty input" TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cancellation", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") { - UnitTest::LoadPlugins(); const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); CAPTURE(scenario); UnitTest::AlgorithmTestScope scope(scenario); const bool cancelBeforeExecute = GENERATE(false, true); CAPTURE(cancelBeforeExecute); std::atomic_bool shouldCancel = cancelBeforeExecute; - const std::vector eulers(4097, {23, 37, 89}); + const std::vector eulers(4097, {23, 37, 89}); auto ds = MakeData(eulers, {eulers.size()}); REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); - // If cancellation is ignored at the next boundary, this produces -98712. + // If cancellation is ignored at the next chunk boundary, this phase gives k_PhaseOutOfRangeError. ds.getDataRefAs(k_Phases)[4096] = -1; - const IFilter::MessageHandler handler{[&shouldCancel](const IFilter::Message& message) { + const IFilter::MessageHandler messageHandler{[&shouldCancel](const IFilter::Message& message) { if(message.type == IFilter::Message::Type::Progress) { shouldCancel = true; } }}; - auto result = Filter().execute(ds, MakeArgs(), nullptr, handler, shouldCancel); + auto result = FilterType().execute(ds, MakeArgs(), nullptr, messageHandler, shouldCancel); SIMPLNX_RESULT_REQUIRE_INVALID(result.result); + // IFilter reports a cancelled run with code -1. REQUIRE(result.result.errors()[0].code == -1); if(cancelBeforeExecute) { @@ -640,7 +633,289 @@ TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: cancellation else { REQUIRE(shouldCancel.load()); - CheckOutput(ds, std::vector(4096, eulers.front()), {0, 0, 1}); + CheckOutput(ds, std::vector(4096, eulers.front()), {0, 0, 1}); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: invalid reference", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const auto reference = + GENERATE(std::vector{0, 0, 0}, std::vector{k_NaN, 0, 1}, std::vector{0, std::numeric_limits::infinity(), 1}, std::vector{0, 0, 1.0e-13F}); + auto ds = MakeData({{0, 30, 0}}, {1}); + auto result = FilterType().preflight(ds, MakeArgs(reference)); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors()[0].code == AlgorithmType::k_InvalidReferenceDirectionError); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: reference with wrong component count", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + // VectorParameter rejects a wrong-sized vector before preflightImpl, so only a direct call reaches this check. + const auto reference = GENERATE(std::vector{}, std::vector{0.0F, 1.0F}, std::vector{0.0F, 0.0F, 1.0F, 0.0F}); + const Result<> result = AlgorithmType::ValidateReferenceDirection(reference); + SIMPLNX_RESULT_REQUIRE_INVALID(result); + REQUIRE(result.errors().size() == 1); + REQUIRE(result.errors()[0].code == AlgorithmType::k_ReferenceDirectionSizeError); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: tuple mismatch", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool mismatchMask = GENERATE(false, true); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + auto* otherPtr = AttributeMatrix::Create(ds, "Other", {3}); + REQUIRE(otherPtr != nullptr); + const DataPath otherPath({"Other", "Mismatched"}); + auto args = MakeArgs({0, 0, 1}, mismatchMask); + if(mismatchMask) + { + CreateArray(ds, otherPath, {3}, 1, otherPtr->getId()); + args.insertOrAssign(FilterType::k_MaskArrayPath_Key, std::make_any(otherPath)); } + else + { + CreateArray(ds, otherPath, {3}, 1, otherPtr->getId()); + args.insertOrAssign(FilterType::k_PhasesArrayPath_Key, std::make_any(otherPath)); + } + auto result = FilterType().preflight(ds, args); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors()[0].code == AlgorithmType::k_TupleCountMismatchError); + const auto& messageRef = result.outputActions.errors()[0].message; + REQUIRE(messageRef.find(mismatchMask ? "Mask array" : "Phases array") == 0); + REQUIRE(messageRef.find("Quats") != std::string::npos); + REQUIRE(messageRef.find("Mismatched") != std::string::npos); + REQUIRE(messageRef.find("2") != std::string::npos); + REQUIRE(messageRef.find("3") != std::string::npos); UnitTest::CheckArraysInheritTupleDims(ds); } + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: phase index error", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const int32 phase = GENERATE(-1, 5, std::numeric_limits::max()); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + ds.getDataRefAs(k_Phases)[1] = phase; + auto result = FilterType().execute(ds, MakeArgs()); + REQUIRE(result.result.invalid()); + REQUIRE(result.result.errors()[0].code == AlgorithmType::k_PhaseOutOfRangeError); + const auto& messageRef = result.result.errors()[0].message; + REQUIRE(messageRef.find("Phases") != std::string::npos); + REQUIRE(messageRef.find("CrystalStructures") != std::string::npos); + REQUIRE(messageRef.find(std::to_string(phase)) != std::string::npos); + REQUIRE(messageRef.find("tuple index 1") != std::string::npos); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: rejects Euler angle input", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + const DataPath eulersPath({"Orientations", "EulerAngles"}); + CreateArray(ds, eulersPath, {2}, 3, parentId); + auto args = MakeArgs(); + args.insertOrAssign(FilterType::k_QuatsArrayPath_Key, std::make_any(eulersPath)); + auto result = FilterType().preflight(ds, args); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors().size() == 1); + REQUIRE(result.outputActions.errors()[0].code == FilterParameter::Constants::k_Validate_TupleShapeValue); + REQUIRE(ds.getDataAs(k_Output) == nullptr); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: output name equal to an input name", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + CreateArray(ds, k_Mask, {2}, 1, ds.getDataRefAs(k_Quats.getParent()).getId()).fill(1); + const std::string inputName = GENERATE(std::string("Quats"), std::string("Phases"), std::string("Mask")); + CAPTURE(inputName); + auto args = MakeArgs({0, 0, 1}, true); + args.insertOrAssign(FilterType::k_CAxisReferenceMisalignmentsArrayName_Key, std::make_any(inputName)); + auto result = FilterType().preflight(ds, args); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors().size() == 1); + REQUIRE(result.outputActions.errors()[0].code == AlgorithmType::k_OutputPathCollisionError); + REQUIRE(result.outputActions.errors()[0].message.find("Orientations/" + inputName) != std::string::npos); + REQUIRE(ds.getDataRefAs(k_Quats.replaceName(inputName)).getNumberOfComponents() == (inputName == "Quats" ? 4 : 1)); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: preflight rejects string array selections", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + // ArraySelectionParameter applies its numeric type and component-shape lists only to IDataArray, + // so a StringArray with the right tuple count reaches preflightImpl. + auto ds = MakeData({{0, 30, 0}, {0, 45, 0}}, {2}); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + const DataPath stringsPath({"Orientations", "Strings"}); + REQUIRE(StringArray::CreateWithValues(ds, stringsPath.getTargetName(), {2}, {"a", "b"}, parentId) != nullptr); + const bool useMask = GENERATE(false, true); + auto args = MakeArgs({0, 0, 1}, useMask); + int32 expectedCode = AlgorithmType::k_InvalidMaskArrayError; + if(useMask) + { + args.insertOrAssign(FilterType::k_MaskArrayPath_Key, std::make_any(stringsPath)); + } + else + { + args.insertOrAssign(FilterType::k_QuatsArrayPath_Key, std::make_any(stringsPath)); + expectedCode = AlgorithmType::k_InvalidQuatsArrayError; + } + IFilter::PreflightResult result; + REQUIRE_NOTHROW(result = FilterType().preflight(ds, args)); + REQUIRE(result.outputActions.invalid()); + REQUIRE(result.outputActions.errors().size() == 1); + REQUIRE(result.outputActions.errors()[0].code == expectedCode); + REQUIRE(result.outputActions.errors()[0].message.find("has type 'StringArray'") != std::string::npos); + REQUIRE(ds.getDataAs(k_Output) == nullptr); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: algorithm validates its own inputs", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + DataStructure ds = MakeDirectCallData(); + InputValuesType inputValues = MakeDirectCallInputValues(); + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + REQUIRE_NOTHROW(ds.getDataRefAs(DataPath({"Other"}))); + const auto otherId = ds.getDataRefAs(DataPath({"Other"})).getId(); + const DataPath wrongPath({"Orientations", "Wrong"}); + const DataPath otherPath({"Other", "Mismatched"}); + // 0 means the inputs are valid and the call must succeed. + int32 expectedCode = 0; + // A phrase that only the message of the expected condition contains, when the code alone is not enough. + std::string expectedPhrase; + + SECTION("Valid inputs succeed") + { + } + SECTION("Missing quaternion array") + { + inputValues.QuatsArrayPath = DataPath({"Orientations", "Missing"}); + expectedCode = AlgorithmType::k_InvalidQuatsArrayError; + expectedPhrase = "does not exist"; + } + SECTION("Quaternion array of the wrong type") + { + CreateArray(ds, wrongPath, {2}, 4, parentId); + inputValues.QuatsArrayPath = wrongPath; + expectedCode = AlgorithmType::k_InvalidQuatsArrayError; + expectedPhrase = "has type 'float64', but type 'float32' is required"; + } + SECTION("Quaternion array with five components") + { + CreateArray(ds, wrongPath, {2}, 5, parentId); + inputValues.QuatsArrayPath = wrongPath; + expectedCode = AlgorithmType::k_QuatsComponentShapeError; + } + SECTION("Phases array of the wrong type") + { + CreateArray(ds, wrongPath, {2}, 1, parentId); + inputValues.PhasesArrayPath = wrongPath; + expectedCode = AlgorithmType::k_InvalidPhasesArrayError; + } + SECTION("Phases array with two components") + { + CreateArray(ds, wrongPath, {2}, 2, parentId); + inputValues.PhasesArrayPath = wrongPath; + expectedCode = AlgorithmType::k_PhasesComponentShapeError; + } + SECTION("Phases array with a different tuple count") + { + CreateArray(ds, otherPath, {3}, 1, otherId); + inputValues.PhasesArrayPath = otherPath; + expectedCode = AlgorithmType::k_TupleCountMismatchError; + expectedPhrase = "Phases array 'Other/Mismatched' has 3 tuples"; + } + SECTION("Mask array of the wrong type") + { + CreateArray(ds, wrongPath, {2}, 1, parentId); + inputValues.MaskArrayPath = wrongPath; + expectedCode = AlgorithmType::k_InvalidMaskArrayError; + expectedPhrase = "has type 'int32', but type 'bool' or 'uint8' is required"; + } + SECTION("Mask array with two components") + { + CreateArray(ds, wrongPath, {2}, 2, parentId); + inputValues.MaskArrayPath = wrongPath; + expectedCode = AlgorithmType::k_MaskComponentShapeError; + } + SECTION("Mask array with a different tuple count") + { + CreateArray(ds, otherPath, {3}, 1, otherId); + inputValues.MaskArrayPath = otherPath; + expectedCode = AlgorithmType::k_TupleCountMismatchError; + expectedPhrase = "Mask array 'Other/Mismatched' has 3 tuples"; + } + SECTION("Crystal Structures array of the wrong type") + { + CreateArray(ds, otherPath, {3}, 1, otherId); + inputValues.CrystalStructuresArrayPath = otherPath; + expectedCode = AlgorithmType::k_InvalidCrystalStructuresArrayError; + } + SECTION("Crystal Structures array with two components") + { + CreateArray(ds, otherPath, {3}, 2, otherId); + inputValues.CrystalStructuresArrayPath = otherPath; + expectedCode = AlgorithmType::k_CrystalStructuresComponentShapeError; + } + SECTION("Missing output array") + { + inputValues.OutputArrayPath = DataPath({"Orientations", "Missing"}); + expectedCode = AlgorithmType::k_InvalidOutputArrayError; + } + SECTION("Output array with two components") + { + CreateArray(ds, wrongPath, {2}, 2, parentId); + inputValues.OutputArrayPath = wrongPath; + expectedCode = AlgorithmType::k_OutputComponentShapeError; + } + SECTION("Output array with a different tuple count") + { + CreateArray(ds, otherPath, {3}, 1, otherId); + inputValues.OutputArrayPath = otherPath; + expectedCode = AlgorithmType::k_OutputTupleCountMismatchError; + } + SECTION("Non-finite reference direction") + { + inputValues.ReferenceDirection = {k_NaN, 0.0F, 1.0F}; + expectedCode = AlgorithmType::k_InvalidReferenceDirectionError; + } + SECTION("Reference direction with two components") + { + inputValues.ReferenceDirection = {0.0F, 1.0F}; + expectedCode = AlgorithmType::k_ReferenceDirectionSizeError; + } + SECTION("Phase outside the Crystal Structures array") + { + REQUIRE_NOTHROW(ds.getDataRefAs(k_Phases)); + ds.getDataRefAs(k_Phases)[1] = 5; + expectedCode = AlgorithmType::k_PhaseOutOfRangeError; + } + CAPTURE(expectedCode); + Result<> result; + REQUIRE_NOTHROW(result = RunAlgorithm(ds, inputValues)); + if(expectedCode == 0) + { + SIMPLNX_RESULT_REQUIRE_VALID(result); + return; + } + SIMPLNX_RESULT_REQUIRE_INVALID(result); + REQUIRE(result.errors().size() == 1); + REQUIRE(result.errors()[0].code == expectedCode); + if(!expectedPhrase.empty()) + { + REQUIRE(result.errors()[0].message.find(expectedPhrase) != std::string::npos); + } +}