diff --git a/src/Plugins/OrientationAnalysis/CMakeLists.txt b/src/Plugins/OrientationAnalysis/CMakeLists.txt index 4ae8d0aea0..6510870999 100644 --- a/src/Plugins/OrientationAnalysis/CMakeLists.txt +++ b/src/Plugins/OrientationAnalysis/CMakeLists.txt @@ -45,6 +45,7 @@ set(FilterList ComputeFeatureNeighborCAxisMisalignmentsFilter ComputeFeatureReferenceCAxisMisorientationsFilter ComputeFeatureReferenceMisorientationsFilter + ComputeCAxisMisorientationsFilter ComputeFZQuaternionsFilter ComputeGBCDFilter ComputeGBCDMetricBasedFilter @@ -188,6 +189,7 @@ set(filter_algorithms ComputeFeatureNeighborMisorientations ComputeFeatureReferenceCAxisMisorientations ComputeFeatureReferenceMisorientations + ComputeCAxisMisorientations ComputeFZQuaternions ComputeGBCD ComputeGBCDMetricBased diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeCAxisMisorientationsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisMisorientationsFilter.md new file mode 100644 index 0000000000..76b57f87d3 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/docs/ComputeCAxisMisorientationsFilter.md @@ -0,0 +1,47 @@ +# Compute C-Axis Misorientations + +## Group (Subgroup) + +Statistics (Crystallography) + +## Description + +This **Filter** computes the angle between each **Feature**'s average C-axis direction and a user-supplied reference direction. The result is a scalar angle in degrees per **Feature**, indicating how closely the grain's C-axis is aligned with a direction of interest in the sample reference frame — such as a loading axis, rolling direction, or sample normal. + +### Relationship to C-Axis Misalignment + +This filter differs from [Compute Feature Neighbor C-Axis Misalignments](ComputeFeatureNeighborCAxisMisalignmentsFilter.md), which measures the angle *between neighboring grains*. This filter instead measures the angle *between each grain and a fixed reference direction*, making it useful for global texture analysis. + +### Prerequisite + +The **Average C-Axes** input must be produced by [Compute Average C-Axis Orientations](ComputeAvgCAxesFilter.md) before running this filter. The reference direction is specified in the sample reference frame. + +### Hexagonal Materials Only + +Because the C-axis is only well-defined in hexagonal crystal systems, [Compute Average C-Axis Orientations](ComputeAvgCAxesFilter.md) marks non-hexagonal **Features** with NaN. This filter propagates those NaN values directly to the output without additional phase checking. + +### Result Range + +The C-axis has antipodal symmetry — the [001] and [00-1] directions are crystallographically identical. The output is therefore always in **[0°, 90°]**, where 0° means the C-axis is parallel (or antiparallel) to the reference direction and 90° means it is perpendicular. + +## Algorithm + +For each **Feature**, the filter computes the absolute dot product of the (already-normalized) average C-axis vector with the normalized reference direction, then takes the arc-cosine and converts to degrees: + +``` +angle = arccos( |avgCAxis · refDir| ) × (180 / π) +``` + +**Features** whose average C-axis is NaN (non-hexagonal phases) produce NaN in the output. + +% Auto generated parameter table will be inserted here + +## Example Pipelines + +## 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/ComputeCAxisMisorientations.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.cpp new file mode 100644 index 0000000000..b98cf4f21e --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.cpp @@ -0,0 +1,111 @@ +#include "ComputeCAxisMisorientations.hpp" + +#include "simplnx/Common/Constants.hpp" +#include "simplnx/DataStructure/DataArray.hpp" + +#include + +#include +#include +#include + +using namespace nx::core; + +namespace +{ +constexpr usize k_CAxisComponents = 3; +// Feature arrays are small relative to cell arrays, but we still chunk for OOC correctness. +constexpr usize k_MaxChunkTuples = 65536; +// Minimum acceptable magnitude for the reference direction before normalization. +constexpr float64 k_MinRefDirNorm = 1.0e-10; +} // namespace + +// ----------------------------------------------------------------------------- +ComputeCAxisMisorientations::ComputeCAxisMisorientations(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, + ComputeCAxisMisorientationsInputValues* inputValues) +: m_DataStructure(dataStructure) +, m_InputValues(inputValues) +, m_ShouldCancel(shouldCancel) +, m_MessageHandler(mesgHandler) +{ +} + +// ----------------------------------------------------------------------------- +ComputeCAxisMisorientations::~ComputeCAxisMisorientations() noexcept = default; + +// ----------------------------------------------------------------------------- +Result<> ComputeCAxisMisorientations::operator()() +{ + // Normalize the reference direction once. Both vectors must be unit length for + // arccos(dot) to give the correct angle. + const float64 rx = m_InputValues->ReferenceDir[0]; + const float64 ry = m_InputValues->ReferenceDir[1]; + const float64 rz = m_InputValues->ReferenceDir[2]; + const float64 refNorm = std::sqrt(rx * rx + ry * ry + rz * rz); + if(refNorm < k_MinRefDirNorm) + { + return MakeErrorResult(-77001, "Reference direction has zero magnitude. Provide a non-zero reference direction."); + } + const float64 refX = rx / refNorm; + const float64 refY = ry / refNorm; + const float64 refZ = rz / refNorm; + + const auto& avgCAxesStoreRef = m_DataStructure.getDataRefAs(m_InputValues->AvgCAxesArrayPath).getDataStoreRef(); + auto& misorientationsStoreRef = m_DataStructure.getDataRefAs(m_InputValues->MisorientationArrayPath).getDataStoreRef(); + + const usize totalFeatures = avgCAxesStoreRef.getNumberOfTuples(); + if(totalFeatures == 0) + { + return {}; + } + + const usize chunkTuples = std::min(k_MaxChunkTuples, totalFeatures); + const auto avgCAxesBuffer = std::make_unique(chunkTuples * k_CAxisComponents); + const auto misorientationsBuffer = std::make_unique(chunkTuples); + + m_MessageHandler.sendMessage({IFilter::Message::Type::Info, "Computing C-axis misorientations"}); + + for(usize tupleOffset = 0; tupleOffset < totalFeatures; tupleOffset += chunkTuples) + { + if(m_ShouldCancel) + { + return {}; + } + + const usize tupleCount = std::min(chunkTuples, totalFeatures - tupleOffset); + + if(Result<> ioResult = avgCAxesStoreRef.copyIntoBuffer(tupleOffset * k_CAxisComponents, nonstd::span(avgCAxesBuffer.get(), tupleCount * k_CAxisComponents)); ioResult.invalid()) + { + return ioResult; + } + + for(usize tupleIdx = 0; tupleIdx < tupleCount; tupleIdx++) + { + const usize cAxisOffset = tupleIdx * k_CAxisComponents; + const float32 cx = avgCAxesBuffer[cAxisOffset]; + const float32 cy = avgCAxesBuffer[cAxisOffset + 1]; + const float32 cz = avgCAxesBuffer[cAxisOffset + 2]; + + // ComputeAvgCAxes marks non-hexagonal features with NaN on all three components. + if(std::isnan(cx)) + { + misorientationsBuffer[tupleIdx] = std::numeric_limits::quiet_NaN(); + continue; + } + + // AvgCAxes vectors are already normalized. The c-axis has antipodal symmetry + // ([001] == [00-1] in hexagonal), so take |dot| to keep the result in [0°, 90°]. + const float64 dot = std::clamp(std::abs(cx * refX + cy * refY + cz * refZ), 0.0, 1.0); + misorientationsBuffer[tupleIdx] = static_cast(std::acos(dot) * Constants::k_180OverPiD); + } + + if(Result<> ioResult = misorientationsStoreRef.copyFromBuffer(tupleOffset, nonstd::span(misorientationsBuffer.get(), tupleCount)); ioResult.invalid()) + { + return ioResult; + } + } + + m_MessageHandler.sendMessage({IFilter::Message::Type::Info, "C-axis misorientation computation complete"}); + + return {}; +} diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.hpp new file mode 100644 index 0000000000..63c9640800 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.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 ComputeCAxisMisorientationsInputValues + * @brief Identifies c-axis misorientation inputs. + * + * AvgCAxes must be a feature-level float32[3] array produced by + * ComputeAvgCAxes. Non-hexagonal features are encoded as NaN by that + * algorithm; this executor propagates those NaN values to the output. + */ +struct ORIENTATIONANALYSIS_EXPORT ComputeCAxisMisorientationsInputValues +{ + std::vector ReferenceDir; + DataPath AvgCAxesArrayPath; + DataPath MisorientationArrayPath; +}; + +/** + * @class ComputeCAxisMisorientations + * @brief Computes the angle between each feature's average c-axis and a + * user-supplied reference direction. + * + * The c-axis has antipodal symmetry in hexagonal systems, so the result is + * arccos(|cAxis · refDir|) converted to degrees, always in [0°, 90°]. + * + * The executor reads AvgCAxes in bounded chunks for OOC compatibility. + * Non-hexagonal features (NaN in AvgCAxes) produce NaN in the output. + */ +class ORIENTATIONANALYSIS_EXPORT ComputeCAxisMisorientations +{ +public: + /** + * @brief Initializes c-axis misorientation computation. + * @param dataStructure Provides the selected arrays. + * @param mesgHandler Supplies progress messages. + * @param shouldCancel Signals cancellation. + * @param inputValues Identifies selected arrays and reference direction. + * @pre dataStructure, mesgHandler, shouldCancel, and inputValues outlive + * this executor. + */ + ComputeCAxisMisorientations(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ComputeCAxisMisorientationsInputValues* inputValues); + + /** + * @brief Destroys the c-axis misorientation executor. + */ + ~ComputeCAxisMisorientations() noexcept; + + ComputeCAxisMisorientations(const ComputeCAxisMisorientations&) = delete; + ComputeCAxisMisorientations(ComputeCAxisMisorientations&&) noexcept = delete; + ComputeCAxisMisorientations& operator=(const ComputeCAxisMisorientations&) = delete; + ComputeCAxisMisorientations& operator=(ComputeCAxisMisorientations&&) noexcept = delete; + + /** + * @brief Computes c-axis misorientations. + * @return An error if the reference direction has zero magnitude or bulk I/O + * fails. Cancellation returns success with completed chunks preserved. + */ + Result<> operator()(); + +private: + DataStructure& m_DataStructure; + const ComputeCAxisMisorientationsInputValues* m_InputValues = nullptr; + const std::atomic_bool& m_ShouldCancel; + const IFilter::MessageHandler& m_MessageHandler; +}; + +} // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.cpp new file mode 100644 index 0000000000..0fcf979ee3 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.cpp @@ -0,0 +1,123 @@ +#include "ComputeCAxisMisorientationsFilter.hpp" + +#include "OrientationAnalysis/Filters/Algorithms/ComputeCAxisMisorientations.hpp" + +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Parameters/ArraySelectionParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" + +using namespace nx::core; + +namespace +{ +// Squared-magnitude threshold below which the reference direction is treated as zero. +constexpr float64 k_MinRefDirSquaredNorm = 1.0e-20; +} // namespace + +namespace nx::core +{ +//------------------------------------------------------------------------------ +std::string ComputeCAxisMisorientationsFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string ComputeCAxisMisorientationsFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid ComputeCAxisMisorientationsFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string ComputeCAxisMisorientationsFilter::humanName() const +{ + return "Compute C-Axis Misorientations"; +} + +//------------------------------------------------------------------------------ +std::vector ComputeCAxisMisorientationsFilter::defaultTags() const +{ + return {className(), "Statistics", "Crystallography"}; +} + +//------------------------------------------------------------------------------ +Parameters ComputeCAxisMisorientationsFilter::parameters() const +{ + Parameters params; + + params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); + params.insert(std::make_unique(k_ReferenceDir_Key, "Reference Direction", "The reference axis with respect to which the C-axis misorientation is computed", + std::vector{0.0F, 0.0F, 1.0F}, std::vector{"X", "Y", "Z"})); + + params.insertSeparator(Parameters::Separator{"Input Feature Data"}); + params.insert(std::make_unique(k_AvgCAxesArrayPath_Key, "Average C-Axes", "The direction of each Feature's C-axis in the sample reference frame", DataPath{}, + ArraySelectionParameter::AllowedTypes{DataType::float32}, ArraySelectionParameter::AllowedComponentShapes{{3}})); + + params.insertSeparator(Parameters::Separator{"Output Feature Data"}); + params.insert(std::make_unique( + k_MisorientationArrayName_Key, "Misorientation Array Name", + "Name of the output feature-level array storing the misorientation angle (in degrees) between each Feature's C-axis and the reference direction", "ReferenceOrientationMisorientation")); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType ComputeCAxisMisorientationsFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer ComputeCAxisMisorientationsFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult ComputeCAxisMisorientationsFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + const auto pAvgCAxesArrayPathValue = filterArgs.value(k_AvgCAxesArrayPath_Key); + const auto pMisorientationArrayNameValue = filterArgs.value(k_MisorientationArrayName_Key); + + const auto pReferenceDirValue = filterArgs.value>(k_ReferenceDir_Key); + + Result resultOutputActions; + std::vector preflightUpdatedValues; + + const float64 rx = pReferenceDirValue[0]; + const float64 ry = pReferenceDirValue[1]; + const float64 rz = pReferenceDirValue[2]; + if((rx * rx + ry * ry + rz * rz) < k_MinRefDirSquaredNorm) + { + return {MakeErrorResult(-77000, "Reference direction has zero magnitude. Provide a non-zero reference direction.")}; + } + + const ShapeType tupleShape = dataStructure.getDataRefAs(pAvgCAxesArrayPathValue).getTupleShape(); + auto createArrayAction = std::make_unique(DataType::float32, tupleShape, std::vector{1}, pAvgCAxesArrayPathValue.replaceName(pMisorientationArrayNameValue)); + resultOutputActions.value().appendAction(std::move(createArrayAction)); + + return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; +} + +//------------------------------------------------------------------------------ +Result<> ComputeCAxisMisorientationsFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + ComputeCAxisMisorientationsInputValues inputValues; + + inputValues.ReferenceDir = filterArgs.value>(k_ReferenceDir_Key); + inputValues.AvgCAxesArrayPath = filterArgs.value(k_AvgCAxesArrayPath_Key); + inputValues.MisorientationArrayPath = inputValues.AvgCAxesArrayPath.replaceName(filterArgs.value(k_MisorientationArrayName_Key)); + + return ComputeCAxisMisorientations(dataStructure, messageHandler, shouldCancel, &inputValues)(); +} +} // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.hpp new file mode 100644 index 0000000000..5f06e47792 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include "OrientationAnalysis/OrientationAnalysis_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class ComputeCAxisMisorientationsFilter + * @brief Computes the angle between each feature's average C-axis and a + * user-supplied reference direction in the sample reference frame. + * + * Input C-axes must be produced by ComputeAvgCAxesFilter. Non-hexagonal + * features (encoded as NaN by that filter) produce NaN in the output. + * Results are always in [0°, 90°] due to C-axis antipodal symmetry. + * Preflight rejects a zero-magnitude reference direction. + */ +class ORIENTATIONANALYSIS_EXPORT ComputeCAxisMisorientationsFilter : public IFilter +{ +public: + ComputeCAxisMisorientationsFilter() = default; + ~ComputeCAxisMisorientationsFilter() noexcept override = default; + + ComputeCAxisMisorientationsFilter(const ComputeCAxisMisorientationsFilter&) = delete; + ComputeCAxisMisorientationsFilter(ComputeCAxisMisorientationsFilter&&) noexcept = delete; + + ComputeCAxisMisorientationsFilter& operator=(const ComputeCAxisMisorientationsFilter&) = delete; + ComputeCAxisMisorientationsFilter& operator=(ComputeCAxisMisorientationsFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_ReferenceDir_Key = "reference_dir"; + static constexpr StringLiteral k_AvgCAxesArrayPath_Key = "avg_c_axes_array_path"; + static constexpr StringLiteral k_MisorientationArrayName_Key = "misorientation_array_name"; + + /** + * @brief Returns the name of the filter. + * @return + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return + */ + Uuid uuid() const override; + + /** + * @brief Returns the human-readable name of the filter. + * @return + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return + */ + std::vector defaultTags() const override; + + /** + * @brief Returns the parameters of the filter (i.e. its inputs) + * @return + */ + Parameters parameters() const override; + + /** + * @brief Returns parameters version integer. + * The Initial version should always be 1. + * Should be incremented everytime the parameters change. + * @return VersionType + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Takes in a DataStructure and checks that the filter can be run on it with the given arguments. + * Returns any warnings/errors. Also returns the changes that would be applied to the DataStructure. + * Some parts of the actions may not be completely filled out if all the required information is not available at preflight time. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Applies the filter's algorithm to the DataStructure with the given arguments. Returns any warnings/errors. + * On failure, there is no guarantee that the DataStructure is in a correct state. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param pipelineNode The node in the pipeline that is being executed + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + 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, ComputeCAxisMisorientationsFilter, "0d5cf4a2-12cf-4d1e-b38e-33a464136fce"); diff --git a/src/Plugins/OrientationAnalysis/test/CMakeLists.txt b/src/Plugins/OrientationAnalysis/test/CMakeLists.txt index 40b09db423..0712e52a3b 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 + ComputeCAxisMisorientationsTest.cpp ComputeFaceIPFColoringTest.cpp ComputeFeatureFaceMisorientationTest.cpp ComputeFeatureNeighborCAxisMisalignmentsTest.cpp diff --git a/src/Plugins/OrientationAnalysis/test/ComputeCAxisMisorientationsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeCAxisMisorientationsTest.cpp new file mode 100644 index 0000000000..598f9ba878 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/test/ComputeCAxisMisorientationsTest.cpp @@ -0,0 +1,287 @@ +#include "OrientationAnalysis/Filters/ComputeCAxisMisorientationsFilter.hpp" + +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataGroup.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" + +#include + +#include +#include + +using namespace nx::core; + +namespace +{ +namespace CAxisMisorientationFixtures +{ +// A flat DataGroup > AttributeMatrix hierarchy is sufficient — this filter requires no geometry. +const auto k_FeatureDataPath = DataPath{{"TestData", "FeatureData"}}; +const DataPath k_AvgCAxesPath = k_FeatureDataPath.createChildPath("AvgCAxes"); +const std::string k_MisorientationsName = "CAxisMisorientations"; +const DataPath k_MisorientationsPath = k_FeatureDataPath.createChildPath(k_MisorientationsName); + +struct FixtureData +{ + DataStructure ds; + Float32Array* avgCAxes = nullptr; + usize numFeatures = 0; +}; + +// Build a minimal DataStructure with a feature-level AvgCAxes array. +// All features are initialized to NaN, matching the ComputeAvgCAxes sentinel convention for +// features that either have no cells or belong to a non-hexagonal phase. +FixtureData CreateScaffold(usize numFeatures) +{ + FixtureData td; + td.numFeatures = numFeatures; + + const auto* dataGroup = DataGroup::Create(td.ds, "TestData"); + const auto* featureAM = AttributeMatrix::Create(td.ds, "FeatureData", ShapeType{numFeatures}, dataGroup->getId()); + const auto avgCAxesStore = DataStoreUtilities::CreateDataStore(td.ds, k_AvgCAxesPath, {numFeatures}, {3}); + td.avgCAxes = Float32Array::Create(td.ds, "AvgCAxes", avgCAxesStore, featureAM->getId()); + + const float32 nan = std::numeric_limits::quiet_NaN(); + for(usize i = 0; i < numFeatures * 3; ++i) + { + (*td.avgCAxes)[i] = nan; + } + + return td; +} + +void SetCAxis(FixtureData& td, usize featureIdx, float32 cx, float32 cy, float32 cz) +{ + (*td.avgCAxes)[featureIdx * 3 + 0] = cx; + (*td.avgCAxes)[featureIdx * 3 + 1] = cy; + (*td.avgCAxes)[featureIdx * 3 + 2] = cz; +} + +Arguments BuildArgs(std::vector refDir) +{ + Arguments args; + args.insertOrAssign(ComputeCAxisMisorientationsFilter::k_ReferenceDir_Key, std::make_any>(std::move(refDir))); + args.insertOrAssign(ComputeCAxisMisorientationsFilter::k_AvgCAxesArrayPath_Key, std::make_any(k_AvgCAxesPath)); + args.insertOrAssign(ComputeCAxisMisorientationsFilter::k_MisorientationArrayName_Key, std::make_any(k_MisorientationsName)); + return args; +} + +const Float32Array& GetOutput(const DataStructure& ds) +{ + REQUIRE_NOTHROW(ds.getDataRefAs(k_MisorientationsPath)); + return ds.getDataRefAs(k_MisorientationsPath); +} + +} // namespace CAxisMisorientationFixtures +} // namespace + +// ===================================================================================== +// Preflight error +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeCAxisMisorientationsFilter: Preflight Error - Zero Reference Direction (-77000)", "[OrientationAnalysis][ComputeCAxisMisorientationsFilter][preflight]") +{ + UnitTest::LoadPlugins(); + + // A zero-magnitude reference direction is a user error that cannot be caught at parameter + // entry time, so the filter must reject it during preflight. + auto td = CAxisMisorientationFixtures::CreateScaffold(2); + CAxisMisorientationFixtures::SetCAxis(td, 1, 0.0f, 0.0f, 1.0f); + + ComputeCAxisMisorientationsFilter filter; + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 0.0f}); + + const auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors()[0].code == -77000); +} + +// ===================================================================================== +// Class 1 (Analytical) — exact expected values derived from pure vector geometry. +// +// Reference direction is [0, 0, 1] (global Z) for every section. The expected angle is +// arccos(|cAxis · refDir|) converted to degrees. The antipodal cases (antiparallel and +// folded antiparallel) verify that the |dot| in the algorithm keeps results in [0°, 90°]. +// +// Feature 0 is the sentinel (NaN in all sections). Feature 1 is the test feature. +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeCAxisMisorientationsFilter: Class 1 - Analytical Angles", "[OrientationAnalysis][ComputeCAxisMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Shared constants for intermediate angles. + static const float32 k_Sqrt2Inv = 1.0f / std::sqrt(2.0f); + static const float32 k_Sqrt3Over2 = std::sqrt(3.0f) / 2.0f; + static constexpr float32 k_Half = 0.5f; + + // CreateScaffold and the filter are re-constructed fresh for each SECTION by Catch2. + auto td = CAxisMisorientationFixtures::CreateScaffold(2); + ComputeCAxisMisorientationsFilter filter; + + SECTION("Aligned: c=[0,0,1] vs ref[0,0,1] => 0°") + { + CAxisMisorientationFixtures::SetCAxis(td, 1, 0.0f, 0.0f, 1.0f); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(0.0f).margin(1e-3f)); + } + + SECTION("Perpendicular: c=[1,0,0] vs ref[0,0,1] => 90°") + { + CAxisMisorientationFixtures::SetCAxis(td, 1, 1.0f, 0.0f, 0.0f); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(90.0f).margin(1e-3f)); + } + + SECTION("45°: c=[1/√2, 0, 1/√2] vs ref[0,0,1]") + { + CAxisMisorientationFixtures::SetCAxis(td, 1, k_Sqrt2Inv, 0.0f, k_Sqrt2Inv); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(45.0f).margin(1e-3f)); + } + + SECTION("30°: c=[0.5, 0, √3/2] vs ref[0,0,1]") + { + CAxisMisorientationFixtures::SetCAxis(td, 1, k_Half, 0.0f, k_Sqrt3Over2); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(30.0f).margin(1e-3f)); + } + + SECTION("Antiparallel: c=[0,0,-1] vs ref[0,0,1] => 0° (antipodal symmetry)") + { + // The c-axis [001] and [00-1] are crystallographically equivalent in hexagonal. + // The |dot| in the algorithm must fold this back to 0° rather than 180°. + CAxisMisorientationFixtures::SetCAxis(td, 1, 0.0f, 0.0f, -1.0f); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(0.0f).margin(1e-3f)); + } + + SECTION("Folded antiparallel 30°: c=[-0.5, 0, -√3/2] vs ref[0,0,1] => 30°") + { + // Same as the 30° case but with the c-axis flipped. Both hemispheres must give the same angle. + CAxisMisorientationFixtures::SetCAxis(td, 1, -k_Half, 0.0f, -k_Sqrt3Over2); + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + REQUIRE(output[1] == Approx(30.0f).margin(1e-3f)); + } +} + +// ===================================================================================== +// NaN propagation +// +// ComputeAvgCAxes writes NaN for non-hexagonal features. This test verifies that NaN +// propagates to the output for those features and does not corrupt adjacent valid features. +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeCAxisMisorientationsFilter: NaN Propagation - Non-Hexagonal Features", "[OrientationAnalysis][ComputeCAxisMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Feature layout: + // F0: NaN (sentinel — no cells belong to feature 0) + // F1: [0,0,1] => 0° (valid hex) + // F2: NaN (non-hexagonal phase — marked by ComputeAvgCAxes) + // F3: [1,0,0] => 90° (valid hex, adjacent to the NaN feature) + auto td = CAxisMisorientationFixtures::CreateScaffold(4); + CAxisMisorientationFixtures::SetCAxis(td, 1, 0.0f, 0.0f, 1.0f); + // F2 is left as NaN (scaffold initializes all features to NaN). + CAxisMisorientationFixtures::SetCAxis(td, 3, 1.0f, 0.0f, 0.0f); + + ComputeCAxisMisorientationsFilter filter; + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + + REQUIRE(std::isnan(output[0])); + REQUIRE(output[1] == Approx(0.0f).margin(1e-3f)); + REQUIRE(std::isnan(output[2])); + REQUIRE(output[3] == Approx(90.0f).margin(1e-3f)); +} + +// ===================================================================================== +// Class 4 (Invariants) — oracle-agnostic properties that hold for any valid input. +// ===================================================================================== + +TEST_CASE("OrientationAnalysis::ComputeCAxisMisorientationsFilter: Class 4 - Invariants", "[OrientationAnalysis][ComputeCAxisMisorientationsFilter]") +{ + UnitTest::LoadPlugins(); + + // Mixed fixture: F0=sentinel(NaN), F1=0°, F2=90°, F3=30°, F4=NaN (non-hex). + static const float32 k_Sqrt3Over2 = std::sqrt(3.0f) / 2.0f; + static constexpr float32 k_Half = 0.5f; + + auto td = CAxisMisorientationFixtures::CreateScaffold(5); + CAxisMisorientationFixtures::SetCAxis(td, 1, 0.0f, 0.0f, 1.0f); + CAxisMisorientationFixtures::SetCAxis(td, 2, 1.0f, 0.0f, 0.0f); + CAxisMisorientationFixtures::SetCAxis(td, 3, k_Half, 0.0f, k_Sqrt3Over2); + // F0 and F4 are left as NaN by the scaffold. + + ComputeCAxisMisorientationsFilter filter; + const Arguments args = CAxisMisorientationFixtures::BuildArgs({0.0f, 0.0f, 1.0f}); + auto preflightResult = filter.preflight(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& output = CAxisMisorientationFixtures::GetOutput(td.ds); + + SECTION("(i) Valid outputs are in [0°, 90°]") + { + for(usize f = 0; f < td.numFeatures; ++f) + { + if(!std::isnan(output[f])) + { + constexpr float32 k_CAxisUpperBoundDeg = 90.0f; + REQUIRE(output[f] >= 0.0f); + REQUIRE(output[f] <= k_CAxisUpperBoundDeg); + } + } + } + + SECTION("(ii) NaN in => NaN out; non-NaN in => non-NaN out") + { + const auto& avgCAxes = *td.avgCAxes; + for(usize f = 0; f < td.numFeatures; ++f) + { + const bool inputIsNaN = std::isnan(avgCAxes[f * 3]); + const bool outputIsNaN = std::isnan(output[f]); + REQUIRE(inputIsNaN == outputIsNaN); + } + } +}