Files
MeanField_SERiF/tests/deformation/radial_extension.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

476 lines
23 KiB
C++

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