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,475 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <numeric>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include "mfem/fem/gridfunc.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
using serif::tests::deformation::PairedMesh;
/** @brief Expose a numbering mismatch without requiring unsupported variable-order nonconforming meshes. */
struct ReorderedCompanionSpace : ::mfem::FiniteElementSpace
{
using ::mfem::FiniteElementSpace::FiniteElementSpace;
/** @brief Preserve the basis, collection and total size while swapping two local scalar coefficients. */
void GetElementDofs(int element, ::mfem::Array<int>& dofs, ::mfem::DofTransformation& transformation) const override
{
::mfem::FiniteElementSpace::GetElementDofs(element, dofs, transformation);
std::swap(dofs[0], dofs[1]);
}
};
/** @brief Keep action tests concise while ensuring compilation failure is reported at its source. */
[[nodiscard]] std::shared_ptr<const deformation::RadialInterpolation> interpolation(PairedMesh& mesh)
{
auto result = mesh.interpolation();
if (!result)
{
INFO(result.error().message);
REQUIRE(result.has_value());
}
return std::make_shared<const deformation::RadialInterpolation>(std::move(*result));
}
/** @brief Preserve the MeanField finite-difference normalization, including its epsilon floor. */
[[nodiscard]] double relative_error(std::span<const double> actual, std::span<const double> expected)
{
double squared_error = 0.0, squared_norm = 0.0;
for (std::size_t entry = 0; entry < actual.size(); ++entry)
{
const double difference = actual[entry] - expected[entry];
squared_error += difference * difference;
squared_norm += expected[entry] * expected[entry];
}
return std::sqrt(squared_error) / std::max(std::sqrt(squared_norm), std::numeric_limits<double>::epsilon());
}
} // namespace
TEST_CASE("Radial interpolation preserves constants on paired logical rays and canonical physical surface nodes", "[unit][deformation]")
{
// Cubic nodes on a refined core project between surface nodes; the unrefined quadratic fixture is exactly aligned.
PairedMesh mesh(1, 0.0, 3);
const auto data = interpolation(mesh);
const auto logical_positions = backend::sample_reference_positions(mesh.scalar_space, *mesh.generated.reference_mesh);
REQUIRE(logical_positions.has_value());
const auto surface = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute);
REQUIRE(surface.has_value());
CHECK(surface->scalar_dofs == data->data().surface_scalar_dofs);
CHECK(std::is_sorted(surface->scalar_dofs.begin(), surface->scalar_dofs.end()));
CHECK(surface->positions.size() == data->surface_node_count());
CHECK(data->data().stellar_radius > 0.0);
CHECK(data->data().infinity_radius > data->data().stellar_radius);
CHECK(data->scalar_dof_count() == static_cast<std::size_t>(mesh.scalar_space.GetNDofs()));
int interpolated_rows = 0;
int centers = 0;
for (std::size_t node = 0; node < data->scalar_dof_count(); ++node)
{
const int begin = data->data().row_offsets[node];
const int end = data->data().row_offsets[node + 1];
if (data->data().logical_radii[node] < 1.0e-14)
{
CHECK(begin == end);
++centers;
continue;
}
const double sum = std::accumulate(data->data().interpolation_weights.begin() + begin, data->data().interpolation_weights.begin() + end, 0.0);
CHECK(sum == Catch::Approx(1.0).margin(2.0e-12));
for (std::size_t component = 0; component < 3; ++component)
{
double interpolated_position = 0.0;
for (int entry = begin; entry < end; ++entry)
{
const int surface_dof = data->data().surface_scalar_dofs[data->data().surface_columns[entry]];
interpolated_position += data->data().interpolation_weights[entry] * (*logical_positions)[surface_dof][component];
}
const double projected_position = data->data().stellar_radius * (*logical_positions)[node][component] / data->data().logical_radii[node];
CHECK(interpolated_position == Catch::Approx(projected_position).margin(2.0e-11));
}
if (end - begin > 1)
{
++interpolated_rows;
}
}
CHECK(centers > 0);
CHECK(interpolated_rows > 0);
}
TEST_CASE("Fused radial extension reproduces the surface fixes center and infinity and preserves original powers", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
const auto quadratic = deformation::make_radial_extension(data);
const auto cubic = deformation::make_radial_extension(data, {.radial_power = 3.0});
REQUIRE(quadratic.has_value());
REQUIRE(cubic.has_value());
CHECK(quadratic->interpolation() == cubic->interpolation());
std::vector<double> surface(quadratic->input_size());
for (std::size_t node = 0; node < data->surface_node_count(); ++node)
{
surface[3 * node] = 0.02;
surface[3 * node + 1] = -0.03;
surface[3 * node + 2] = 0.05;
}
std::vector<double> output(quadratic->output_size());
REQUIRE(quadratic->apply(surface, output));
int shared_nodes = 0, fixed_centers = 0, fixed_infinity = 0;
for (std::size_t node = 0; node < data->scalar_dof_count(); ++node)
{
const double radius = data->data().logical_radii[node];
double weight = 0.0;
if (data->data().stellar_support[node])
{
weight = std::pow(radius / data->data().stellar_radius, 2.0);
CHECK(cubic->radial_weights()[node] == Catch::Approx(std::pow(radius / data->data().stellar_radius, 3.0)).margin(2.0e-14));
}
else if (data->data().vacuum_support[node])
{
weight = (data->data().infinity_radius - radius) / (data->data().infinity_radius - data->data().stellar_radius);
CHECK(cubic->radial_weights()[node] == quadratic->radial_weights()[node]);
}
for (std::size_t component = 0; component < 3; ++component)
{
CHECK(output[node + component * data->scalar_dof_count()] == Catch::Approx(weight * surface[component]).margin(2.0e-13));
}
if (radius < 1.0e-14)
{
CHECK(quadratic->radial_weights()[node] == 0.0);
++fixed_centers;
}
if (std::abs(radius - data->data().infinity_radius) < 1.0e-14)
{
CHECK(quadratic->radial_weights()[node] == Catch::Approx(0.0).margin(1.0e-14));
++fixed_infinity;
}
if (data->data().stellar_support[node] && data->data().vacuum_support[node])
{
CHECK(quadratic->radial_weights()[node] == Catch::Approx(1.0).margin(2.0e-14));
++shared_nodes;
}
}
CHECK(fixed_centers > 0);
CHECK(fixed_infinity > 0);
CHECK(shared_nodes == static_cast<int>(data->surface_node_count()));
for (std::size_t entry = 0; entry < surface.size(); ++entry)
{
surface[entry] = std::sin(0.17 * (entry + 1));
}
REQUIRE(quadratic->apply(surface, output));
for (std::size_t node = 0; node < data->surface_node_count(); ++node)
{
for (std::size_t component = 0; component < 3; ++component)
{
CHECK(output[data->data().surface_scalar_dofs[node] + component * data->scalar_dof_count()] == Catch::Approx(surface[3 * node + component]).margin(2.0e-12));
}
}
}
TEST_CASE("Radial extension derivatives retain centered differences and exact transpose work after refinement", "[unit][deformation]")
{
for (const int refinement : {0, 1})
{
CAPTURE(refinement);
PairedMesh mesh(refinement, 0.1);
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
std::vector<double> direction(extension->input_size()), plus(direction.size()), minus(direction.size());
constexpr double step = 1.0e-6;
for (std::size_t entry = 0; entry < direction.size(); ++entry)
{
const double index = entry + 1;
direction[entry] = std::cos(0.13 * index) - 0.2 * std::sin(0.31 * index);
const double surface = 0.01 * std::sin(0.17 * index);
plus[entry] = surface + step * direction[entry];
minus[entry] = surface - step * direction[entry];
}
std::vector<double> plus_output(extension->output_size()), minus_output(plus_output.size()), derivative(plus_output.size()), force(plus_output.size()), surface_force(direction.size());
REQUIRE(extension->apply(plus, plus_output));
REQUIRE(extension->apply(minus, minus_output));
REQUIRE(extension->apply(direction, derivative));
for (std::size_t entry = 0; entry < derivative.size(); ++entry)
{
plus_output[entry] = (plus_output[entry] - minus_output[entry]) / (2.0 * step);
const double index = entry + 1;
force[entry] = std::sin(0.07 * index) + 0.3 * std::cos(0.11 * index);
}
CHECK(relative_error(derivative, plus_output) < 2.0e-10);
REQUIRE(extension->apply_transpose(force, surface_force));
const double volume_work = std::inner_product(derivative.begin(), derivative.end(), force.begin(), 0.0);
const double surface_work = std::inner_product(direction.begin(), direction.end(), surface_force.begin(), 0.0);
CHECK(std::abs(volume_work - surface_work) <= 5.0e-13 * std::max({1.0, std::abs(volume_work), std::abs(surface_work)}));
}
}
TEST_CASE("Logical radial interpolation is independent of physical flattening", "[unit][deformation]")
{
PairedMesh spherical;
PairedMesh oblate(0, 0.15);
const auto spherical_data = interpolation(spherical);
const auto oblate_data = interpolation(oblate);
CHECK(spherical_data->data().row_offsets == oblate_data->data().row_offsets);
CHECK(spherical_data->data().surface_columns == oblate_data->data().surface_columns);
CHECK(spherical_data->data().interpolation_weights == oblate_data->data().interpolation_weights);
CHECK(spherical_data->data().logical_radii == oblate_data->data().logical_radii);
const auto spherical_surface = backend::sample_reference_surface(spherical.scalar_space, PairedMesh::surface_attribute);
const auto oblate_surface = backend::sample_reference_surface(oblate.scalar_space, PairedMesh::surface_attribute);
REQUIRE(spherical_surface.has_value());
REQUIRE(oblate_surface.has_value());
CHECK(spherical_surface->positions != oblate_surface->positions);
}
TEST_CASE("Radial compilation rejects unsupported spaces broken mesh pairing and invalid foliation", "[unit][deformation]")
{
PairedMesh mesh;
SECTION("Wrong vector storage")
{
::mfem::FiniteElementSpace interleaved(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byVDIM);
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, interleaved, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Different companion collection")
{
::mfem::H1_FECollection different_collection(2, 3);
::mfem::FiniteElementSpace different_vector(mesh.generated.mesh.get(), &different_collection, 3);
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, different_vector, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Matching sizes and collection with different scalar coefficient numbering")
{
ReorderedCompanionSpace reordered(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byNODES);
REQUIRE(reordered.GetVSize() == mesh.vector_space.GetVSize());
REQUIRE(reordered.FEColl() == mesh.scalar_space.FEColl());
const auto result = backend::build_radial_interpolation(mesh.scalar_space, reordered, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization);
CHECK(result.error().element == 0);
}
SECTION("Discontinuous scalar basis")
{
::mfem::L2_FECollection discontinuous(2, 3);
::mfem::FiniteElementSpace scalar(mesh.generated.mesh.get(), &discontinuous);
CHECK_FALSE(backend::sample_reference_surface(scalar, 1));
}
SECTION("Missing boundary")
{
CHECK_FALSE(backend::sample_reference_surface(mesh.scalar_space, 77));
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 77));
}
SECTION("Malformed element support")
{
mesh.stellar_elements.pop_back();
CHECK_FALSE(mesh.interpolation());
}
SECTION("Non-binary element support")
{
mesh.stellar_elements[0] = -1;
CHECK_FALSE(mesh.interpolation());
}
SECTION("Reversed logical shells")
{
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 2, 1));
}
SECTION("Inconsistent paired boundary attributes")
{
mesh.generated.reference_mesh->GetBdrElement(0)->SetAttribute(77);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Inconsistent paired element attributes")
{
mesh.generated.reference_mesh->GetElement(0)->SetAttribute(77);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Inconsistent paired element orientation")
{
int* vertices = mesh.generated.reference_mesh->GetElement(0)->GetVertices();
std::swap(vertices[0], vertices[1]);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Physical geometry is not a logical cubical foliation")
{
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Vacuum support incorrectly includes the center")
{
std::fill(mesh.vacuum_elements.begin(), mesh.vacuum_elements.end(), 1);
CHECK_FALSE(mesh.interpolation());
}
}
TEST_CASE("Radial actions reject invalid values lengths and overlapping buffers", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
const auto extension = deformation::make_radial_extension(data);
REQUIRE(extension.has_value());
CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = 0.5}));
CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = std::numeric_limits<double>::infinity()}));
CHECK_FALSE(deformation::make_radial_extension(nullptr));
std::vector<double> surface(extension->input_size(), 1.0), volume(extension->output_size(), 7.0);
CHECK_FALSE(extension->apply(std::span(surface).first(surface.size() - 1), volume));
CHECK(std::all_of(volume.begin(), volume.end(), [](double value)
{
return value == 7.0;
}));
surface[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(extension->apply(surface, volume));
surface[0] = 1.0;
volume[0] = std::numeric_limits<double>::infinity();
CHECK_FALSE(extension->apply_transpose(volume, surface));
std::vector<double> overlap(extension->input_size() + extension->output_size(), 1.0);
CHECK_FALSE(extension->apply(std::span<const double>(overlap).first(extension->input_size()), std::span<double>(overlap).first(extension->output_size())));
CHECK_FALSE(extension->apply_transpose(std::span<const double>(overlap).first(extension->output_size()), std::span<double>(overlap).first(extension->input_size())));
volume[0] = 7.0;
REQUIRE(extension->apply(surface, volume));
REQUIRE(extension->apply_transpose(volume, surface));
}
TEST_CASE("Imported radial interpolation is checked once and owns its validated data", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
deformation::RadialInterpolationData malformed = data->data();
malformed.surface_columns.front() = static_cast<int>(data->surface_node_count());
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
malformed.row_offsets.back() += 1;
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
const int surface_dof = data->data().surface_scalar_dofs.front();
for (int entry = malformed.row_offsets[surface_dof]; entry < malformed.row_offsets[surface_dof + 1]; ++entry)
{
malformed.interpolation_weights[entry] *= 0.5;
}
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
const int first_entry = malformed.row_offsets[surface_dof];
const int next_entry = malformed.row_offsets[surface_dof + 1];
const int removed_entries = next_entry - first_entry;
REQUIRE(removed_entries > 0);
malformed.surface_columns.erase(malformed.surface_columns.begin() + first_entry, malformed.surface_columns.begin() + next_entry);
malformed.interpolation_weights.erase(malformed.interpolation_weights.begin() + first_entry, malformed.interpolation_weights.begin() + next_entry);
for (std::size_t row = surface_dof + 1; row < malformed.row_offsets.size(); ++row)
{
malformed.row_offsets[row] -= removed_entries;
}
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
SECTION("Nonfinite interpolation weight")
{
malformed = data->data();
malformed.interpolation_weights.front() = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Invalid material support")
{
malformed = data->data();
malformed.stellar_support.front() = 2;
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Nonfinite logical radius")
{
malformed = data->data();
malformed.logical_radii.front() = std::numeric_limits<double>::infinity();
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Imported storage cannot mutate a compiled table")
{
deformation::RadialInterpolationData imported = data->data();
const auto checked = deformation::make_radial_interpolation(imported);
REQUIRE(checked);
imported.interpolation_weights.front() = std::numeric_limits<double>::quiet_NaN();
CHECK(checked->data().interpolation_weights == data->data().interpolation_weights);
const auto shared = std::make_shared<const deformation::RadialInterpolation>(*checked);
const auto first = deformation::make_radial_extension(shared);
const auto second = deformation::make_radial_extension(shared, {.radial_power = 3.0});
REQUIRE(first);
REQUIRE(second);
CHECK(first->interpolation() == shared);
CHECK(second->interpolation() == shared);
CHECK_FALSE(std::ranges::equal(first->radial_weights(), second->radial_weights()));
}
}
TEST_CASE("Extended constant vectors retain scalar finite element interpolation between volume nodes", "[unit][deformation]")
{
PairedMesh mesh;
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
constexpr std::array<double, 3> components{1.25, -0.75, 2.5};
std::vector<double> surface(extension->input_size()), volume(extension->output_size());
for (std::size_t node = 0; node < surface.size() / 3; ++node)
{
for (std::size_t component = 0; component < 3; ++component)
{
surface[3 * node + component] = components[component];
}
}
REQUIRE(extension->apply(surface, volume));
::mfem::GridFunction displacement(&mesh.vector_space);
::mfem::GridFunction radial_weight(&mesh.scalar_space);
std::copy(volume.begin(), volume.end(), displacement.GetData());
std::copy(extension->radial_weights().begin(), extension->radial_weights().end(), radial_weight.GetData());
::mfem::Vector value(3);
for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element)
{
const ::mfem::IntegrationPoint& point = ::mfem::Geometries.GetCenter(mesh.generated.mesh->GetElementGeometry(element));
const double weight = radial_weight.GetValue(element, point);
displacement.GetVectorValue(element, point, value);
for (int component = 0; component < 3; ++component)
{
CHECK(value[component] == Catch::Approx(components[component] * weight).margin(2.0e-11));
}
}
}
TEST_CASE("Small radial surface motion preserves orientation before and after uniform refinement", "[unit][deformation]")
{
for (const int refinement : {0, 1})
{
CAPTURE(refinement);
PairedMesh mesh(refinement);
const auto surface_geometry = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute);
REQUIRE(surface_geometry.has_value());
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
std::vector<double> surface(extension->input_size()), volume(extension->output_size());
for (std::size_t node = 0; node < surface_geometry->positions.size(); ++node)
{
const std::array<double, 3>& position = surface_geometry->positions[node];
const double radius = std::sqrt(position[0] * position[0] + position[1] * position[1] + position[2] * position[2]);
const double polar_direction = position[2] / radius;
const double amplitude = 0.006 - 0.001 * 0.5 * (3.0 * polar_direction * polar_direction - 1.0);
for (std::size_t component = 0; component < 3; ++component)
{
surface[3 * node + component] = amplitude * position[component] / radius;
}
}
REQUIRE(extension->apply(surface, volume));
::mfem::GridFunction displacement(&mesh.vector_space);
std::copy(volume.begin(), volume.end(), displacement.GetData());
double minimum_determinant = std::numeric_limits<double>::infinity();
::mfem::DenseMatrix gradient;
for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element)
{
::mfem::ElementTransformation& transformation = *mesh.generated.mesh->GetElementTransformation(element);
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), mesh.vector_space.GetFE(element)->GetOrder() + 2);
for (int point = 0; point < rule.GetNPoints(); ++point)
{
transformation.SetIntPoint(&rule.IntPoint(point));
displacement.GetVectorGradient(transformation, gradient);
for (int component = 0; component < 3; ++component)
{
gradient(component, component) += 1.0;
}
minimum_determinant = std::min(minimum_determinant, gradient.Det());
}
}
CHECK(minimum_determinant > 0.0);
}
}