Files
MeanField_SERiF/tests/deformation/surface_embedding.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
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.
2026-11-09 13:53:17 -05:00

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);
}