#include #include #include #include #include #include #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 [[nodiscard]] mfem::Vector residual_copy(const System& system) { mfem::Vector result(static_cast(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 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 [[nodiscard]] model::StellarState perturbed(const model::StellarState& state, const normalization::DiagonalNormalization& coordinates, const mfem::Vector& direction, double step) { mfem::Vector physical(direction.Size()); coordinates.denormalize_state({direction.GetData(), static_cast(direction.Size())}, {physical.GetData(), static_cast(physical.Size())}); model::StellarState 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 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(layout.template offset()); const int column_offset = static_cast(layout.template offset()); 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{.surface = {.maximum_degree = 2}}, serif::discretization::reconstruction::SurfacePower{.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()[0] = 0.08; REQUIRE(state.get().size() == 9); state.get()[0] = 0.01; state.get()[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(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 steps = column < layout.state_sizes.size() ? std::vector{2e-6} : std::vector{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()); const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.binding()); check_boundary_transpose(*frozen, layout, density_surface); check_boundary_transpose(*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{.surface = {.maximum_degree = 2}}, serif::discretization::reconstruction::SurfacePower{.density_exponent = 4.0 / 3.0}); auto state = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial); REQUIRE(state); state->get()[0] = 0.08; state->get()[6] = -0.015; std::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 = [&]() { const int offset = static_cast(state->layout().template offset()); 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()(); check_functional.template operator()(); }