Files
MeanField_SERiF/tests/solver/virial.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

155 lines
9.1 KiB
C++

#include <limits>
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "mfem/mesh/mesh.hpp"
#include "serif/tests/solver/virial.hpp"
namespace {
namespace diagnostics = serif::tests::solver;
namespace quantities = serif::dimensions;
namespace eos = serif::eos;
/** @brief A pressure-only EOS demonstrates that diagnostics need no enthalpy or polytropic parameters. */
struct PressureOnlyEOS {
using Relations = eos::relations::RelationCatalog<eos::relations::PressureFromDensity>;
enum class Mode { regular, negative, nonfinite, domain_failure, finite_surface };
Mode mode{Mode::regular};
/** @brief Supply an exactly integrable pressure or a deliberate validation failure. */
[[nodiscard]] quantities::PressureValue evaluate(eos::relations::PressureFromDensity, const quantities::DensityValue density) const {
if (mode == Mode::finite_surface) { return quantities::PressureValue{1.0 + density.value() * density.value()}; }
if (density.value() == 0.0) { return quantities::PressureValue{0.0}; }
if (mode == Mode::domain_failure) { throw eos::EOSEvaluationError{"Deliberate pressure domain rejection."}; }
if (mode == Mode::negative) { return quantities::PressureValue{-1.0}; }
if (mode == Mode::nonfinite) { return quantities::PressureValue{std::numeric_limits<double>::quiet_NaN()}; }
return quantities::PressureValue{density.value() * density.value()};
}
};
/** @brief Put the complete unit-cube boundary in the fixture's stellar-surface attribute. */
[[nodiscard]] mfem::Mesh material_cube() {
auto mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON);
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { mesh.GetBdrElement(boundary)->SetAttribute(1); }
mesh.SetAttributes();
return mesh;
}
/** @brief Use exact tensor-polynomial density on a translated physical cube with a genuine vacuum trace. */
struct VirialFixture {
mfem::Mesh mesh{material_cube()};
mfem::H1_FECollection collection{diagnostics::Density::FunctionalSpace::order, 3};
mfem::H1_FECollection potential_collection{diagnostics::Potential::FunctionalSpace::order, 3};
mfem::FiniteElementSpace scalar{&mesh, &collection};
mfem::FiniteElementSpace potential_space{&mesh, &potential_collection};
mfem::FiniteElementSpace vector{&mesh, &collection, 3, mfem::Ordering::byVDIM};
serif::discretization::backend::mfem::TermBinding<diagnostics::Density, diagnostics::Schema> binding{scalar};
mfem::GridFunction density{&scalar}, potential{&potential_space}, compactification{&scalar}, displacement{&vector};
serif::mapping::DomainMapper mapper{{}, std::make_unique<serif::mapping::compactification::KelvinCompactification>(
serif::utils::user_options::KelvinCompactificationOptions{})};
serif::mapping::GridFunctionMappingEvaluator geometry{mapper, displacement, compactification};
/** @brief Project rho=216 product[x_i(1-x_i)] and translate the physical domain by (2,-1,1/2). */
VirialFixture() {
mfem::FunctionCoefficient profile([](const mfem::Vector& point) {
double value = 216.0;
for (int axis = 0; axis < 3; ++axis) { value *= point[axis] * (1.0 - point[axis]); }
return value;
});
density.ProjectCoefficient(profile);
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(binding);
for (const int dof : boundary.reduced_dofs()) { density[dof] = 0.0; }
potential = -2.0;
compactification = 0.0;
mfem::VectorFunctionCoefficient shift(3, [](const mfem::Vector&, mfem::Vector& value) {
value.SetSize(3);
value[0] = 2.0; value[1] = -1.0; value[2] = 0.5;
});
displacement.ProjectCoefficient(shift);
}
};
}
TEST_CASE("Vacuum virial integrates tilted physical rotation about a displaced axis", "[solver][virial][unit]") {
VirialFixture fixture;
const serif::equations::RigidRotation rotation{{0.2, 0.4, -0.4}, {-1.0, 0.25, 0.75}};
const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential,
fixture.geometry, PressureOnlyEOS{}, rotation);
REQUIRE(result);
REQUIRE(result->boundary_dofs > 0);
// Each normalized coordinate has mean 1/2 and variance 1/20. With the physical translation,
// mean minus axis origin is (3.5,-.75,.25); T=1/2[|omega|^2(2/20+|d|^2)-(omega.d)^2]=2.2905.
for (const auto& sample : result->samples) {
CHECK_THAT(sample.mass, Catch::Matchers::WithinAbs(1.0, 2.0e-13));
CHECK_THAT(sample.volume, Catch::Matchers::WithinAbs(1.0, 2.0e-13));
CHECK_THAT(sample.kinetic_energy, Catch::Matchers::WithinAbs(2.2905, 2.0e-12));
CHECK_THAT(sample.pressure_integral, Catch::Matchers::WithinAbs(1.728, 2.0e-12));
CHECK_THAT(sample.potential_energy, Catch::Matchers::WithinAbs(-1.0, 2.0e-13));
CHECK_THAT(sample.gravity_work, Catch::Matchers::WithinAbs(0.0, 2.0e-12));
CHECK_THAT(sample.energy_virial(), Catch::Matchers::WithinAbs(8.765, 2.0e-11));
CHECK_THAT(sample.force_virial(), Catch::Matchers::WithinAbs(9.765, 2.0e-11));
}
CHECK(result->quadrature_allowance < 1.0e-11);
CHECK_FALSE(result->passes()); // This analytically integrable candidate is deliberately not an equilibrium.
}
TEST_CASE("Vacuum virial rejects unsupported thermodynamics and an uncertified surface", "[solver][virial][unit]") {
VirialFixture fixture;
PressureOnlyEOS equation_of_state;
SECTION("Negative pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::negative; }
SECTION("Nonfinite pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::nonfinite; }
SECTION("EOS domain rejection") { equation_of_state.mode = PressureOnlyEOS::Mode::domain_failure; }
SECTION("Finite pressure at zero density") { equation_of_state.mode = PressureOnlyEOS::Mode::finite_surface; }
SECTION("Even a tiny nonzero density trace is not silently clipped") {
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(fixture.binding);
fixture.density[boundary.reduced_dofs()[0]] = 1.0e-17;
}
SECTION("Negative represented interior density") { fixture.density *= -1.0; }
SECTION("Zero binding energy has no usable normalization") { fixture.potential = 0.0; }
SECTION("Missing stellar boundary cannot certify a vacuum trace") {
for (int face = 0; face < fixture.mesh.GetNBE(); ++face) { fixture.mesh.GetBdrElement(face)->SetAttribute(2); }
fixture.mesh.SetAttributes();
}
const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential,
fixture.geometry, equation_of_state, serif::equations::RigidRotation{{0.0, 0.0, 0.0}});
REQUIRE_FALSE(result);
CHECK_FALSE(result.error().empty());
}
TEST_CASE("Vacuum virial certifies the entire infinity potential trace", "[solver][virial][unit]") {
VirialFixture fixture;
serif::discretization::backend::mfem::TermBinding<diagnostics::Potential, diagnostics::Schema> potential_binding{fixture.potential_space};
fixture.potential = 0.0;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.mesh.GetBdrElement(0)->SetAttribute(2);
fixture.mesh.SetAttributes();
CHECK(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.potential = -2.0;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.potential = 0.0;
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(potential_binding);
fixture.potential[boundary.reduced_dofs()[boundary.size() / 2]] = 1.0e-17;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
}
TEST_CASE("Vacuum virial acceptance reserves room for integration uncertainty", "[solver][virial][unit]") {
diagnostics::VacuumVirialAssessment assessment{};
auto& final = assessment.samples.back();
final.potential_energy = -1.0;
final.pressure_integral = 1.0 / 3.0;
assessment.quadrature_allowance = 1.0e-8;
CHECK(assessment.passes());
final.pressure_integral = (1.0 + 9.95e-7) / 3.0;
CHECK_FALSE(assessment.passes()); // A point estimate below 1e-6 does not leave enough integration margin.
final.pressure_integral = 1.0 / 3.0;
assessment.quadrature_allowance = 1.01e-7;
CHECK_FALSE(assessment.passes()); // The separately bounded quadrature requirement also applies at exact virial balance.
assessment.quadrature_allowance = 0.0;
final.potential_energy = 0.0;
CHECK_FALSE(assessment.passes());
}