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..808766ec4e --- /dev/null +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisReferenceMisalignmentsFilter.md @@ -0,0 +1,69 @@ +# 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 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. + +### 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. + +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. + +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, 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. + +### 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 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\|}, +\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..21122184d8 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.cpp @@ -0,0 +1,345 @@ +#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 + +using namespace nx::core; + +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) +, m_MessageHandler(messageHandler) +, m_ShouldCancel(shouldCancel) +, m_InputValues(inputValues) +{ +} + +// ----------------------------------------------------------------------------- +Result<> ComputeCAxisReferenceMisalignments::ValidateReferenceDirection(const std::vector& referenceDirection) +{ + if(referenceDirection.size() != 3) + { + 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(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 = ValidateInputs(m_DataStructure, *m_InputValues); validation.invalid()) + { + return validation; + } + 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; + 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& 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& phasesStoreRef = m_DataStructure.getDataRefAs(m_InputValues->PhasesArrayPath).getDataStoreRef(); + + // ValidateInputs guarantees that an enabled mask is exactly one of these types. + const BoolArray* boolMaskPtr = nullptr; + const UInt8Array* byteMaskPtr = nullptr; + if(m_InputValues->UseMask) + { + boolMaskPtr = m_DataStructure.getDataAs(m_InputValues->MaskArrayPath); + if(boolMaskPtr == nullptr) + { + 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(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 boolMaskBufferPtr = boolMaskPtr != 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(quatsStoreRef.copyIntoBuffer(offset * 4, nonstd::span(quats.data(), count * 4))) || + !ioSucceeded(phasesStoreRef.copyIntoBuffer(offset, nonstd::span(phases.data(), count)))) + { + return result; + } + if(boolMaskPtr != nullptr && !ioSucceeded(boolMaskPtr->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(boolMaskBufferPtr.get(), count)))) + { + return result; + } + if(byteMaskPtr != nullptr && !ioSucceeded(byteMaskPtr->getDataStoreRef().copyIntoBuffer(offset, nonstd::span(byteMaskBuffer.data(), count)))) + { + return result; + } + + for(usize tupleIdx = 0; tupleIdx < count; ++tupleIdx) + { + output[tupleIdx] = std::numeric_limits::quiet_NaN(); + if((boolMaskPtr != nullptr && !boolMaskBufferPtr[tupleIdx]) || (byteMaskPtr != nullptr && byteMaskBuffer[tupleIdx] == 0)) + { + continue; + } + const int32 phase = phases[tupleIdx]; + if(phase < 0 || static_cast(phase) >= 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) + { + if(crystalStructure != ebsdlib::CrystalStructure::UnknownCrystalStructure) + { + ++nonHexagonalCount; + } + continue; + } + 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) + { + continue; // Output stays NaN: this quaternion is not a rotation. + } + 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[tupleIdx] = static_cast(std::acos(cosine) * (180.0 / numbers::pi)); + } + if(!ioSucceeded(outputStoreRef.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({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 new file mode 100644 index 0000000000..3189560ac3 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisReferenceMisalignments.hpp @@ -0,0 +1,175 @@ +#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 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; +}; + +/** + * @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 tuples, non-hexagonal tuples, and zero or nonfinite quaternions receive NaN. + * Finite, nonzero quaternions are normalized before use. + * Antipodal symmetry folds angles to [0, 90] degrees. + */ +class ORIENTATIONANALYSIS_EXPORT ComputeCAxisReferenceMisalignments +{ +public: + /** + * @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; + + /** + * @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 direction. ValidateInputs() calls it. + * @param referenceDirection Three finite sample-frame components. + * @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. + * + * 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. + */ + 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..91ecc93b32 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.cpp @@ -0,0 +1,157 @@ +#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" + +#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 +{ +//------------------------------------------------------------------------------ +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(), "Statistics", "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 ComputeCAxisReferenceMisalignmentsInputValues inputValues = CreateInputValues(filterArgs); + Result<> validationResult = ComputeCAxisReferenceMisalignments::ValidateInputs(dataStructure, inputValues); + if(validationResult.invalid()) + { + return {ConvertResultTo(std::move(validationResult), {})}; + } + std::vector> inputPaths = { + {"Input Quaternions", inputValues.QuatsArrayPath}, {"Phases", inputValues.PhasesArrayPath}, {"Crystal Structures", inputValues.CrystalStructuresArrayPath}}; + if(inputValues.UseMask) + { + inputPaths.emplace_back("Mask", inputValues.MaskArrayPath); + } + for(const auto& inputRef : inputPaths) + { + 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& quatsRef = dataStructure.getDataRefAs(inputValues.QuatsArrayPath); + OutputActions actions; + actions.appendAction(std::make_unique(DataType::float32, quatsRef.getIDataStoreRef().getTupleShape(), ShapeType{1}, inputValues.OutputArrayPath)); + 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 +{ + 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 new file mode 100644 index 0000000000..c0cb465951 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisReferenceMisalignmentsFilter.hpp @@ -0,0 +1,117 @@ +#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..8ca2f68e33 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisReferenceMisalignmentsTest.cpp @@ -0,0 +1,921 @@ +#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" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace nx::core; + +namespace +{ +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"}); +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 storePtr = DataStoreUtilities::CreateDataStore(ds, path, shape, {components}); + auto* arrayPtr = DataArray::Create(ds, path.getTargetName(), storePtr, parent); + REQUIRE(arrayPtr != nullptr); + return *arrayPtr; +} + +// 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; + 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 tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) + { + 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 * tupleIdx + compIdx] = tupleIdx == zeroQuaternion ? 0.0F : static_cast(quat[compIdx]); + } + } + auto quatsWriteResult = quatsRef.getDataStoreRef().copyFromBuffer(0, nonstd::span(quatValues.data(), quatValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(quatsWriteResult); + auto phasesWriteResult = phasesRef.getDataStoreRef().copyFromBuffer(0, nonstd::span(phaseValues.data(), phaseValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(phasesWriteResult); + 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 = 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, const std::string& groupName = "") +{ + Arguments args; + 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. +float64 ExpectedAngle(const EulerType& euler, const std::vector& reference) +{ + 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 = {}, const std::string& groupName = "") +{ + 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 tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) + { + CAPTURE(tupleIdx); + if(std::find(excluded.begin(), excluded.end(), tupleIdx) != excluded.end()) + { + REQUIRE(std::isnan(outputValues[tupleIdx])); + } + else + { + 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. +Result<> ExecuteInScope(DataStructure& ds, const Arguments& args, const UnitTest::AlgorithmTestScope& scope, const std::string& groupName = "") +{ + 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); + 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]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool featureLevel = GENERATE(false, true); + 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) + { + 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); + CheckOutput(ds, eulers, {0, 0, 1}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonunit reference oracle", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + 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: 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]") +{ + 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& eulerRef : eulers) + { + eulerRef = {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]") +{ + 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 tupleIdx = 0; tupleIdx < eulers.size(); ++tupleIdx) + { + 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 parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + const std::vector excluded = {0, 4095, 4096, 8192, 8200}; + if(byteMask) + { + auto& maskRef = CreateArray(ds, k_Mask, {eulers.size()}, 1, parentId); + maskRef.fill(255); + for(auto tupleIdx : excluded) + { + maskRef[tupleIdx] = 0; + } + } + else + { + auto& maskRef = CreateArray(ds, k_Mask, {eulers.size()}, 1, parentId); + maskRef.fill(true); + for(auto tupleIdx : excluded) + { + maskRef[tupleIdx] = 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: 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) + { + // 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(masked || !hexagonal || 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); + 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 == 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) + { + 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]") +{ + 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& 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 = {0, 3}; + SECTION("Unknown crystal structure produces NaN without a warning") + { + } + SECTION("Cubic phase produces NaN and counts only the known non-hexagonal tuple") + { + phasesRef[1] = 3; + expectedNonHexagonalCount = 1; + excluded.push_back(1); + } + SECTION("Cubic and trigonal phases count both known non-hexagonal tuples") + { + phasesRef[1] = 3; + phasesRef[4] = 4; + expectedNonHexagonalCount = 2; + excluded = {0, 1, 3, 4}; + } + const Result<> result = ExecuteInScope(ds, MakeArgs(), scope); + CheckOutput(ds, eulers, {0, 0, 1}, excluded); + if(expectedNonHexagonalCount == 0) + { + REQUIRE(result.warnings().empty()); + } + else + { + 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]") +{ + 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& phasesRef = ds.getDataRefAs(k_Phases); + phasesRef[1] = 3; + phasesRef[2] = 4; + REQUIRE_NOTHROW(ds.getDataRefAs(k_Quats.getParent())); + const auto parentId = ds.getDataRefAs(k_Quats.getParent()).getId(); + if(byteMask) + { + auto& maskRef = CreateArray(ds, k_Mask, {6}, 1, parentId); + maskRef.fill(1); + maskRef[1] = 0; + maskRef[2] = 0; + } + else + { + 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}; + SECTION("All non-hexagonal tuples masked") + { + } + SECTION("One unmasked cubic tuple is counted") + { + phasesRef[4] = 3; + expectedNonHexagonalCount = 1; + excluded.push_back(4); + } + const Result<> result = ExecuteInScope(ds, MakeArgs({0, 0, 1}, true), scope); + CheckOutput(ds, eulers, {0, 0, 1}, excluded); + if(expectedNonHexagonalCount == 0) + { + REQUIRE(result.warnings().empty()); + } + else + { + REQUIRE(result.warnings().size() == 1); + REQUIRE(result.warnings()[0].code == AlgorithmType::k_NonHexagonalPhasesWarning); + REQUIRE(result.warnings()[0].message.find("1 tuples") == 0); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: zero quaternion is undefined", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + 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& quatsRef = ds.getDataRefAs(k_Quats); + for(usize compIdx = 0; compIdx < 4; ++compIdx) + { + quatsRef[4 * (count - 1) + compIdx] = 0.0F; + } + ExecuteAndCheck(ds, MakeArgs(), scope); + // A zero quaternion defines no orientation, so it cannot have a finite angle. + CheckOutput(ds, eulers, {0, 0, 1}, {count - 1}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: nonfinite quaternions", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const usize compIdx = GENERATE(0, 1, 2, 3); + const float32 value = GENERATE(as{}, k_NaN, std::numeric_limits::infinity(), -std::numeric_limits::infinity()); + CAPTURE(compIdx, 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)[compIdx] = value; + ExecuteAndCheck(ds, MakeArgs(), scope); + CheckOutput(ds, eulers, {0, 0, 1}, {0}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("OrientationAnalysis::ComputeCAxisReferenceMisalignments: quaternion scale invariance", "[OrientationAnalysis][ComputeCAxisReferenceMisalignments]") +{ + 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& quatsRef = ds.getDataRefAs(k_Quats); + for(usize valueIdx = 0; valueIdx < quatsRef.getSize(); ++valueIdx) + { + quatsRef[valueIdx] *= 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]") +{ + 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]") +{ + 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 chunk boundary, this phase gives k_PhaseOutOfRangeError. + ds.getDataRefAs(k_Phases)[4096] = -1; + const IFilter::MessageHandler messageHandler{[&shouldCancel](const IFilter::Message& message) { + if(message.type == IFilter::Message::Type::Progress) + { + shouldCancel = true; + } + }}; + 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) + { + REQUIRE(ds.getDataAs(k_Output) == nullptr); + } + else + { + REQUIRE(shouldCancel.load()); + 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); + } +}