#include #include #include #include #include #include #include #include #include #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> surface_positions(const std::array& center = {}) { std::vector> 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(node) + 0.5) / node_count; const double sine = std::sqrt(1.0 - cosine * cosine); const double azimuth = golden_angle * static_cast(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(selected - surface.modes().begin()); } /** @brief Use deterministic nonconstant data to expose ordering errors in displacement and force actions. */ [[nodiscard]] std::vector varying_values(std::size_t size, double scale = 1.0) { std::vector result(size); for (std::size_t index = 0; index < size; ++index) { result[index] = scale * std::sin(0.37 * static_cast(index + 1)); } return result; } /** @brief Evaluate virtual work without depending on the tested transpose implementation. */ [[nodiscard]] double work(std::span first, std::span second) { REQUIRE(first.size() == second.size()); long double result = 0.0L; for (std::size_t index = 0; index < first.size(); ++index) { result += static_cast(first[index]) * second[index]; } return static_cast(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 parameters = varying_values(surface.input_size(), 0.03); const std::vector direction = varying_values(surface.input_size(), 0.07); const std::vector force = varying_values(surface.output_size()); std::vector action(surface.output_size()); std::vector 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 plus(parameters); std::vector minus(parameters); for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) { plus[parameter] += epsilon * direction[parameter]; minus[parameter] -= epsilon * direction[parameter]; } std::vector plus_value(surface.output_size()); std::vector 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 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 parameters(surface->input_size(), 0.125); std::vector 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 parameters(surface->input_size()); std::vector direction(surface->input_size()); std::vector plus(surface->input_size()); std::vector minus(surface->input_size()); constexpr double step = 1e-6; for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) { const double index = static_cast(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 plus_value(surface->output_size()); std::vector minus_value(surface->output_size()); std::vector 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 surface_force(surface->output_size()); for (std::size_t entry = 0; entry < surface_force.size(); ++entry) { const double index = static_cast(entry + 1); surface_force[entry] = std::sin(0.23 * index) + 0.17 * std::cos(0.31 * index); } std::vector 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, 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::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { for (std::size_t component = 0; component < 3; ++component) { std::array 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, 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 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(expected_counts[static_cast(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::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 parameters(surface->input_size(), 0.0); parameters[0] = 0.125 * std::sqrt(4.0 * pi); std::vector 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 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> 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 parameters = varying_values(harmonic->input_size(), 0.03); std::vector 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(harmonic->nodal_basis()[node * harmonic->input_size() + mode]) * parameters[mode]; } nodal_parameters[node] = static_cast(amplitude); } std::vector harmonic_motion(harmonic->output_size()); std::vector 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::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 parameters(surface->input_size(), 0.0); std::vector displacement(surface->output_size(), 0.0); const std::vector 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::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::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 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::max(); std::vector parameters(surface->input_size()); std::vector 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); }