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.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

@@ -0,0 +1,369 @@
#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);
}