The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
370 lines
21 KiB
C++
370 lines
21 KiB
C++
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <limits>
|
|
#include <numbers>
|
|
#include <span>
|
|
#include <vector>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include "serif/geometry/deformation/actions.hpp"
|
|
#include "serif/geometry/deformation/surface_embedding.hpp"
|
|
|
|
namespace {
|
|
namespace deformation = serif::geometry::deformation;
|
|
|
|
/** @brief Resolve low harmonic degrees without relying on a particular mesh backend. */
|
|
[[nodiscard]] std::vector<std::array<double, 3>> surface_positions(const std::array<double, 3>& center = {}) {
|
|
std::vector<std::array<double, 3>> positions;
|
|
constexpr std::size_t node_count = 96;
|
|
const double golden_angle = std::numbers::pi * (3.0 - std::sqrt(5.0));
|
|
for (std::size_t node = 0; node < node_count; ++node) {
|
|
const double cosine = 1.0 - 2.0 * (static_cast<double>(node) + 0.5) / node_count;
|
|
const double sine = std::sqrt(1.0 - cosine * cosine);
|
|
const double azimuth = golden_angle * static_cast<double>(node);
|
|
const double radius = 2.0 * (1.0 + 0.1 * cosine * cosine);
|
|
positions.push_back({center[0] + radius * sine * std::cos(azimuth), center[1] + radius * sine * std::sin(azimuth), center[2] + radius * cosine});
|
|
}
|
|
positions.push_back({center[0], center[1], center[2] + 2.0});
|
|
positions.push_back({center[0], center[1], center[2] - 2.0});
|
|
return positions;
|
|
}
|
|
|
|
/** @brief Select an analytic mode independently of its expected position in the ordered basis. */
|
|
[[nodiscard]] std::size_t mode_index(const deformation::SurfaceEmbedding& surface, int degree, int order) {
|
|
const auto selected = std::ranges::find(surface.modes(), deformation::SphericalHarmonicMode{.degree = degree, .order = order});
|
|
REQUIRE(selected != surface.modes().end());
|
|
return static_cast<std::size_t>(selected - surface.modes().begin());
|
|
}
|
|
|
|
/** @brief Use deterministic nonconstant data to expose ordering errors in displacement and force actions. */
|
|
[[nodiscard]] std::vector<double> varying_values(std::size_t size, double scale = 1.0) {
|
|
std::vector<double> result(size);
|
|
for (std::size_t index = 0; index < size; ++index) {
|
|
result[index] = scale * std::sin(0.37 * static_cast<double>(index + 1));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** @brief Evaluate virtual work without depending on the tested transpose implementation. */
|
|
[[nodiscard]] double work(std::span<const double> first, std::span<const double> second) {
|
|
REQUIRE(first.size() == second.size());
|
|
long double result = 0.0L;
|
|
for (std::size_t index = 0; index < first.size(); ++index) {
|
|
result += static_cast<long double>(first[index]) * second[index];
|
|
}
|
|
return static_cast<double>(result);
|
|
}
|
|
|
|
/** @brief Compare against a centered finite difference and independent dual work at MeanField tolerances. */
|
|
void check_linear_actions(const deformation::SurfaceEmbedding& surface) {
|
|
const std::vector<double> parameters = varying_values(surface.input_size(), 0.03);
|
|
const std::vector<double> direction = varying_values(surface.input_size(), 0.07);
|
|
const std::vector<double> force = varying_values(surface.output_size());
|
|
std::vector<double> action(surface.output_size());
|
|
std::vector<double> transferred(surface.input_size());
|
|
REQUIRE(surface.apply(direction, action));
|
|
REQUIRE(surface.apply_transpose(force, transferred));
|
|
const double scale = std::max({1.0, std::sqrt(work(action, action) * work(force, force)), std::sqrt(work(direction, direction) * work(transferred, transferred))});
|
|
CHECK(std::abs(work(action, force) - work(direction, transferred)) < 2e-13 * scale);
|
|
|
|
constexpr double epsilon = 1e-5;
|
|
std::vector<double> plus(parameters);
|
|
std::vector<double> minus(parameters);
|
|
for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) {
|
|
plus[parameter] += epsilon * direction[parameter];
|
|
minus[parameter] -= epsilon * direction[parameter];
|
|
}
|
|
std::vector<double> plus_value(surface.output_size());
|
|
std::vector<double> minus_value(surface.output_size());
|
|
REQUIRE(surface.apply(plus, plus_value));
|
|
REQUIRE(surface.apply(minus, minus_value));
|
|
for (std::size_t index = 0; index < action.size(); ++index) {
|
|
plus_value[index] = (plus_value[index] - minus_value[index]) / (2.0 * epsilon) - action[index];
|
|
}
|
|
CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-10);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Nodal surface coordinates remain physical radial displacement lengths", "[unit][deformation]") {
|
|
const std::array<double, 3> center{0.3, -0.2, 0.4};
|
|
const auto positions = surface_positions(center);
|
|
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, positions);
|
|
REQUIRE(surface);
|
|
CHECK(surface->has_nodal_parameters());
|
|
CHECK(surface->modes().empty());
|
|
CHECK(surface->nodal_basis().empty());
|
|
REQUIRE(surface->input_size() == positions.size());
|
|
REQUIRE(surface->output_size() == 3 * positions.size());
|
|
std::vector<double> parameters(surface->input_size(), 0.125);
|
|
std::vector<double> displacement(surface->output_size(), 8.0);
|
|
REQUIRE(surface->apply(parameters, displacement));
|
|
for (std::size_t node = 0; node < positions.size(); ++node) {
|
|
double moved_radius_squared = 0.0;
|
|
for (std::size_t component = 0; component < 3; ++component) {
|
|
const double radial_coordinate = positions[node][component] - center[component];
|
|
CHECK(std::abs(surface->radial_directions()[node][component] * surface->reference_radii()[node] - radial_coordinate) < 2e-15);
|
|
CHECK(displacement[3 * node + component] == 0.125 * surface->radial_directions()[node][component]);
|
|
moved_radius_squared += std::pow(radial_coordinate + displacement[3 * node + component], 2);
|
|
}
|
|
CHECK(std::abs(std::sqrt(moved_radius_squared) - surface->reference_radii()[node] - 0.125) < 2e-15);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Nodal surface actions preserve exact derivatives and virtual work", "[unit][deformation]") {
|
|
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions());
|
|
REQUIRE(surface);
|
|
check_linear_actions(*surface);
|
|
}
|
|
|
|
TEST_CASE("Nodal radial derivatives retain MeanField nonproportional directions and original work tolerances", "[unit][deformation]") {
|
|
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions());
|
|
REQUIRE(surface);
|
|
std::vector<double> parameters(surface->input_size());
|
|
std::vector<double> direction(surface->input_size());
|
|
std::vector<double> plus(surface->input_size());
|
|
std::vector<double> minus(surface->input_size());
|
|
constexpr double step = 1e-6;
|
|
for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) {
|
|
const double index = static_cast<double>(parameter + 1);
|
|
parameters[parameter] = 0.013 * std::sin(0.37 * index);
|
|
direction[parameter] = std::cos(0.19 * index) - 0.21 * std::sin(0.43 * index);
|
|
plus[parameter] = parameters[parameter] + step * direction[parameter];
|
|
minus[parameter] = parameters[parameter] - step * direction[parameter];
|
|
}
|
|
std::vector<double> plus_value(surface->output_size());
|
|
std::vector<double> minus_value(surface->output_size());
|
|
std::vector<double> action(surface->output_size());
|
|
auto workspace = deformation::make_deformation_workspace(*surface);
|
|
REQUIRE(surface->apply(plus, plus_value));
|
|
REQUIRE(surface->apply(minus, minus_value));
|
|
REQUIRE(deformation::apply_jacobian(*surface, parameters, direction, action, workspace));
|
|
for (std::size_t entry = 0; entry < action.size(); ++entry) {
|
|
plus_value[entry] = (plus_value[entry] - minus_value[entry]) / (2.0 * step) - action[entry];
|
|
}
|
|
CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-11);
|
|
|
|
std::vector<double> surface_force(surface->output_size());
|
|
for (std::size_t entry = 0; entry < surface_force.size(); ++entry) {
|
|
const double index = static_cast<double>(entry + 1);
|
|
surface_force[entry] = std::sin(0.23 * index) + 0.17 * std::cos(0.31 * index);
|
|
}
|
|
std::vector<double> parameter_force(surface->input_size());
|
|
REQUIRE(deformation::apply_jacobian_transpose(*surface, parameters, surface_force, parameter_force, workspace));
|
|
const double surface_work = work(action, surface_force);
|
|
const double parameter_work = work(direction, parameter_force);
|
|
CHECK(std::abs(surface_work - parameter_work) <= 3e-14 * std::max({1.0, std::abs(surface_work), std::abs(parameter_work)}));
|
|
std::ranges::fill(parameter_force, 1.0);
|
|
REQUIRE(deformation::apply_pullback_derivative(*surface, parameters, direction, surface_force, parameter_force, workspace));
|
|
CHECK(work(parameter_force, parameter_force) == 0.0);
|
|
}
|
|
|
|
TEST_CASE("Reference radial geometry rejects empty nonfinite and centered nodes", "[unit][deformation]") {
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, {}));
|
|
const std::array<std::array<double, 3>, 1> center_node{{{0.0, 0.0, 0.0}}};
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, center_node));
|
|
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
|
|
for (std::size_t component = 0; component < 3; ++component) {
|
|
std::array<double, 3> center{};
|
|
center[component] = invalid;
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, surface_positions()));
|
|
auto positions = surface_positions();
|
|
positions[4][component] = invalid;
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions));
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Reference radius calculation does not overflow a representable length", "[unit][deformation]") {
|
|
const std::array<std::array<double, 3>, 2> positions{{{1e200, 0.0, 0.0}, {0.0, 1e-200, 0.0}}};
|
|
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
|
|
REQUIRE(surface);
|
|
CHECK(surface->reference_radii()[0] == 1e200);
|
|
CHECK(surface->reference_radii()[1] == 1e-200);
|
|
CHECK(surface->radial_directions()[0][0] == 1.0);
|
|
CHECK(surface->radial_directions()[1][1] == 1.0);
|
|
}
|
|
|
|
TEST_CASE("Harmonic surfaces retain physical mode counts and canonical ordering", "[unit][deformation]") {
|
|
const auto positions = surface_positions();
|
|
const std::array<int, 5> expected_counts{5, 13, 19, 23, 25};
|
|
for (int cutoff = 0; cutoff <= 4; ++cutoff) {
|
|
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = cutoff}, positions);
|
|
REQUIRE(surface);
|
|
CHECK_FALSE(surface->has_nodal_parameters());
|
|
CHECK(surface->input_size() == static_cast<std::size_t>(expected_counts[static_cast<std::size_t>(cutoff)]));
|
|
std::size_t index = 0;
|
|
for (int degree = 0; degree <= 4; ++degree) {
|
|
for (int order = -std::min(degree, cutoff); order <= std::min(degree, cutoff); ++order) {
|
|
CHECK((surface->modes()[index++] == deformation::SphericalHarmonicMode{.degree = degree, .order = order}));
|
|
}
|
|
}
|
|
}
|
|
const auto monopole = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 0}, positions);
|
|
REQUIRE(monopole);
|
|
CHECK(monopole->input_size() == 1);
|
|
}
|
|
|
|
TEST_CASE("Harmonic surfaces reject invalid or unresolved mode selections", "[unit][deformation]") {
|
|
for (const deformation::SphericalHarmonicSurface model : {
|
|
deformation::SphericalHarmonicSurface{.maximum_degree = -1},
|
|
deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = -1},
|
|
deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = 5},
|
|
deformation::SphericalHarmonicSurface{.maximum_degree = std::numeric_limits<int>::max()}}) {
|
|
const auto surface = deformation::build_surface_embedding(model, surface_positions());
|
|
REQUIRE_FALSE(surface);
|
|
CHECK(surface.error().reason == deformation::DeformationFailureReason::invalid_configuration);
|
|
}
|
|
const auto unresolved = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 10}, surface_positions());
|
|
REQUIRE_FALSE(unresolved);
|
|
CHECK(unresolved.error().reason == deformation::DeformationFailureReason::unresolved_surface);
|
|
CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, {}));
|
|
}
|
|
|
|
TEST_CASE("Real harmonics preserve analytic normalization phase and signed-order convention", "[unit][deformation]") {
|
|
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
|
|
REQUIRE(surface);
|
|
constexpr double pi = std::numbers::pi;
|
|
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
|
|
const double x = surface->radial_directions()[node][0];
|
|
const double y = surface->radial_directions()[node][1];
|
|
const double z = surface->radial_directions()[node][2];
|
|
/// Analytic low-degree values protect physical coefficient conventions independently of the recurrence.
|
|
const auto check_mode = [&](int degree, int order, double exact) {
|
|
CHECK(std::abs(surface->nodal_basis()[node * surface->input_size() + mode_index(*surface, degree, order)] - exact) < 2e-14);
|
|
};
|
|
check_mode(0, 0, 1.0 / std::sqrt(4.0 * pi));
|
|
check_mode(1, 0, std::sqrt(3.0 / (4.0 * pi)) * z);
|
|
check_mode(1, 1, -std::sqrt(3.0 / (4.0 * pi)) * x);
|
|
check_mode(1, -1, -std::sqrt(3.0 / (4.0 * pi)) * y);
|
|
check_mode(2, 0, std::sqrt(5.0 / (16.0 * pi)) * (3.0 * z * z - 1.0));
|
|
check_mode(2, -2, std::sqrt(15.0 / (4.0 * pi)) * x * y);
|
|
check_mode(2, 2, std::sqrt(15.0 / (16.0 * pi)) * (x * x - y * y));
|
|
}
|
|
std::vector<double> parameters(surface->input_size(), 0.0);
|
|
parameters[0] = 0.125 * std::sqrt(4.0 * pi);
|
|
std::vector<double> displacement(surface->output_size());
|
|
REQUIRE(surface->apply(parameters, displacement));
|
|
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
|
|
for (std::size_t component = 0; component < 3; ++component) {
|
|
CHECK(std::abs(displacement[3 * node + component] - 0.125 * surface->radial_directions()[node][component]) < 1e-15);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Restricting harmonic orders does not renormalize surviving coefficients", "[unit][deformation]") {
|
|
const auto positions = surface_positions();
|
|
const auto full = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions);
|
|
REQUIRE(full);
|
|
for (int cutoff : {0, 1, 2}) {
|
|
const auto restricted = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_azimuthal_order = cutoff}, positions);
|
|
REQUIRE(restricted);
|
|
for (std::size_t mode = 0; mode < restricted->input_size(); ++mode) {
|
|
const deformation::SphericalHarmonicMode selected = restricted->modes()[mode];
|
|
const std::size_t full_mode = mode_index(*full, selected.degree, selected.order);
|
|
for (std::size_t node = 0; node < full->surface_node_count(); ++node) {
|
|
CHECK(restricted->nodal_basis()[node * restricted->input_size() + mode] == full->nodal_basis()[node * full->input_size() + full_mode]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Harmonic surface actions preserve exact derivatives virtual work and the reference basis", "[unit][deformation]") {
|
|
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
|
|
REQUIRE(surface);
|
|
const std::vector<double> original_basis(surface->nodal_basis().begin(), surface->nodal_basis().end());
|
|
check_linear_actions(*surface);
|
|
CHECK(std::ranges::equal(original_basis, surface->nodal_basis()));
|
|
}
|
|
|
|
TEST_CASE("Harmonic surfaces reject aliased modes even when enough nodes are present", "[unit][deformation]") {
|
|
std::vector<std::array<double, 3>> corners;
|
|
for (double x : {-0.5, 0.5}) {
|
|
for (double y : {-0.5, 0.5}) {
|
|
for (double z : {-0.5, 0.5}) {
|
|
corners.push_back({x, y, z});
|
|
}
|
|
}
|
|
}
|
|
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2, .maximum_azimuthal_order = 0}, corners);
|
|
REQUIRE_FALSE(surface);
|
|
CHECK(surface.error().reason == deformation::DeformationFailureReason::unresolved_surface);
|
|
}
|
|
|
|
TEST_CASE("Nodal and harmonic representations produce identical nodal surface motion", "[unit][deformation]") {
|
|
const auto positions = surface_positions();
|
|
const auto harmonic = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions);
|
|
const auto nodal = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
|
|
REQUIRE(harmonic);
|
|
REQUIRE(nodal);
|
|
const std::vector<double> parameters = varying_values(harmonic->input_size(), 0.03);
|
|
std::vector<double> nodal_parameters(nodal->input_size());
|
|
for (std::size_t node = 0; node < nodal_parameters.size(); ++node) {
|
|
long double amplitude = 0.0L;
|
|
for (std::size_t mode = 0; mode < harmonic->input_size(); ++mode) {
|
|
amplitude += static_cast<long double>(harmonic->nodal_basis()[node * harmonic->input_size() + mode]) * parameters[mode];
|
|
}
|
|
nodal_parameters[node] = static_cast<double>(amplitude);
|
|
}
|
|
std::vector<double> harmonic_motion(harmonic->output_size());
|
|
std::vector<double> nodal_motion(nodal->output_size());
|
|
REQUIRE(harmonic->apply(parameters, harmonic_motion));
|
|
REQUIRE(nodal->apply(nodal_parameters, nodal_motion));
|
|
// Independent reductions must agree to roundoff, including optimized builds.
|
|
const double scale = std::sqrt(std::max(work(harmonic_motion, harmonic_motion), work(nodal_motion, nodal_motion)));
|
|
for (std::size_t index = 0; index < harmonic_motion.size(); ++index) {
|
|
harmonic_motion[index] -= nodal_motion[index];
|
|
}
|
|
CHECK(std::sqrt(work(harmonic_motion, harmonic_motion)) <= 8.0 * std::numeric_limits<double>::epsilon() * scale);
|
|
}
|
|
|
|
TEST_CASE("Surface actions reject wrong sizes nonfinite inputs and overlapping storage", "[unit][deformation]") {
|
|
for (bool harmonic : {false, true}) {
|
|
const auto positions = surface_positions();
|
|
const auto surface = harmonic ? deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions) : deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
|
|
REQUIRE(surface);
|
|
std::vector<double> parameters(surface->input_size(), 0.0);
|
|
std::vector<double> displacement(surface->output_size(), 0.0);
|
|
const std::vector<double> wrong(surface->input_size() + 1, 0.0);
|
|
CHECK_FALSE(surface->apply(wrong, displacement));
|
|
CHECK_FALSE(surface->apply(parameters, parameters));
|
|
CHECK_FALSE(surface->apply_transpose(parameters, parameters));
|
|
CHECK_FALSE(surface->apply_transpose(displacement, displacement));
|
|
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
|
|
parameters.back() = invalid;
|
|
CHECK_FALSE(surface->apply(parameters, displacement));
|
|
parameters.back() = 0.0;
|
|
displacement.back() = invalid;
|
|
CHECK_FALSE(surface->apply_transpose(displacement, parameters));
|
|
displacement.back() = 0.0;
|
|
}
|
|
const std::span<double> buffer(displacement);
|
|
CHECK_FALSE(surface->apply(buffer.first(surface->input_size()), buffer));
|
|
CHECK_FALSE(surface->apply_transpose(buffer, buffer.first(surface->input_size())));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Finite harmonic inputs cannot silently overflow surface motion or modal forces", "[unit][deformation]") {
|
|
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
|
|
REQUIRE(surface);
|
|
const double maximum = std::numeric_limits<double>::max();
|
|
std::vector<double> parameters(surface->input_size());
|
|
std::vector<double> displacement(surface->output_size());
|
|
for (std::size_t mode = 0; mode < surface->input_size(); ++mode) {
|
|
parameters[mode] = std::copysign(maximum, surface->nodal_basis()[mode]);
|
|
}
|
|
CHECK_FALSE(surface->apply(parameters, displacement));
|
|
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
|
|
for (std::size_t component = 0; component < 3; ++component) {
|
|
displacement[3 * node + component] = maximum * surface->radial_directions()[node][component];
|
|
REQUIRE(std::isfinite(displacement[3 * node + component]));
|
|
}
|
|
}
|
|
const auto transpose = surface->apply_transpose(displacement, parameters);
|
|
REQUIRE_FALSE(transpose);
|
|
CHECK(transpose.error().reason == deformation::DeformationFailureReason::non_finite);
|
|
}
|