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

363 lines
23 KiB
C++

#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));
}