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.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

362
tests/solver/seed.cpp Normal file
View File

@@ -0,0 +1,362 @@
#include <algorithm>
#include <cmath>
#include <numbers>
#include <limits>
#include "serif/backend/mfem/discretization/dof_selection.hpp"
#include "serif/backend/mfem/model/equilibrium.hpp"
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/seed/polytrope.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
namespace {
namespace seed = serif::solver::seed;
namespace dimensions = serif::dimensions;
namespace fixture = serif::tests::solver;
}
TEST_CASE("Polytropic seeds retain the EOS and solve the requested mass scale", "[unit][solver][seed]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}});
REQUIRE(radial);
CHECK(radial->scales.mass.value() == Catch::Approx(1.0).epsilon(2.0e-12));
CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-9));
CHECK(radial->scales.central_density.value() == Catch::Approx(std::numbers::pi / 4.0).epsilon(3.0e-9));
CHECK(radial->scales.central_enthalpy.value() == Catch::Approx(1.0).epsilon(3.0e-9));
CHECK(*radial->scales.polytropic_constant == Catch::Approx(2.0 / std::numbers::pi).epsilon(3.0e-12));
const double inertia = 2.0 * (std::numbers::pi * std::numbers::pi - 6.0) / (3.0 * std::numbers::pi * std::numbers::pi);
CHECK(radial->moment_of_inertia == Catch::Approx(inertia).epsilon(3.0e-8));
CHECK(radial->angular_velocity == Catch::Approx(0.05 / inertia).epsilon(3.0e-8));
const auto changed_mass = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.5, 0.4}, dimensions::MassValue{2.0});
REQUIRE(changed_mass);
CHECK(changed_mass->scales.mass.value() == Catch::Approx(2.0).epsilon(3.0e-12));
CHECK(*changed_mass->scales.polytropic_constant == Catch::Approx(0.4).epsilon(3.0e-12));
}
TEST_CASE("Polytropic seeds diagnose missing homology scales and incompatible physical models", "[unit][solver][seed]") {
const serif::eos::models::Polytrope n3{3.0, 1.0};
CHECK_FALSE(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0}));
CHECK(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0}, {.central_density = dimensions::DensityValue{2.0}}));
const auto dimensionless = serif::utils::laneemden::solve(3.0);
REQUIRE(dimensionless);
const auto scales = serif::utils::laneemden::scale_from_polytropic_constant(*dimensionless, 1.0, dimensions::DensityValue{2.0},
dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0});
REQUIRE(scales);
CHECK(seed::make_polytropic_seed(n3, scales->mass, {.central_density = dimensions::DensityValue{2.0}}));
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{-1.0}));
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{1.0},
{.integration = {.maximum_radius = 1.0}}));
}
TEST_CASE("Spherical seed projection initializes every moving stellar coordinate by physical identity", "[integration][solver][seed]") {
const auto star = fixture::make_model();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto discretization = fixture::make_discretization(resources, 2);
const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *radial);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); }));
CHECK(state->template get<fixture::Density>().size() == static_cast<std::size_t>(resources->density.size()));
CHECK(state->template get<fixture::Potential>().size() == static_cast<std::size_t>(resources->potential.size()));
CHECK(state->template get<fixture::Shape>().size() == 9);
CHECK(std::ranges::all_of(state->template get<fixture::Shape>(), [](double value) { return value == 0.0; }));
CHECK(state->template get<fixture::Constant>()[0] == Catch::Approx(-1.0).epsilon(3.0e-7));
CHECK(state->template get<fixture::Omega>()[0] == 0.0);
CHECK(std::ranges::min(state->template get<fixture::Density>()) >= 0.0);
CHECK(std::ranges::max(state->template get<fixture::Density>()) > 0.7);
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
REQUIRE(surface_rows.size() > 0);
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
}
}
TEST_CASE("Central seed density remains an initial guess independent of the model's mass prescription", "[unit][solver][seed]") {
const auto base = fixture::make_model(3.0, 0.25);
const auto model = serif::model::make_stellar_model(base.equation_of_state(), base.equations(),
serif::model::FixedTotalMass{dimensions::MassValue{1.0}},
base.specification<serif::model::FixedAngularMomentum>());
const auto from_model = seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{2.0}});
REQUIRE(from_model);
CHECK(from_model->scales.central_density.value() == 2.0);
CHECK(from_model->scales.central_enthalpy.value() == Catch::Approx(std::cbrt(2.0)));
const seed::PolytropeOptions options{.central_density = dimensions::DensityValue{1.7}};
const auto explicit_seed = seed::make_polytropic_seed(model, options);
const auto other_mass = seed::make_polytropic_seed(base.equation_of_state(), dimensions::MassValue{9.0}, options);
REQUIRE(explicit_seed);
REQUIRE(other_mass);
CHECK(explicit_seed->scales.central_density.value() == 1.7);
CHECK(model.specification<serif::model::FixedTotalMass>().target().value() == 1.0);
CHECK(explicit_seed->scales.radius == other_mass->scales.radius);
CHECK(explicit_seed->scales.mass == other_mass->scales.mass);
double previous_density = 2.0;
double previous_enthalpy = from_model->scales.central_enthalpy.value();
for (int index = 0; index < 64; ++index) {
const double fraction = static_cast<double>(index) / 63.0;
const auto physical = serif::utils::laneemden::evaluate_physical(from_model->solution, from_model->scales,
dimensions::LengthValue{fraction * from_model->scales.radius.value()});
const auto first = serif::utils::laneemden::evaluate_physical(explicit_seed->solution, explicit_seed->scales,
dimensions::LengthValue{fraction * explicit_seed->scales.radius.value()});
const auto second = serif::utils::laneemden::evaluate_physical(other_mass->solution, other_mass->scales,
dimensions::LengthValue{fraction * other_mass->scales.radius.value()});
REQUIRE(physical);
REQUIRE(first);
REQUIRE(second);
CHECK(first->density == second->density);
CHECK(first->enthalpy == second->enthalpy);
CHECK(physical->density.value() >= 0.0);
CHECK(physical->enthalpy.value() >= 0.0);
CHECK(physical->density.value() <= previous_density);
CHECK(physical->enthalpy.value() <= previous_enthalpy);
previous_density = physical->density.value();
previous_enthalpy = physical->enthalpy.value();
}
CHECK(previous_density == 0.0);
CHECK(previous_enthalpy == 0.0);
}
TEST_CASE("Seed generation rejects invalid density scales and stars without a finite surface", "[unit][solver][seed]") {
const auto model = fixture::make_model();
for (const double density : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{density}}));
}
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{5.0, 0.25}, dimensions::MassValue{1.0},
{.central_density = dimensions::DensityValue{1.0}}));
}
TEST_CASE("Radial seed projection rejects incompatible surfaces and invalid radius tolerances", "[integration][solver][seed]") {
const auto model = fixture::make_model();
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);
for (const double tolerance : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial, {.surface_radius_relative_tolerance = tolerance}));
}
const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01);
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(pressured, finite_elements, *radial));
const auto wrong_reference = fixture::make_resources(1.1 * radial->scales.radius.value());
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, fixture::make_discretization(wrong_reference, 0), *radial));
}
TEST_CASE("Projected radial stars satisfy mass and angular momentum constraints with exact surface and infinity rows", "[integration][solver][seed]") {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace dependencies = serif::utils::dependencies;
using Kind = serif::discretization::blocks::RepresentedValueKind;
const auto star = fixture::make_model();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value(), 1, 4);
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
using State = std::remove_cvref_t<decltype(*state)>;
static_assert(dependencies::key_count_v<typename State::Layout::Values> == 5);
for (const auto values : {state->template get<fixture::Density>(), state->template get<fixture::Potential>()}) {
CHECK(std::ranges::any_of(values, [](double value) { return value != 0.0; }));
}
auto evaluator = backend::bind_stellar_model(star, finite_elements, *state);
const auto evaluated = evaluator.evaluate(*state);
REQUIRE(evaluated);
const auto residual = (*evaluated)->residual();
CHECK(std::ranges::all_of(residual, [](double value) { return std::isfinite(value); }));
CHECK(std::abs(residual[state->layout().template offset<Kind::residual, fixture::Constant>()]) < 5.0e-4);
CHECK(std::abs(residual[state->layout().template offset<Kind::residual, fixture::Omega>()]) < 1.0e-10);
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
const auto offset = state->layout().template offset<Kind::residual, fixture::Density>();
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
CHECK(residual[offset + dof] == 0.0);
}
const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::InfinitySurfaceBoundary>(resources->potential);
REQUIRE(infinity_rows.size() > 0);
const auto potential_offset = state->layout().template offset<Kind::residual, fixture::Potential>();
for (const int dof : infinity_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Potential>()[dof] == 0.0);
CHECK(residual[potential_offset + dof] == 0.0);
}
}
TEST_CASE("Explicit radial fitting preserves thermodynamics and gives the stretched density consistent gravity", "[unit][solver][seed][fitting]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}});
REQUIRE(radial);
const seed::RadialSeed original = *radial;
for (const double stretch : {0.5, 1.0, 2.0}) {
const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{stretch * radial->scales.radius.value()});
REQUIRE(fitted);
CHECK(fitted->mass.value() == Catch::Approx(radial->scales.mass.value() * std::pow(stretch, 3)));
CHECK(fitted->moment_of_inertia == Catch::Approx(radial->moment_of_inertia * std::pow(stretch, 5)));
CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(0.05));
CHECK(fitted->source.scales.radial_scale == original.scales.radial_scale);
CHECK(fitted->source.scales.radius == original.scales.radius);
CHECK(fitted->source.scales.mass == original.scales.mass);
CHECK(fitted->source.scales.polytropic_constant == original.scales.polytropic_constant);
for (const double fraction : {0.0, 0.1, 0.4, 0.9, 1.0, 2.0}) {
const auto source = seed::evaluate_radial_seed(*radial, dimensions::LengthValue{fraction * radial->scales.radius.value()});
const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{fraction * fitted->radius.value()});
REQUIRE(source);
REQUIRE(sample);
CHECK(sample->density.value() == Catch::Approx(source->density.value()).margin(1.0e-14));
CHECK(sample->enthalpy.value() == Catch::Approx(source->enthalpy.value()).margin(1.0e-14));
CHECK(sample->pressure.value() == Catch::Approx(source->pressure.value()).margin(1.0e-14));
CHECK(sample->enclosed_mass.value() == Catch::Approx(source->enclosed_mass.value() * std::pow(stretch, 3)).margin(1.0e-14));
CHECK(sample->potential.value() == Catch::Approx(source->potential.value() * stretch * stretch));
CHECK(sample->gravity.value() == Catch::Approx(source->gravity.value() * stretch).margin(1.0e-14));
if (fraction > 0.0) {
const double radius = fraction * fitted->radius.value();
CHECK(sample->gravity.value() == Catch::Approx(radial->scales.gravitational_constant.value() * sample->enclosed_mass.value() / (radius * radius)).epsilon(1.0e-12));
}
}
// Independent spherical quadrature checks the mass and inertia assigned to the stretched density.
constexpr int intervals = 2000;
const double width = fitted->radius.value() / intervals;
double mass = 0.0;
double inertia = 0.0;
for (int index = 0; index <= intervals; ++index) {
const double radius = width * index;
const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius});
REQUIRE(sample);
const double weight = (index == 0 || index == intervals) ? 1.0 : (index % 2 == 0 ? 2.0 : 4.0);
mass += weight * sample->density.value() * radius * radius;
inertia += weight * sample->density.value() * std::pow(radius, 4);
}
mass *= width * 4.0 * std::numbers::pi / 3.0;
inertia *= width * 8.0 * std::numbers::pi / 9.0;
CHECK(mass == Catch::Approx(fitted->mass.value()).epsilon(2.0e-8));
CHECK(inertia == Catch::Approx(fitted->moment_of_inertia).epsilon(2.0e-8));
}
REQUIRE(radial->solution.samples.size() == original.solution.samples.size());
for (std::size_t index = 0; index < original.solution.samples.size(); ++index) {
CHECK(radial->solution.samples[index].xi == original.solution.samples[index].xi);
CHECK(radial->solution.samples[index].theta == original.solution.samples[index].theta);
CHECK(radial->solution.samples[index].derivative == original.solution.samples[index].derivative);
}
CHECK(radial->scales.radius == original.scales.radius);
CHECK(radial->scales.central_density == original.scales.central_density);
CHECK(radial->scales.central_enthalpy == original.scales.central_enthalpy);
CHECK(radial->angular_velocity == original.angular_velocity);
CHECK(star.equation_of_state().polytropic_constant() == 2.0 / std::numbers::pi);
CHECK(star.specification<serif::model::FixedTotalMass>().target().value() == 1.0);
CHECK(star.specification<serif::model::FixedAngularMomentum>().target().value() == 0.05);
// These consistent units keep the final physical quantities finite even though the standalone stretch^5 is not.
for (const double stretch : {1.0e-70, 1.0e70}) {
const double source_radius = stretch > 1.0 ? 1.0e-10 : 1.0e10;
const double source_mass = stretch > 1.0 ? 1.0e-270 : 1.0e270;
const double gravity_constant = stretch > 1.0 ? 1.0e260 : 1.0e-260;
const double constant = 2.0 * gravity_constant * source_radius * source_radius / std::numbers::pi;
auto extreme = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, constant}, dimensions::MassValue{source_mass},
{.gravitational_constant = dimensions::QuantityValue<dimensions::GravitationalConstant>{gravity_constant},
.central_density = dimensions::DensityValue{std::numbers::pi * source_mass / (4.0 * source_radius * source_radius * source_radius)}});
REQUIRE(extreme);
extreme->angular_velocity = 1.0 / extreme->moment_of_inertia;
const auto fitted = seed::fit_radial_seed(*extreme, dimensions::LengthValue{stretch * extreme->scales.radius.value()});
REQUIRE(fitted);
CHECK(fitted->mass.value() == Catch::Approx(extreme->scales.mass.value() * stretch * stretch * stretch).epsilon(1.0e-12));
CHECK(fitted->moment_of_inertia == Catch::Approx(extreme->moment_of_inertia * stretch * stretch * stretch * stretch * stretch).epsilon(1.0e-12));
CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(1.0).epsilon(1.0e-12));
const auto surface = seed::evaluate_radial_seed(*fitted, fitted->radius);
const auto interior = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{0.5 * fitted->radius.value()});
REQUIRE(surface);
REQUIRE(interior);
CHECK(std::isfinite(surface->gravity.value()));
CHECK(std::isfinite(interior->potential.value()));
}
}
TEST_CASE("Radial fitting rejects invalid profiles and unrepresentable physical scales", "[unit][solver][seed][fitting]") {
const auto radial = seed::make_polytropic_seed(fixture::make_model());
REQUIRE(radial);
for (const double radius : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN(),
std::numeric_limits<double>::max(), std::numeric_limits<double>::denorm_min()}) {
CHECK_FALSE(seed::fit_radial_seed(*radial, dimensions::LengthValue{radius}));
}
auto invalid = *radial;
invalid.solution.samples[2].xi = invalid.solution.samples[1].xi;
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.solution.samples[2].theta = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.moment_of_inertia = 0.0;
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.angular_velocity = std::numeric_limits<double>::infinity();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.center[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{0.5});
REQUIRE(fitted);
for (const double radius : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius}));
}
CHECK(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{std::numeric_limits<double>::max()}));
for (const double invalid_scale : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
auto malformed = *fitted;
malformed.mass = dimensions::MassValue{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
malformed = *fitted;
malformed.source.scales.radius = dimensions::LengthValue{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
malformed = *fitted;
malformed.source.scales.gravitational_constant = dimensions::QuantityValue<dimensions::GravitationalConstant>{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
}
}
TEST_CASE("Reference radius fitting is explicit and rejects nonspherical surfaces", "[integration][solver][seed][fitting]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(2.0 * radial->scales.radius.value());
const auto discretization = fixture::make_discretization(resources, 0);
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(star, discretization, *radial));
const auto fitted = seed::backend::mfem::fit_radial_seed(discretization, *radial);
if (!fitted) {
INFO(fitted.error());
}
REQUIRE(fitted);
CHECK(fitted->radius.value() == Catch::Approx(2.0 * radial->scales.radius.value()).epsilon(5.0e-4));
const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *fitted);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); }));
CHECK(state->template get<fixture::Constant>()[0] == Catch::Approx(-radial->scales.gravitational_constant.value() * fitted->mass.value() / fitted->radius.value()));
CHECK(state->template get<fixture::Omega>()[0] == fitted->angular_velocity);
CHECK(std::ranges::all_of(state->template get<fixture::Shape>(), [](double value) { return value == 0.0; }));
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
REQUIRE(surface_rows.size() > 0);
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
}
for (const double tolerance : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::backend::mfem::fit_radial_seed(discretization, *radial, tolerance));
}
const auto ellipsoid = fixture::make_resources();
/** @brief Both physical and logical meshes describe the same intentionally nonspherical reference. */
const auto stretch_axis = [](const ::mfem::Vector& point, ::mfem::Vector& mapped) {
mapped = point;
mapped[0] *= 1.2;
};
ellipsoid->mesh.mesh->Transform(stretch_axis);
ellipsoid->mesh.reference_mesh->Transform(stretch_axis);
const auto nonspherical = seed::backend::mfem::fit_radial_seed(fixture::make_discretization(ellipsoid, 0), *radial);
REQUIRE_FALSE(nonspherical);
CHECK(nonspherical.error().find("spherical") != std::string::npos);
CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-7));
}