Files
MeanField_SERiF/tests/model/reconstruction_system.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

207 lines
11 KiB
C++

#include <algorithm>
#include <cmath>
#include <span>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/model/finite_elements.hpp"
#include "serif/runtime.hpp"
#include "serif/solver/seed/state.hpp"
#include "../sandbox/polytropic_model.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace blocks = serif::discretization::blocks;
namespace deformation = serif::geometry::deformation;
namespace normalization = serif::solver::normalization;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using Constant = blocks::barotropic_constant::mass_normalization;
using Omega = blocks::rotation::angular_velocity;
using Shape = blocks::surface_deformation::coefficients;
using Kind = blocks::RepresentedValueKind;
/** @brief Make quadrature refinement explicit without changing the represented stellar state. */
[[nodiscard]] serif::operators::backend::mfem::VolumeOptions integration_order(int order) {
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = order});
return {.quadrature_policy = serif::discretization::quadrature::Policy{rules}};
}
/** @brief Freeze a residual before preparing another physical candidate. */
template <typename System>
[[nodiscard]] mfem::Vector residual_copy(const System& system) {
mfem::Vector result(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), result.GetData());
return result;
}
/** @brief Exercise all coefficients with a reproducible bounded direction, independent of mesh numbering. */
[[nodiscard]] mfem::Vector direction_values(int size, double phase) {
mfem::Vector result(size);
for (int index = 0; index < size; ++index) { result[index] = std::sin(0.37 * index + phase); }
return result;
}
/** @brief Compare each physical equation separately so gravity cannot mask an incorrect scalar or surface row. */
template <typename Layout>
void check_rows(const mfem::Vector& actual, const mfem::Vector& expected, const Layout& layout,
double relative_tolerance, double absolute_tolerance) {
REQUIRE(actual.Size() == expected.Size());
int offset = 0;
for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) {
CAPTURE(row);
double error_squared = 0.0, reference_squared = 0.0;
for (std::size_t entry = 0; entry < layout.residual_sizes[row]; ++entry, ++offset) {
const double error = actual[offset] - expected[offset];
error_squared += error * error;
reference_squared += expected[offset] * expected[offset];
}
CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared));
}
}
/** @brief Perturb solver coordinates through their actual dimensional scaling. */
template <typename Layout>
[[nodiscard]] model::StellarState<Layout> perturbed(const model::StellarState<Layout>& state,
const normalization::DiagonalNormalization& coordinates, const mfem::Vector& direction, double step) {
mfem::Vector physical(direction.Size());
coordinates.denormalize_state({direction.GetData(), static_cast<std::size_t>(direction.Size())},
{physical.GetData(), static_cast<std::size_t>(physical.Size())});
model::StellarState<Layout> result(state);
for (int index = 0; index < direction.Size(); ++index) { result.values()[index] += step * physical[index]; }
return result;
}
/** @brief Verify strong physical boundary equations after the complete normalized transpose assembly. */
template <typename Field, typename Layout, typename Selection>
void check_boundary_transpose(const backend::NormalizedSystem& system, const Layout& layout, const Selection& selected) {
const auto& coordinates = system.coordinates();
const int row_offset = static_cast<int>(layout.template offset<Kind::residual, Field>());
const int column_offset = static_cast<int>(layout.template offset<Kind::value, Field>());
mfem::Vector dual(system.jacobian().Height()), expected(system.jacobian().Width()), actual;
dual = 0.0;
expected = 0.0;
REQUIRE(selected.size() > 0);
for (int row : selected.reduced_dofs()) {
const double value = 0.2 + 0.01 * (row % 7);
dual[row_offset + row] = value;
expected[column_offset + row] = value * coordinates.residual_factors()[row_offset + row] /
coordinates.state_factors()[column_offset + row];
}
system.jacobian().MultTranspose(dual, actual);
actual -= expected;
CHECK(actual.Norml2() <= 2e-12 * expected.Norml2());
}
}
TEST_CASE("Weighted moving stars retain complete normalized derivatives and physical boundary rows", "[model][integration][reconstruction][reconstruction_system]") {
serif::initialize();
const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04);
const auto radial = serif::solver::seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 3},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{.surface = {.maximum_degree = 2}},
serif::discretization::reconstruction::SurfacePower<Density>{.density_exponent = 4.0 / 3.0});
auto seeded = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(seeded);
auto state = *seeded;
state.get<Omega>()[0] = 0.08;
REQUIRE(state.get<Shape>().size() == 9);
state.get<Shape>()[0] = 0.01;
state.get<Shape>()[6] = -0.015;
const auto scales = normalization::prepare_stellar_reference_scales(radial->scales.mass, radial->scales.radius);
REQUIRE(scales);
const auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = star, .finite_elements = finite_elements,
.normalization = normalization::NormalizationOptions{.scheme = *scales}});
auto evaluator = backend::bind_stellar_model(configuration, state, integration_order(12));
const auto evaluated = evaluator.evaluate(state);
REQUIRE(evaluated);
const auto frozen = *evaluated;
const auto& layout = state.layout();
const auto& coordinates = frozen->coordinates();
const mfem::Vector original_residual = residual_copy(*frozen);
const mfem::Vector seed = direction_values(frozen->jacobian().Width(), 0.43);
const mfem::Vector dual = direction_values(frozen->jacobian().Height(), 1.17);
mfem::Vector transpose;
frozen->jacobian().MultTranspose(dual, transpose);
int offset = 0;
for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) {
CAPTURE(column);
mfem::Vector direction(seed);
if (column < layout.state_sizes.size()) {
direction = 0.0;
std::copy_n(seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset);
offset += static_cast<int>(layout.state_sizes[column]);
}
mfem::Vector action;
frozen->jacobian().Mult(direction, action);
REQUIRE(action.Norml2() > 1e-10);
CHECK(action * dual == Catch::Approx(direction * transpose).epsilon(2e-11).margin(2e-11));
const std::vector<double> steps = column < layout.state_sizes.size() ? std::vector<double>{2e-6} : std::vector<double>{2e-6, 1e-6};
for (const double step : steps) {
CAPTURE(step);
const auto plus = evaluator.evaluate(perturbed(state, coordinates, direction, step));
REQUIRE(plus);
mfem::Vector difference = residual_copy(**plus);
const auto minus = evaluator.evaluate(perturbed(state, coordinates, direction, -step));
REQUIRE(minus);
difference -= residual_copy(**minus);
difference /= 2.0 * step;
check_rows(action, difference, layout, 5e-6, 2e-8);
}
}
const auto density_surface = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(finite_elements.binding<Density>());
const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.binding<Potential>());
check_boundary_transpose<Density>(*frozen, layout, density_surface);
check_boundary_transpose<Potential>(*frozen, layout, infinity);
const auto restored = evaluator.evaluate(state);
REQUIRE(restored);
check_rows(residual_copy(**restored), original_residual, layout, 0.0, 0.0);
mfem::Vector repeated;
frozen->jacobian().MultTranspose(dual, repeated);
repeated -= transpose;
CHECK(repeated.Norml2() == 0.0);
}
TEST_CASE("Weighted moving-star mass and angular momentum converge under independent quadrature refinement", "[model][integration][reconstruction][reconstruction_quadrature]") {
serif::initialize();
const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04);
const auto radial = serif::solver::seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 3},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{.surface = {.maximum_degree = 2}},
serif::discretization::reconstruction::SurfacePower<Density>{.density_exponent = 4.0 / 3.0});
auto state = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(state);
state->get<Omega>()[0] = 0.08;
state->get<Shape>()[6] = -0.015;
std::vector<mfem::Vector> residuals;
for (int order : {12, 20, 28}) {
auto evaluator = backend::bind_stellar_model(star, finite_elements, *state, integration_order(order));
const auto evaluated = evaluator.evaluate(*state);
REQUIRE(evaluated);
residuals.push_back(residual_copy(**evaluated));
}
const auto check_functional = [&]<typename Row>() {
const int offset = static_cast<int>(state->layout().template offset<Kind::residual, Row>());
const double coarse_error = std::abs(residuals[0][offset] - residuals[2][offset]);
const double fine_error = std::abs(residuals[1][offset] - residuals[2][offset]);
CAPTURE(offset, coarse_error, fine_error);
CHECK(fine_error <= 0.7 * coarse_error + 1e-11);
CHECK(fine_error < 1e-4);
};
check_functional.template operator()<Constant>();
check_functional.template operator()<Omega>();
}