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

398 lines
26 KiB
C++

#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numeric>
#include <tuple>
#include <variant>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
#include "serif/backend/mfem/model/volume_displacement.hpp"
#include "serif/backend/mfem/model/deformation.hpp"
#include "serif/tests/deformation/mesh.hpp"
#include "serif/tests/equations/fixture.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace deformation_backend = deformation::backend::mfem;
namespace equation_backend = serif::operators::backend::mfem;
namespace equation_fixture = serif::tests::equations;
namespace dependencies = serif::utils::dependencies;
namespace blocks = serif::discretization::blocks;
namespace stages = equation_backend::volume_keys;
using Status = dependencies::PreparationStatus;
using Closure = equation_fixture::Closure;
using Model = deformation::DomainDeformation<deformation::SphericalHarmonicSurface>;
using VolumeDiscretization = equation_backend::VolumeDiscretization<Closure::Test, equation_fixture::Schema>;
using Failure = std::variant<deformation::DeformationFailure, equation_backend::PreparationRejection>;
using DensityValue = serif::discretization::forms::Operand<blocks::density::mass>;
/** @brief Exercise displacement support independently of the stellar domain vocabulary. */
struct SupportedDisplacement : serif::discretization::backend::mfem::DofMap {
/** @brief A selected element set gives an independent gather/scatter reference. */
SupportedDisplacement(mfem::FiniteElementSpace& space, const mfem::Array<int>& support) : DofMap(space, support) {}
};
/** @brief Keep the collection and coefficient count unchanged while exposing incompatible local numbering. */
struct ReorderedDisplacementSpace : mfem::FiniteElementSpace
{
using mfem::FiniteElementSpace::FiniteElementSpace;
/** @brief Model an equation companion whose scalar coefficients have different local meanings. */
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 Reuse the volume equation's existing field bindings and physical mapper. */
[[nodiscard]] VolumeDiscretization equation_discretization(const equation_fixture::Fixture& fixture)
{
return {.residual_binding = fixture.density_binding, .displacement_binding = fixture.displacement_binding, .compactification = fixture.compactification, .mapper = fixture.mapper};
}
/** @brief Supply only independent physical inputs; displacement is produced inside the combined graph. */
[[nodiscard]] auto combined_inputs(const Model& model, const deformation_backend::ReferenceDiscretization& reference, const std::vector<double>& shape, const equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const auto shape_inputs = deformation_backend::make_deformation_inputs(model, reference, shape);
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(equation_discretization(fixture), fixture.state(density, enthalpy));
return dependencies::make_dependency_context(
shape_inputs.template resource<deformation::keys::ReferenceDiscretization>(),
shape_inputs.template resource<deformation::keys::SurfaceDiscretization>(),
shape_inputs.template resource<deformation::keys::ReferenceCoordinates>(),
shape_inputs.template resource<deformation::keys::SurfaceModel>(),
shape_inputs.template resource<deformation::keys::InteriorModel>(),
shape_inputs.template resource<deformation::keys::VacuumModel>(),
shape_inputs.template resource<deformation::keys::ShapeCoefficients>(),
equation_inputs.template resource<equation_backend::MeshAndCoefficientBindings>(),
equation_inputs.template resource<equation_backend::CompactificationCoefficients>(),
equation_inputs.template resource<blocks::density::mass>(),
equation_inputs.template resource<::serif::tests::equations::Enthalpy>());
}
/** @brief Compare accepted residuals and both linear actions with the established standalone equation path. */
template <typename Cache>
void check_against_volume_cache(const Cache& combined, equation_fixture::Fixture& fixture, const serif::eos::models::Polytrope& eos, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const mfem::Vector displacement(combined.template get<equation_backend::DisplacementCoefficients>().values);
const auto discretization = equation_discretization(fixture);
const auto inputs = equation_backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto independent = equation_backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
REQUIRE(independent.try_prepare(inputs));
mfem::Vector actual, expected;
equation_backend::volume_residual(combined.template get<stages::Linearization>(), actual);
equation_backend::volume_residual(independent.template get<stages::Linearization>(), expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
const mfem::Vector direction = equation_fixture::packed(fixture.density(0.7), fixture.enthalpy(0.2), fixture.displacement(0.01));
equation_backend::apply_jacobian(combined.template get<stages::Linearization>(), direction, fixture.workspace, actual);
equation_backend::apply_jacobian(independent.template get<stages::Linearization>(), direction, fixture.workspace, expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
const mfem::Vector dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3);
equation_backend::apply_jacobian_transpose(combined.template get<stages::Linearization>(), dual, fixture.workspace, actual);
equation_backend::apply_jacobian_transpose(independent.template get<stages::Linearization>(), dual, fixture.workspace, expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
}
/** @brief Verify the complete harmonic-shape-to-equation tangent and its reverse virtual-work pairing. */
template <typename Cache>
void check_shape_equation_linearization(Cache& cache, const Model& model, const deformation_backend::ReferenceDiscretization& reference, std::vector<double>& shape, equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const auto& accepted = cache.template get<deformation::keys::Linearization>();
auto shape_workspace = deformation::make_deformation_workspace(*accepted.map);
const std::array shape_direction{0.35, -0.17, 0.22, 0.09};
std::vector<double> volume_direction(accepted.map->output_size());
REQUIRE(deformation::apply_jacobian(accepted, shape_direction, volume_direction, shape_workspace));
// This fixture uses the same complete component-block ordering on both sides of the interface.
REQUIRE(fixture.displacement_space.GetOrdering() == mfem::Ordering::byNODES);
mfem::Vector displacement_direction(fixture.displacement_binding.size());
REQUIRE(volume_direction.size() == static_cast<std::size_t>(displacement_direction.Size()));
std::ranges::copy(volume_direction, displacement_direction.GetData());
const mfem::Vector zero_density = equation_fixture::constant(fixture.density_binding.size(), 0.0);
const mfem::Vector zero_enthalpy = equation_fixture::constant(fixture.enthalpy_binding.size(), 0.0);
const mfem::Vector direction = equation_fixture::packed(zero_density, zero_enthalpy, displacement_direction);
mfem::Vector analytic, equation_transpose;
equation_backend::apply_jacobian(cache.template get<stages::Linearization>(), direction, fixture.workspace, analytic);
const mfem::Vector equation_dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3);
equation_backend::apply_jacobian_transpose(cache.template get<stages::Linearization>(), equation_dual, fixture.workspace, equation_transpose);
const int displacement_offset = zero_density.Size() + zero_enthalpy.Size();
REQUIRE(equation_transpose.Size() == displacement_offset + displacement_direction.Size());
const std::span<const double> displacement_force(equation_transpose.GetData() + displacement_offset, displacement_direction.Size());
std::array<double, 4> shape_force{};
REQUIRE(deformation::apply_jacobian_transpose(accepted, displacement_force, shape_force, shape_workspace));
const double equation_work = analytic * equation_dual;
const double shape_work = std::inner_product(shape_direction.begin(), shape_direction.end(), shape_force.begin(), 0.0);
CHECK(std::abs(equation_work - shape_work) <= 2.0e-12 * std::max({1.0, std::abs(equation_work), std::abs(shape_work)}));
const std::vector<double> accepted_shape = shape;
constexpr double step = 1.0e-6;
for (std::size_t parameter = 0; parameter < shape.size(); ++parameter)
{
shape[parameter] = accepted_shape[parameter] + step * shape_direction[parameter];
}
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
mfem::Vector plus_residual, minus_residual;
equation_backend::volume_residual(cache.template get<stages::Linearization>(), plus_residual);
for (std::size_t parameter = 0; parameter < shape.size(); ++parameter)
{
shape[parameter] = accepted_shape[parameter] - step * shape_direction[parameter];
}
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
equation_backend::volume_residual(cache.template get<stages::Linearization>(), minus_residual);
plus_residual -= minus_residual;
plus_residual /= 2.0 * step;
CHECK(equation_fixture::relative_error(analytic, plus_residual) < 2.0e-8);
shape = accepted_shape;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
}
} // namespace
TEST_CASE("Deformation and volume equations share one dependency graph with atomic preparation", "[unit][deformation][equation_integration]")
{
auto generated = serif::tests::deformation::make_mesh();
equation_fixture::Fixture fixture(std::move(*generated.mesh));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, *generated.reference_mesh);
Model model{.surface = {.maximum_degree = 1}, .stellar_interior = {}, .vacuum = {}};
std::vector<double> shape{0.004, -0.002, 0.001, 0.003};
mfem::Vector density = fixture.density();
mfem::Vector enthalpy = fixture.enthalpy();
const serif::eos::models::Polytrope eos{1.5, 0.25};
const auto inputs = combined_inputs(model, reference, shape, fixture, density, enthalpy);
const auto calculations = std::tuple_cat(
deformation_backend::make_deformation_computations<deformation::SphericalHarmonicSurface>(),
deformation_backend::make_volume_displacement_computations(fixture.displacement_binding),
equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture)));
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<Failure>(inputs, std::move(calculation)...);
},
calculations);
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
check_shape_equation_linearization(cache, model, reference, shape, fixture, density, enthalpy);
const auto& backend_vector = cache.get<deformation_backend::volume_displacement_keys::Coefficients>();
CHECK(&cache.get<equation_backend::DisplacementCoefficients>().values == &backend_vector);
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().computed_count() == 0);
shape[1] += 0.001;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::SurfaceMap>() == Status::reused);
CHECK(cache.report().status<deformation::keys::RadialInterpolation>() == Status::reused);
CHECK(cache.report().status<deformation::keys::VolumeDisplacement>() == Status::computed);
CHECK(cache.report().status<deformation_backend::volume_displacement_keys::CheckedBinding>() == Status::reused);
CHECK(cache.report().status<stages::Basis<0>>() == Status::reused);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::computed);
CHECK(cache.report().status<stages::ReferenceValues<0, DensityValue>>() == Status::reused);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
density += 0.02;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::VolumeDisplacement>() == Status::reused);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::reused);
CHECK(cache.report().status<stages::ReferenceValues<0, DensityValue>>() == Status::computed);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
// A different interior response changes derivative information even when a spherical zero-displacement state has equal values.
std::ranges::fill(shape, 0.0);
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
model.stellar_interior.radial_power = 3.0;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::VolumeExtension>() == Status::computed);
CHECK(cache.report().status<deformation::keys::RadialInterpolation>() == Status::reused);
CHECK(cache.report().status<equation_backend::DisplacementCoefficients>() == Status::computed);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::computed);
CHECK(cache.get<equation_backend::DisplacementCoefficients>().values.Norml2() == 0.0);
shape[0] = std::numeric_limits<double>::quiet_NaN();
const auto invalid_shape = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy));
REQUIRE_FALSE(invalid_shape);
CHECK(std::holds_alternative<deformation::DeformationFailure>(invalid_shape.error().error));
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<stages::Linearization>());
CHECK_FALSE(cache.contains<deformation::keys::Linearization>());
shape[0] = 0.003;
enthalpy = -1.0;
const auto invalid_equation = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy));
REQUIRE_FALSE(invalid_equation);
CHECK(std::holds_alternative<equation_backend::PreparationRejection>(invalid_equation.error().error));
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<stages::Linearization>());
CHECK_FALSE(cache.contains<deformation::keys::Linearization>());
enthalpy = fixture.enthalpy();
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
}
TEST_CASE("The deformation displacement connection respects both global MFEM orderings", "[unit][deformation][equation_integration]")
{
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM})
{
equation_fixture::Fixture fixture(mfem::Element::HEXAHEDRON, ordering);
const mfem::Vector density = fixture.density();
const mfem::Vector enthalpy = fixture.enthalpy();
const mfem::Vector reference_displacement = fixture.displacement(0.03);
const int scalar_nodes = fixture.displacement_space.GetNDofs();
std::vector<double> displacement(fixture.displacement_binding.full_size());
for (int component = 0; component < 3; ++component)
{
for (int node = 0; node < scalar_nodes; ++node)
{
const int full = fixture.displacement_space.DofToVDof(node, component);
const int reduced = *fixture.displacement_binding.reduced_dof(full);
displacement[node + component * scalar_nodes] = reference_displacement[reduced];
}
}
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(equation_discretization(fixture), fixture.state(density, enthalpy));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh);
const auto inputs = dependencies::make_dependency_context(
dependencies::borrow<deformation::keys::VolumeDisplacement>(displacement, deformation_backend::DeformationValueTracking{}),
dependencies::own<deformation::keys::ReferenceDiscretization>(reference, deformation_backend::ReferenceDiscretizationTracking{}),
equation_inputs.resource<equation_backend::MeshAndCoefficientBindings>(),
equation_inputs.resource<equation_backend::CompactificationCoefficients>(),
equation_inputs.resource<blocks::density::mass>(),
equation_inputs.resource<::serif::tests::equations::Enthalpy>());
const serif::eos::models::Polytrope eos{1.5, 0.25};
const auto calculations = std::tuple_cat(deformation_backend::make_volume_displacement_computations(fixture.displacement_binding), equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture)));
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<Failure>(inputs, std::move(calculation)...);
},
calculations);
REQUIRE(cache.try_prepare(inputs));
const mfem::Vector& actual = cache.get<equation_backend::DisplacementCoefficients>().values;
CHECK(equation_fixture::relative_error(actual, reference_displacement) == 0.0);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
equation_fixture::Fixture other_fixture(mfem::Element::HEXAHEDRON, ordering);
const auto other_reference = deformation_backend::deformation_discretization(other_fixture.enthalpy_space, other_fixture.displacement_space, other_fixture.mesh);
const auto mismatched_inputs = dependencies::make_dependency_context(
inputs.resource<deformation::keys::VolumeDisplacement>(),
dependencies::own<deformation::keys::ReferenceDiscretization>(other_reference, deformation_backend::ReferenceDiscretizationTracking{}),
inputs.resource<equation_backend::MeshAndCoefficientBindings>(),
inputs.resource<equation_backend::CompactificationCoefficients>(),
inputs.resource<blocks::density::mass>(),
inputs.resource<::serif::tests::equations::Enthalpy>());
const auto mismatch = cache.try_prepare(mismatched_inputs);
REQUIRE_FALSE(mismatch);
CHECK(std::holds_alternative<deformation::DeformationFailure>(mismatch.error().error));
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
displacement.pop_back();
const auto invalid = cache.try_prepare(inputs);
REQUIRE_FALSE(invalid);
CHECK(std::holds_alternative<deformation::DeformationFailure>(invalid.error().error));
CHECK_FALSE(cache.ready());
}
}
TEST_CASE("Deformation rejects an equation displacement space with matching dimensions but different scalar numbering", "[unit][deformation][equation_integration]")
{
equation_fixture::Fixture fixture;
ReorderedDisplacementSpace reordered(&fixture.mesh, &fixture.enthalpy_collection, 3, mfem::Ordering::byNODES);
using Binding = serif::discretization::backend::mfem::TermBinding<blocks::displacement::vector, equation_fixture::Schema>;
const Binding binding(reordered);
REQUIRE(reordered.GetNDofs() == fixture.displacement_space.GetNDofs());
REQUIRE(reordered.FEColl() == fixture.displacement_space.FEColl());
REQUIRE(binding.size() == fixture.displacement_binding.size());
const VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper};
const mfem::Vector density = fixture.density();
const mfem::Vector enthalpy = fixture.enthalpy();
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh);
const std::vector<double> displacement(binding.full_size(), 0.0);
const auto inputs = dependencies::make_dependency_context(
dependencies::borrow<deformation::keys::VolumeDisplacement>(displacement, deformation_backend::DeformationValueTracking{}),
dependencies::own<deformation::keys::ReferenceDiscretization>(reference, deformation_backend::ReferenceDiscretizationTracking{}),
equation_inputs.resource<equation_backend::MeshAndCoefficientBindings>());
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<deformation::DeformationFailure>(inputs, std::move(calculation)...);
},
deformation_backend::make_volume_displacement_computations(binding));
const auto outcome = cache.try_prepare(inputs);
REQUIRE_FALSE(outcome);
CHECK(outcome.error().error.reason == deformation::DeformationFailureReason::incompatible_discretization);
CHECK(outcome.error().error.element == 0);
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<equation_backend::DisplacementCoefficients>());
}
TEST_CASE("Displacement values and sparse derivatives share supported MFEM coordinates", "[unit][deformation][displacement_mapping]") {
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
for (const bool restrict_support : {false, true}) {
mfem::Vector component_values, full_values, expected, dual, expected_transpose;
const auto projection = [&]() {
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON);
mfem::H1_FECollection collection(1, 3);
mfem::FiniteElementSpace space(&mesh, &collection, 3, ordering);
mfem::Array<int> support(mesh.GetNE());
support = 1;
if (restrict_support) {
support[1] = 0;
}
const SupportedDisplacement binding(space, support);
if (restrict_support) {
REQUIRE(binding.size() < binding.full_size());
}
component_values.SetSize(binding.full_size());
full_values.SetSize(binding.full_size());
for (int component = 0; component < space.GetVDim(); ++component) {
for (int node = 0; node < space.GetNDofs(); ++node) {
const int column = node + component * space.GetNDofs();
component_values[column] = 0.2 + 0.03 * column;
full_values[space.DofToVDof(node, component)] = component_values[column];
}
}
expected = binding.gather(full_values);
mfem::Vector converted(binding.size());
std::vector<int> visits(static_cast<std::size_t>(binding.size()), 0);
deformation_backend::for_each_displacement_coefficient(binding, [&](const int row, const int column) {
converted[row] = component_values[column];
++visits[static_cast<std::size_t>(row)];
});
CHECK(equation_fixture::relative_error(converted, expected) == 0.0);
CHECK(std::ranges::all_of(visits, [](const int count) { return count == 1; }));
dual = equation_fixture::deterministic(binding.size(), 0.4);
const mfem::Vector scattered = binding.scatter(dual);
expected_transpose.SetSize(binding.full_size());
for (int component = 0; component < space.GetVDim(); ++component) {
for (int node = 0; node < space.GetNDofs(); ++node) {
expected_transpose[node + component * space.GetNDofs()] = scattered[space.DofToVDof(node, component)];
}
}
return serif::model::backend::mfem::displacement_projection(binding);
}();
// The immutable projection must not depend on the departed mesh or binding.
REQUIRE(projection.has_assembly());
const auto sparse = projection.assemble();
REQUIRE(sparse->Finalized());
mfem::Vector actual(expected.Size()), transpose(expected_transpose.Size());
projection.action()->Mult(component_values, actual);
projection.action()->MultTranspose(dual, transpose);
CHECK(equation_fixture::relative_error(actual, expected) == 0.0);
CHECK(equation_fixture::relative_error(transpose, expected_transpose) == 0.0);
CHECK(std::abs(actual * dual - component_values * transpose) < 1.0e-12);
CHECK(sparse->NumNonZeroElems() == expected.Size());
}
}
}