#include #include #include #include #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; 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::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 binding{scalar}; mfem::GridFunction density{&scalar}, potential{&potential_space}, compactification{&scalar}, displacement{&vector}; serif::mapping::DomainMapper mapper{{}, std::make_unique( 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 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()); }