#include #include #include #include #include "serif/backend/mfem/discretization/dof_selection.hpp" #include "serif/backend/mfem/model/equilibrium.hpp" #include #include #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{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().size() == static_cast(resources->density.size())); CHECK(state->template get().size() == static_cast(resources->potential.size())); CHECK(state->template get().size() == 9); CHECK(std::ranges::all_of(state->template get(), [](double value) { return value == 0.0; })); CHECK(state->template get()[0] == Catch::Approx(-1.0).epsilon(3.0e-7)); CHECK(state->template get()[0] == 0.0); CHECK(std::ranges::min(state->template get()) >= 0.0); CHECK(std::ranges::max(state->template get()) > 0.7); const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); REQUIRE(surface_rows.size() > 0); for (const int dof : surface_rows.reduced_dofs()) { CHECK(state->template get()[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()); 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().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(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::infinity(), std::numeric_limits::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::infinity(), std::numeric_limits::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; static_assert(dependencies::key_count_v == 5); for (const auto values : {state->template get(), state->template get()}) { 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()]) < 5.0e-4); CHECK(std::abs(residual[state->layout().template offset()]) < 1.0e-10); const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); const auto offset = state->layout().template offset(); for (const int dof : surface_rows.reduced_dofs()) { CHECK(state->template get()[dof] == 0.0); CHECK(residual[offset + dof] == 0.0); } const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->potential); REQUIRE(infinity_rows.size() > 0); const auto potential_offset = state->layout().template offset(); for (const int dof : infinity_rows.reduced_dofs()) { CHECK(state->template get()[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().target().value() == 1.0); CHECK(star.specification().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{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::infinity(), std::numeric_limits::quiet_NaN(), std::numeric_limits::max(), std::numeric_limits::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::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::infinity(); CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); invalid = *radial; invalid.center[0] = std::numeric_limits::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::infinity(), std::numeric_limits::quiet_NaN()}) { CHECK_FALSE(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius})); } CHECK(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{std::numeric_limits::max()})); for (const double invalid_scale : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::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{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()[0] == Catch::Approx(-radial->scales.gravitational_constant.value() * fitted->mass.value() / fitted->radius.value())); CHECK(state->template get()[0] == fitted->angular_velocity); CHECK(std::ranges::all_of(state->template get(), [](double value) { return value == 0.0; })); const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); REQUIRE(surface_rows.size() > 0); for (const int dof : surface_rows.reduced_dofs()) { CHECK(state->template get()[dof] == 0.0); } for (const double tolerance : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::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)); }