Files
MeanField_SERiF/tests/serialize/top_mfem.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

367 lines
22 KiB
C++

#include <algorithm>
#include <array>
#include <bit>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <numbers>
#include <string>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/discretization/field_values.hpp"
#include "serif/backend/mfem/serialize/resources.hpp"
#include "serif/backend/mfem/serialize/top.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/model/finite_elements.hpp"
#include "serif/solver/seed/polytrope.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "../sandbox/stellar_rayfem.hpp"
namespace {
namespace fixture = serif::tests::solver;
namespace serialization = serif::utils::serialize;
namespace native_archive = serif::backend::mfem::serialize;
namespace seed = serif::solver::seed;
/** @brief Supply the exact zero displacement and accepted shape used by a manufactured display state. */
struct StationaryDisplayCache {
struct ShapeSnapshot { std::span<const double> parameters; } linearization;
::mfem::Vector displacement;
/** @brief Expose only the two accepted-state records consumed by the display adapter. */
template <typename Key>
[[nodiscard]] const auto& get() const {
if constexpr (std::same_as<Key, serif::geometry::deformation::keys::Linearization>) {
return linearization;
} else {
static_assert(std::same_as<Key, serif::geometry::deformation::backend::mfem::volume_displacement_keys::Coefficients>);
return displacement;
}
}
};
/** @brief Keep successful exports and rejected partial files within a test's lifetime. */
struct ExportDirectory {
std::filesystem::path path = std::filesystem::temp_directory_path() /
("serif-top-mfem-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
~ExportDirectory() { std::error_code error; std::filesystem::remove_all(path, error); }
};
/** @brief Small sampling grids test the adapter; their coarse P2 density is not a seismic accuracy benchmark. */
serialization::TopExportOptions small_export() {
return {.radial_points = 12, .maximum_degree = 4, .angular_points = 12, .azimuthal_points = 4,
.relative_symmetry_tolerance = 3.0e-2, .relative_surface_tolerance = 1.0e-6,
.relative_surface_projection_tolerance = 1.0e-3};
}
/** @brief Evaluate the original FE field at the center independently of the exporter and seed scales. */
template <typename Discretization, typename State>
double central_density(const Discretization& finite_elements, const State& state) {
const auto& binding = finite_elements.template binding<fixture::Density>();
const auto values = state.template get<fixture::Density>();
::mfem::Vector reduced(static_cast<int>(values.size()));
std::ranges::copy(values, reduced.GetData());
::mfem::GridFunction density(&binding.space());
density = binding.scatter(reduced);
::mfem::Vector center(3);
center = 0.0;
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (!binding.supports(element)) continue;
::mfem::InverseElementTransformation inverse(binding.mesh().GetElementTransformation(element));
::mfem::IntegrationPoint point;
if (inverse.Transform(center, point) == ::mfem::InverseElementTransformation::Inside)
return density.GetValue(element, point);
}
FAIL("The independently sampled stellar center must lie in a material element.");
return 0.0;
}
/** @brief Input preservation includes signed zero, as well as all physical and reference mesh coordinates. */
template <typename State>
void check_state_unchanged(const State& state, const std::vector<double>& original) {
REQUIRE(state.values().size() == original.size());
CHECK(std::ranges::equal(state.values(), original, [](double a, double b) {
return std::bit_cast<std::uint64_t>(a) == std::bit_cast<std::uint64_t>(b);
}));
}
/** @brief Read the TOP scalar file independently of the serializer's returned report. */
std::array<double, 9> scalar_file(const std::filesystem::path& directory) {
std::ifstream input(directory / "lambda_eps");
REQUIRE(input.good());
std::array<double, 9> values{};
for (double& value : values) {
REQUIRE(static_cast<bool>(input >> value));
REQUIRE(std::isfinite(value));
}
return values;
}
}
TEST_CASE("TOP exports the current deformed FE star without changing its state or either mesh", "[serialize][top_export][mfem]") {
constexpr double G = 0.7, K = 0.3, mass = 1.4;
const auto model = fixture::make_model(1.0, K, mass, 0.03, G);
const auto radial = seed::make_polytropic_seed(model);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto finite_elements = fixture::make_discretization(resources, 0);
auto state = seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial);
REQUIRE(state);
// This is a valid manufactured state, not a claimed equilibrium. A nonzero
// monopole makes using the undeformed source mesh detectably wrong.
state->template get<fixture::Shape>()[0] = 0.02 * radial->scales.radius.value();
const double rho_c = central_density(finite_elements, *state);
REQUIRE(rho_c > 0.0);
const std::vector<double> original_state(state->values().begin(), state->values().end());
const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh);
const auto original_reference = native_archive::mesh_text(*resources->mesh.reference_mesh);
const ExportDirectory output;
const auto result = serialization::export_top(output.path, model, finite_elements, *state, small_export());
INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message));
check_state_unchanged(*state, original_state);
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference);
REQUIRE(result);
CHECK(result->equatorial_radius > radial->scales.radius.value() * 1.003);
CHECK(result->polar_radius == Catch::Approx(result->equatorial_radius).epsilon(5.0e-4));
CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * G * rho_c)).epsilon(2.0e-10));
// At n=1 the density cancels: this tests recovery of both physical G and K.
CHECK(result->lambda == Catch::Approx(4.0 * std::numbers::pi * G * std::pow(result->equatorial_radius, 2) / (2.0 * K)).epsilon(2.0e-12));
const auto written = scalar_file(output.path);
CHECK(written[0] == result->lambda);
CHECK(written[2] == result->alpha);
CHECK(written[4] == 1.0);
CHECK(written[8] == small_export().radial_points);
CHECK(written[7] / std::sqrt(3.0 * written[2]) ==
Catch::Approx(state->template get<fixture::Omega>()[0] / result->angular_frequency_unit).epsilon(2.0e-12));
CHECK(std::filesystem::file_size(output.path / "enthalpy_et_der") > 0);
CHECK(std::filesystem::file_size(output.path / "domains_boundaries") > 0);
// The EOS and current density determine the export. A different unsatisfied
// mass prescription must not change TOP's background normalization.
const auto other_mass = fixture::make_model(1.0, K, 3.0 * mass, 0.03, G);
const ExportDirectory other_output;
const auto other = serialization::export_top(other_output.path, other_mass, finite_elements, *state, small_export());
INFO((other ? "Changed-prescription export succeeded" : other.error().path + ": " + other.error().message));
REQUIRE(other);
CHECK(other->alpha == result->alpha);
CHECK(other->rotation_over_kepler == result->rotation_over_kepler);
check_state_unchanged(*state, original_state);
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference);
}
TEST_CASE("TOP sampling honors a tilted rotation axis on a surface and equator refined true-DOF mesh", "[serialize][top_export][mfem][nonconforming]") {
namespace model = serif::model;
namespace deformation = serif::geometry::deformation;
const auto base = fixture::make_model();
const auto star = model::make_stellar_model(base.equation_of_state(), base.equations(),
model::FixedTotalMass{serif::dimensions::MassValue{1.0}},
model::FixedAngularMomentum{model::FixedAngularMomentum::Parameters{
.angular_momentum = serif::dimensions::AngularMomentumValue{0.04}, .axis = {1.0, 2.0, 3.0}}});
const auto& axis = star.specification<model::FixedAngularMomentum>().axis();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 6,
.surface_refinement = {.levels = 1, .equatorial_levels = 1, .axis = axis}},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
.surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}});
const auto& binding = finite_elements.template binding<fixture::Density>();
REQUIRE(binding.space().GetTrueVSize() < binding.space().GetVSize());
REQUIRE(binding.constrained());
auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(state);
// A positive quadrupolar perturbation makes the chosen axis observable:
// treating this field as axisymmetric about z must not silently pass.
const double radius = radial->scales.radius.value();
::mfem::FunctionCoefficient prescribed_density([&](const ::mfem::Vector& position) {
const double r = position.Norml2();
if (r >= radius) return 0.0;
const double x = std::numbers::pi * r / radius;
const double profile = (x == 0.0 ? 1.0 : std::sin(x) / x) * radial->scales.central_density.value();
double axial = 0.0;
for (int component = 0; component < 3; ++component) axial += axis[component] * position[component];
return profile * (1.0 + 0.6 * (3.0 * axial * axial - r * r) / (radius * radius));
});
::mfem::GridFunction density(&binding.space());
density.ProjectCoefficient(prescribed_density);
const auto reduced = binding.gather(density);
std::copy_n(reduced.GetData(), reduced.Size(), state->template get<fixture::Density>().begin());
const auto trace = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(binding);
for (const int dof : trace.reduced_dofs()) state->template get<fixture::Density>()[dof] = 0.0;
const std::vector<double> original_state(state->values().begin(), state->values().end());
const auto original_mesh = native_archive::mesh_text(binding.mesh());
const ExportDirectory output;
const auto result = serialization::export_top(output.path, star, finite_elements, *state, small_export());
INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message));
check_state_unchanged(*state, original_state);
CHECK(native_archive::mesh_text(binding.mesh()) == original_mesh);
REQUIRE(result);
CHECK(result->polar_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4));
CHECK(result->equatorial_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4));
CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * central_density(finite_elements, *state))).epsilon(2.0e-10));
const auto written = scalar_file(output.path);
CHECK(written[7] / std::sqrt(3.0 * written[2]) ==
Catch::Approx(state->template get<fixture::Omega>()[0] / result->angular_frequency_unit).epsilon(2.0e-12));
const auto wrong_axis = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.04);
const ExportDirectory wrong_output;
const auto rejected = serialization::export_top(wrong_output.path, wrong_axis, finite_elements, *state, small_export());
REQUIRE_FALSE(rejected);
CHECK_FALSE(std::filesystem::exists(wrong_output.path / "lambda_eps"));
}
TEST_CASE("TOP rejects a finite pressure stellar boundary before publishing vacuum polytrope files", "[serialize][top_export][mfem]") {
const auto vacuum = fixture::make_model();
const auto radial = seed::make_polytropic_seed(vacuum);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto state = seed::backend::mfem::make_polytropic_state(vacuum, finite_elements, *radial);
REQUIRE(state);
const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01);
const std::vector<double> original_state(state->values().begin(), state->values().end());
const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh);
const ExportDirectory output;
const auto result = serialization::export_top(output.path, pressured, finite_elements, *state, small_export());
REQUIRE_FALSE(result);
CHECK(result.error().message.find("pressure") != std::string::npos);
CHECK_FALSE(std::filesystem::exists(output.path / "lambda_eps"));
check_state_unchanged(*state, original_state);
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
}
TEST_CASE("FieldValues samples reconstructed density and its correctly mapped gradient", "[serialize][mfem][reconstruction]") {
namespace discretization = serif::discretization;
namespace backend = discretization::backend::mfem;
using Density = discretization::blocks::density::mass;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
constexpr double exponent = 4.0 / 3.0;
::mfem::Mesh mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, ::mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0);
for (int face = 0; face < mesh.GetNBE(); ++face) {
auto& transformation = *mesh.GetBdrElementTransformation(face);
::mfem::Vector center(3);
transformation.Transform(::mfem::Geometries.GetCenter(transformation.GetGeometryType()), center);
mesh.GetBdrElement(face)->SetAttribute(center[0] == 0.0 ? 1 : 2);
}
::mfem::H1_FECollection collection(discretization::blocks::SpatialTermTraits<Density>::order, 3);
::mfem::FiniteElementSpace space(&mesh, &collection);
backend::TermBinding<Density, Schema> ordinary(space);
auto plan = std::make_shared<backend::ReconstructionPlan>(ordinary, 1,
std::make_shared<discretization::reconstruction::SurfacePowerProfile>(exponent));
backend::TermBinding<Density, Schema> reconstructed(space, plan);
::mfem::FunctionCoefficient profile([](const ::mfem::Vector& position) {
return std::pow(position[0] / 2.0, exponent) * (1.0 + position[1]) * (2.0 - position[2]);
});
::mfem::GridFunction nodal(&space);
nodal.ProjectCoefficient(profile);
const ::mfem::Vector coefficients = reconstructed.gather(nodal);
backend::FieldValues<Density> values(reconstructed, coefficients);
backend::FieldValues<Density> polynomial(ordinary, coefficients);
CHECK_THROWS(backend::FieldValues<discretization::blocks::displacement::vector>(ordinary, coefficients));
::mfem::IntegrationPoint point;
point.Set3(0.2, 0.3, 0.4);
const double expected = std::pow(point.x, exponent) * 1.9 * 0.4;
CHECK(values.value(0, point) == Catch::Approx(expected).margin(2.0e-13));
CHECK(std::abs(polynomial.value(0, point) - expected) > 1.0e-4);
CHECK(polynomial.value(0, point) == Catch::Approx(nodal.GetValue(0, point)).margin(2.0e-13));
const auto gradient = values.gradient(0, point);
CHECK(gradient[0] == Catch::Approx(exponent * std::pow(point.x, exponent - 1.0) * 1.9 * 0.4 / 2.0).margin(2.0e-13));
CHECK(gradient[1] == Catch::Approx(std::pow(point.x, exponent) * 0.4).margin(2.0e-13));
CHECK(gradient[2] == Catch::Approx(-std::pow(point.x, exponent) * 1.9).margin(2.0e-13));
serif::mapping::MappingPointContext dilation;
dilation.inverse_mapping_jacobian.SetSize(3);
dilation.inverse_mapping_jacobian = 0.0;
for (int axis = 0; axis < 3; ++axis) dilation.inverse_mapping_jacobian(axis, axis) = 0.5;
const auto physical_gradient = values.gradient(0, point, dilation);
for (int axis = 0; axis < 3; ++axis) CHECK(physical_gradient[axis] == Catch::Approx(0.5 * gradient[axis]).margin(2.0e-13));
CHECK(values.value(0, point, dilation) == Catch::Approx(expected).margin(2.0e-13));
const auto& points = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 8);
::mfem::Vector batch;
values.values(0, points, batch);
REQUIRE(batch.Size() == points.GetNPoints());
for (int sample = 0; sample < points.GetNPoints(); ++sample) {
const auto& at = points.IntPoint(sample);
CHECK(batch[sample] == Catch::Approx(std::pow(at.x, exponent) * (1.0 + 3.0 * at.y) * (2.0 - 4.0 * at.z)).margin(2.0e-12));
}
point.x = 0.0;
CHECK(values.value(0, point) == Catch::Approx(0.0).margin(2.0e-13));
for (double component : values.gradient(0, point)) CHECK(component == Catch::Approx(0.0).margin(2.0e-13));
}
TEST_CASE("TOP samples the reconstructed field between its density nodes", "[serialize][top_export][mfem][reconstruction]") {
namespace model = serif::model;
namespace deformation = serif::geometry::deformation;
namespace discretization = serif::discretization;
namespace backend = discretization::backend::mfem;
const auto star = fixture::make_model(4.0 / 3.0);
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 6},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
.surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}},
discretization::reconstruction::SurfacePower<fixture::Density>{.density_exponent = 4.0 / 3.0});
const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(state);
const auto& binding = finite_elements.template binding<fixture::Density>();
const auto density = state->template get<fixture::Density>();
::mfem::Vector coefficients(static_cast<int>(density.size()));
std::ranges::copy(density, coefficients.GetData());
backend::FieldValues<fixture::Density> values(binding, coefficients);
::mfem::GridFunction polynomial(&binding.space());
polynomial = binding.scatter(coefficients);
const std::vector<double> displacement(finite_elements.deformation.reference.displacement_space.GetVSize(), 0.0);
using Binding = std::remove_cvref_t<decltype(binding)>;
auto temporary_binding = std::make_unique<Binding>(binding.space(), binding.reconstruction());
serif::backend::mfem::serialize::detail::TopModelSampler sampler(*temporary_binding, density,
finite_elements.deformation.reference, displacement, {0.0, 0.0, 1.0}, {0.0, 0.0, 0.0});
temporary_binding.reset();
int selected = -1;
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (binding.reconstruction_element(element) != nullptr) { selected = element; break; }
}
REQUIRE(selected >= 0);
::mfem::IntegrationPoint point;
point.Set3(0.27, 0.41, 0.69);
::mfem::Vector position(3);
binding.mesh().GetElementTransformation(selected)->Transform(point, position);
const double radius = position.Norml2();
const double expected = values.value(selected, point);
REQUIRE(std::abs(expected - polynomial.GetValue(selected, point)) > 1.0e-8 * radial->scales.central_density.value());
const auto exported = sampler.density(radius, position[2] / radius, std::atan2(position[1], position[0]));
INFO((exported ? "Sample succeeded" : exported.error().message));
REQUIRE(exported);
CHECK(*exported == Catch::Approx(expected).epsilon(2.0e-10));
// Rayfem receives a display approximation sampled from the same physical field.
// This checks the parent/submesh coordinate correspondence without opening a viewer.
serif::visualization::backend::mfem::StellarFrame frame(finite_elements);
StationaryDisplayCache cache{.linearization = {state->template get<fixture::Shape>()},
.displacement = ::mfem::Vector(finite_elements.template binding<fixture::Displacement>().size())};
cache.displacement = 0.0;
frame.update(*state, cache);
const auto fields = frame.fields();
REQUIRE(fields.front().name == "density (sampled)");
const auto& display = *fields.front().gf;
CHECK(display.FESpace()->GetMaxElementOrder() == binding.space().GetMaxElementOrder() + 3);
const auto& parent_elements = static_cast<const ::mfem::SubMesh&>(frame.mesh()).GetParentElementIDMap();
bool displayed = false;
for (int element = 0; element < parent_elements.Size(); ++element) {
if (parent_elements[element] != selected) continue;
displayed = true;
const auto& nodes = display.FESpace()->GetFE(element)->GetNodes();
for (int node = 0; node < nodes.GetNPoints(); ++node) {
const auto& at = nodes.IntPoint(node);
CHECK(display.GetValue(element, at) == Catch::Approx(values.value(selected, at)).margin(3.0e-12 * radial->scales.central_density.value()));
}
}
REQUIRE(displayed);
}