#include #include #include #include #include #include #include #include #include #include #include #include "mfem/linalg/solvers.hpp" #include "mfem/general/communication.hpp" #include "serif/equations/angular_momentum.hpp" #include "serif/equations/barotropic_density.hpp" #include "serif/equations/primal_gravity.hpp" #include "serif/equations/surface_potential.hpp" #include "serif/equations/mass_normalization.hpp" #include "serif/backend/mfem/model/equilibrium.hpp" #include "serif/model/eos_reference.hpp" #include "serif/solver/preconditioner/default_preconditioners.hpp" #include "serif/tests/deformation/mesh.hpp" #include "serif/tests/equations/gravity_fixture.hpp" // MeanField's prepared_stellar_equilibrium.cpp checks the complete moving root // against its children and centered differences. Keep both checks: child parity // verifies composition, while finite differences verify the assembled derivative. namespace { namespace model = serif::model; namespace backend = model::backend::mfem; namespace execution = serif::operators::backend::mfem; namespace dependencies = serif::utils::dependencies; namespace equations = serif::equations; namespace deformation = serif::geometry::deformation; namespace surface = serif::geometry::surface; namespace blocks = serif::discretization::blocks; namespace tests = serif::tests::equations; using Polytrope = serif::eos::models::Polytrope; 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 ShapeRow = blocks::surface_deformation::shape_equilibrium; using Displacement = blocks::displacement::vector; using Kind = blocks::RepresentedValueKind; using Vector = mfem::Vector; using InteriorRows = dependencies::KeyList; /** @brief Own paired stellar/vacuum meshes and all resources borrowed by the public model binding. */ struct Fixture { stroid::StroidMesh generated{serif::tests::deformation::make_mesh(1)}; tests::gravity::GravityFixture fields; mfem::FiniteElementSpace reference_displacement{&fields.mesh, &fields.enthalpy_collection, 3, mfem::Ordering::byNODES}; mfem::FiniteElementSpace compactification_space{&fields.mesh, generated.exterior_coordinate->space->FEColl()}; mfem::GridFunction compactification{&compactification_space}; /** @brief Preserve the generator's exterior coordinate on the identically numbered mesh copy. */ explicit Fixture(const mfem::Ordering::Type ordering) : fields(mfem::Mesh(*generated.mesh), ordering) { REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size()); compactification = *generated.exterior_coordinate->values; } }; /** @brief Exercise all stellar equations at a valid, deliberately unconverged rotating state. */ [[nodiscard]] auto physical_model(const double index = 1.5) { const auto definition = [](const auto& physics) { const auto eos = model::borrow_eos(physics.equation_of_state()); const auto& rotation = physics.template specification(); const auto& axis = rotation.axis(); const auto& center = rotation.center(); return model::EquilibriumEquations{ .interior_equations = equations::EquationRegistry{ equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}, axis, center}, equations::PrimalPoissonBalance{2.3}, equations::MassNormalization{physics.template specification().target().value()}, equations::AngularMomentumConstraint{rotation.target().value(), axis, center} }, .shape_force = equations::SurfacePotentialBalance{axis, center}, .surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}} }; }; return model::make_stellar_model(Polytrope{index, 0.25}, definition, model::FixedTotalMass{serif::dimensions::MassValue{1.1}}, model::FixedAngularMomentum{{.angular_momentum = serif::dimensions::AngularMomentumValue{0.14}, .axis = {0.2, -0.3, 1.0}, .center = {0.03, -0.02, 0.01}}}); } /** @brief Use the production radial lift and vacuum extension with nine independent surface harmonics. */ [[nodiscard]] auto discretize(const std::shared_ptr& fixture) { const auto& fields = fixture->fields; const auto bindings = dependencies::make_dependency_context( dependencies::borrow(fields.density_binding), dependencies::borrow(fields.potential_binding), dependencies::borrow(fields.constant_binding), dependencies::borrow(fields.angular_velocity_binding), dependencies::borrow(fields.displacement_binding)); const auto reference = deformation::backend::mfem::deformation_discretization( fields.enthalpy_space, fixture->reference_displacement, *fixture->generated.reference_mesh); const deformation::DomainDeformation geometry{ .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {} }; return backend::with_deformation(backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference); } /** @brief Populate all physical fields, scalar constraints and surface modes in the moving-star fixture. */ template [[nodiscard]] model::StellarState initial_state(const Layout& layout, tests::gravity::GravityFixture& fields, const ShapeCoefficients& shape) { model::StellarState state(layout); const auto initialize = [&](const Vector& values) { const auto destination = state.template get(); REQUIRE(destination.size() == static_cast(values.Size())); std::copy_n(values.GetData(), values.Size(), destination.begin()); }; initialize.template operator()(fields.density()); initialize.template operator()(fields.potential()); state.template get()[0] = 0.72; state.template get()[0] = 0.31; std::ranges::copy(shape, state.template get().begin()); return state; } /** @brief Make an independent physical candidate without sharing any mutable field storage. */ template [[nodiscard]] model::StellarState perturbed_state(const model::StellarState& original, const double step, const Vector& direction) { REQUIRE(original.values().size() == static_cast(direction.Size())); model::StellarState result(original); for (int index = 0; index < direction.Size(); ++index) { result.values()[static_cast(index)] += step * direction[index]; } return result; } /** @brief Copy a frozen residual before evaluating another candidate. */ template [[nodiscard]] Vector residual(const System& system) { Vector result(static_cast(system.residual().size())); std::copy(system.residual().begin(), system.residual().end(), result.GetData()); return result; } /** @brief Check each equation separately so large gravity rows cannot hide missing scalar or shape rows. */ template void check_rows(const Vector& actual, const 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) { const double difference = actual[offset] - expected[offset]; error_squared += difference * difference; reference_squared += expected[offset] * expected[offset]; ++offset; } CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared)); } } /** @brief Compare cached scalar functionals with the independently retained moving volume actions. */ template void check_scalar_row(const Evaluator& evaluator, const backend::PhysicalSystem& physical) { const auto& layout = evaluator.layout(); const auto& cache = evaluator.cache(); const auto volume = cache.template share::Linearization>(); const auto& shape = cache.template get(); const auto reference = backend::volume_row_action(volume, layout, backend::ColumnContribution{ static_cast(layout.template offset()), shape.tangent}); const auto& cached = cache.template get>(); const Vector direction = tests::deterministic(reference.action()->Width(), 0.47); const Vector dual = tests::constant(1, 0.37); Vector expected(1), actual(1), expected_transpose, actual_transpose(direction.Size()); reference.action()->Mult(direction, expected); cached.action()->Mult(direction, actual); CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12)); reference.action()->MultTranspose(dual, expected_transpose); cached.action()->MultTranspose(dual, actual_transpose); CHECK(tests::relative_error(actual_transpose, expected_transpose) < 2.0e-12); const auto check_column = [&]() { const int offset = static_cast(layout.template offset()); const int size = static_cast(layout.template size()); const auto selected = physical.block(static_cast(layout.template offset()), 1, offset, size); Vector input(size), selected_transpose; std::copy_n(direction.GetData() + offset, size, input.GetData()); Vector complete(direction.Size()); complete = 0.0; std::copy_n(input.GetData(), size, complete.GetData() + offset); cached.action()->Mult(complete, expected); selected.action()->Mult(input, actual); CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12)); selected.action()->MultTranspose(dual, selected_transpose); Vector expected_column(expected_transpose.GetData() + offset, size); CHECK(tests::relative_error(selected_transpose, expected_column) < 2.0e-12); }; check_column.template operator()(); check_column.template operator()(); check_column.template operator()(); } /** @brief Place the volume and surface children, then independently enforce both physical traces. */ template void check_composition(const Evaluator& evaluator, const model::StellarState& state, const backend::PhysicalSystem& physical, const serif::discretization::backend::mfem::DofSelection& boundary) { const auto& layout = evaluator.layout(); const auto& cache = evaluator.cache(); Vector expected(static_cast(layout.template total_size())); expected = 0.0; [&](dependencies::KeyList) { const auto place = [&]() { const auto& child = cache.template get::Linearization>(); REQUIRE(static_cast(child.residual.Size()) == layout.template size()); std::copy_n(child.residual.GetData(), child.residual.Size(), expected.GetData() + layout.template offset()); }; (place.template operator()(), ...); }(InteriorRows{}); const auto& surface_child = cache.template get(); REQUIRE(surface_child.residual.size() == 1); for (const auto& contribution : surface_child.residual) { std::copy(contribution.values.begin(), contribution.values.end(), expected.GetData() + contribution.row_offset); } const int shape_row = static_cast(layout.template offset()); const int shape_size = static_cast(layout.template size()); Vector shape_residual(expected.GetData() + shape_row, shape_size); REQUIRE(shape_residual.Norml2() > 1.0e-8); // Compare each retained surface child coupling with the corresponding assembled block. for (const auto& contribution : surface_child.jacobian) { const auto selected = physical.block(contribution.row_offset, contribution.block.action()->Height(), contribution.column_offset, contribution.block.action()->Width()); const Vector direction = tests::deterministic(contribution.block.action()->Width(), 0.47); const Vector dual = tests::deterministic(contribution.block.action()->Height(), 0.71); Vector actual(contribution.block.action()->Height()), reference(contribution.block.action()->Height()); Vector actual_transpose(contribution.block.action()->Width()), reference_transpose(contribution.block.action()->Width()); selected.action()->Mult(direction, actual); contribution.block.action()->Mult(direction, reference); CHECK(tests::relative_error(actual, reference) < 2.0e-13); selected.action()->MultTranspose(dual, actual_transpose); contribution.block.action()->MultTranspose(dual, reference_transpose); CHECK(tests::relative_error(actual_transpose, reference_transpose) < 2.0e-13); } const auto density = state.template get(); REQUIRE(boundary.size() > 0); REQUIRE(boundary.size() < static_cast(density.size())); const double surface_density = serif::eos::evaluate( evaluator.model().equation_of_state(), serif::dimensions::PressureValue{0.025}).value(); const int density_row = static_cast(layout.template offset()); for (const int row : boundary.reduced_dofs()) { expected[density_row + row] = density[row] - surface_density; } const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::InfinitySurfaceBoundary>( evaluator.binding().discretization->template binding()); REQUIRE(infinity.size() > 0); const int potential_row = static_cast(layout.template offset()); for (const int row : infinity.reduced_dofs()) { expected[potential_row + row] = state.template get()[row]; } check_rows(residual(physical), expected, layout, 2.0e-13, 2.0e-13); // Each strong trace must reach its own scalar field alone, including in the transpose. const auto check_trace = [&](const auto& selected) { Vector dual(expected.Size()), transpose; dual = 0.0; Vector expected_transpose(physical.jacobian().action()->Width()); expected_transpose = 0.0; const int row_offset = static_cast(layout.template offset()); const int column_offset = static_cast(layout.template offset()); for (const int row : selected.reduced_dofs()) { dual[row_offset + row] = 0.17 + 0.01 * (row % 7); expected_transpose[column_offset + row] = dual[row_offset + row]; } physical.jacobian().action()->MultTranspose(dual, transpose); CHECK(tests::relative_error(transpose, expected_transpose) < 2.0e-13); }; check_trace.template operator()(boundary); check_trace.template operator()(infinity); } /** @brief Compare every physical state column and a mixed perturbation through the public evaluation path. */ template void check_derivatives(Evaluator& evaluator, const model::StellarState& state) { const auto& layout = evaluator.layout(); const auto evaluation = evaluator.evaluate(state); REQUIRE(evaluation); const auto frozen = *evaluation; const Vector direction_seed = tests::deterministic(static_cast(state.values().size()), 0.43); const Vector dual = tests::deterministic(frozen->jacobian().action()->Height(), 1.17); Vector transpose, block_transpose; frozen->jacobian().action()->MultTranspose(dual, transpose); const auto blocks = frozen->block(0, dual.Size(), 0, static_cast(state.values().size())); blocks.action()->MultTranspose(dual, block_transpose); CHECK(tests::relative_error(transpose, block_transpose) < 2.0e-12); // The final iteration moves all physical fields and the surface simultaneously. int offset = 0; for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) { CAPTURE(column); Vector direction(direction_seed); if (column < layout.state_sizes.size()) { direction = 0.0; std::copy_n(direction_seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset); offset += static_cast(layout.state_sizes[column]); } Vector action, block_action; frozen->jacobian().action()->Mult(direction, action); blocks.action()->Mult(direction, block_action); REQUIRE(action.Norml2() > 1.0e-10); check_rows(action, block_action, layout, 2.0e-12, 2.0e-12); CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(2.0e-12).margin(2.0e-12)); constexpr double step = 1.0e-5; const auto plus = evaluator.evaluate(perturbed_state(state, step, direction)); REQUIRE(plus); Vector difference = residual(**plus); const auto minus = evaluator.evaluate(perturbed_state(state, -step, direction)); REQUIRE(minus); difference -= residual(**minus); difference /= 2.0 * step; check_rows(action, difference, layout, 2.0e-6, 2.0e-9); if (column == Layout::template index()) { // Shape must affect each interior equation and the surface potential, not only its own row. int row_offset = 0; for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) { Vector row_action(action.GetData() + row_offset, static_cast(layout.residual_sizes[row])); CHECK(row_action.Norml2() > 1.0e-10); row_offset += row_action.Size(); } } } const auto restored = evaluator.evaluate(state); REQUIRE(restored); check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0); } /** @brief Check the assembled solver coordinates, including strong rows, scalar constraints and native surface modes. */ template void check_normalized_system(Evaluator& evaluator, const model::StellarState& state, const serif::discretization::backend::mfem::DofSelection& boundary) { namespace normalization = serif::solver::normalization; namespace dimensions = serif::dimensions; const auto evaluation = evaluator.evaluate(state); REQUIRE(evaluation); const auto frozen = *evaluation; REQUIRE(frozen->physical() == evaluator.cache().template get()); const auto& layout = evaluator.layout(); const auto& coordinates = frozen->coordinates(); const auto& scales = frozen->reference_scales(); CHECK(scales.mass.value() == Catch::Approx(1.1)); CHECK(scales.radius.value() > 0.0); CHECK(scales.radius.value() != 1.0); REQUIRE(coordinates.state_size() == state.values().size()); REQUIRE(coordinates.residual_size() == frozen->residual().size()); CHECK(coordinates.state_factors().front() != 1.0); Vector expected_residual(static_cast(frozen->residual().size())); coordinates.normalize_residual(frozen->physical()->residual(), {expected_residual.GetData(), frozen->residual().size()}); check_rows(residual(*frozen), expected_residual, layout, 0.0, 0.0); const double radius = scales.radius.value(); const std::size_t density_row = layout.template offset(); for (const int row : boundary.reduced_dofs()) { CHECK(coordinates.residual_factors()[density_row + row] == Catch::Approx(1.0 / scales.density.value()).epsilon(2.0e-13)); } const auto& density_metric = evaluator.cache().template get>(); const auto& selected_rows = boundary.reduced_dofs(); int interior = 0; while (std::find(selected_rows.begin(), selected_rows.end(), interior) != selected_rows.end()) { ++interior; } REQUIRE(interior < static_cast(state.template get().size())); // The density balance residual is a barotropic pressure potential, a specific energy. CHECK(coordinates.residual_factors()[density_row + interior] == Catch::Approx(1.0 / (scales.specific_energy.value() * std::sqrt(density_metric[interior] * radius * radius * radius))).epsilon(2.0e-13)); CHECK(coordinates.state_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::InfinitySurfaceBoundary>( evaluator.binding().discretization->template binding()); for (const int row : infinity.reduced_dofs()) { CHECK(coordinates.residual_factors()[layout.template offset() + row] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); } CHECK(coordinates.residual_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.mass.value()).epsilon(2.0e-13)); CHECK(coordinates.residual_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.angular_momentum.value()).epsilon(2.0e-13)); const auto& surface_metric = evaluator.cache().template get(); for (std::size_t mode = 0; mode < state.template get().size(); ++mode) { CHECK(coordinates.state_factors()[layout.template offset() + mode] == Catch::Approx(std::sqrt(surface_metric[mode]) / (radius * radius)).epsilon(2.0e-13)); CHECK(coordinates.residual_factors()[layout.template offset() + mode] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); } const Vector seed = tests::deterministic(static_cast(state.values().size()), 0.43); const Vector dual = tests::deterministic(static_cast(coordinates.residual_size()), 1.17); Vector transpose; frozen->jacobian().MultTranspose(dual, transpose); for (const int selected : {-1, 0, 1}) { CAPTURE(selected); Vector direction(seed); if (selected >= 0) { direction = 0.0; const std::size_t offset = selected == 0 ? layout.template offset() : layout.template offset(); const std::size_t count = selected == 0 ? state.template get().size() : 1; std::copy_n(seed.GetData() + offset, count, direction.GetData() + offset); } Vector physical_direction(static_cast(state.values().size())), action, physical_action, expected_action(static_cast(coordinates.residual_size())); coordinates.denormalize_state({direction.GetData(), coordinates.state_size()}, {physical_direction.GetData(), coordinates.state_size()}); frozen->jacobian().Mult(direction, action); frozen->physical()->jacobian().action()->Mult(physical_direction, physical_action); coordinates.normalize_residual({physical_action.GetData(), coordinates.residual_size()}, {expected_action.GetData(), coordinates.residual_size()}); check_rows(action, expected_action, layout, 2.0e-13, 2.0e-13); CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(3.0e-12).margin(3.0e-12)); constexpr double step = 2.0e-6; const auto plus = evaluator.evaluate(perturbed_state(state, step, physical_direction)); REQUIRE(plus); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); Vector difference = residual(**plus); const auto minus = evaluator.evaluate(perturbed_state(state, -step, physical_direction)); REQUIRE(minus); difference -= residual(**minus); difference /= 2.0 * step; check_rows(action, difference, layout, 3.0e-6, 3.0e-8); } const auto restored = evaluator.evaluate(state); REQUIRE(restored); check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0); Vector repeated; frozen->jacobian().MultTranspose(dual, repeated); CHECK(tests::relative_error(repeated, transpose) == 0.0); } /** @brief Check the dimensional inverse, solver-coordinate adapter and independently measured linear residual. */ template void check_stellar_linear_solve(const Prepared& prepared, const Layout& layout, const serif::discretization::backend::mfem::DofSelection& boundary) { const auto& system = *prepared.system(); const auto& coordinates = system.coordinates(); const int size = system.jacobian().Width(); REQUIRE(size == system.jacobian().Height()); REQUIRE(coordinates.state_factors().front() != 1.0); REQUIRE(coordinates.residual_factors().front() != 1.0); CHECK(prepared.contract() == serif::solver::preconditioner::ApplicationContract::fixed_linear); const Vector normalized_direction = tests::deterministic(size, 0.37); Vector physical_direction(size), physical_rhs, normalized_rhs(size); coordinates.denormalize_state({normalized_direction.GetData(), coordinates.state_size()}, {physical_direction.GetData(), coordinates.state_size()}); system.physical()->jacobian().action()->Mult(physical_direction, physical_rhs); coordinates.normalize_residual({physical_rhs.GetData(), coordinates.residual_size()}, {normalized_rhs.GetData(), coordinates.residual_size()}); Vector physical_correction(size), expected_correction(size), normalized_correction(size), preconditioned_action; prepared.physical_preconditioner().Mult(physical_rhs, physical_correction); coordinates.normalize_state({physical_correction.GetData(), coordinates.state_size()}, {expected_correction.GetData(), coordinates.state_size()}); prepared.preconditioner().Mult(normalized_rhs, normalized_correction); CHECK(tests::relative_error(normalized_correction, expected_correction) < 3.0e-13); system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action); for (const int row : boundary.reduced_dofs()) { const int index = static_cast(layout.template offset()) + row; CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12)); } // The joint surface/scalar Schur solve must enforce every border equation and mutual coupling. for (const std::size_t index : {layout.template offset(), layout.template offset()}) { CHECK(preconditioned_action[static_cast(index)] == Catch::Approx(physical_rhs[static_cast(index)]).epsilon(2.0e-11).margin(2.0e-12)); } for (std::size_t mode = 0; mode < layout.template size(); ++mode) { const int index = static_cast(layout.template offset() + mode); CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12)); } mfem::FGMRESSolver krylov; krylov.SetRelTol(1.0e-10); krylov.SetAbsTol(1.0e-13); krylov.SetMaxIter(400); krylov.SetKDim(100); krylov.SetPrintLevel(0); krylov.SetPreconditioner(prepared.preconditioner()); krylov.SetOperator(system.jacobian()); for (const bool newton_right_hand_side : {false, true}) { CAPTURE(newton_right_hand_side); Vector solve_rhs(physical_rhs); if (newton_right_hand_side) { solve_rhs = residual(*system.physical()); solve_rhs *= -1.0; } coordinates.normalize_residual({solve_rhs.GetData(), coordinates.residual_size()}, {normalized_rhs.GetData(), coordinates.residual_size()}); Vector solution(size); solution = 0.0; krylov.Mult(normalized_rhs, solution); CAPTURE(krylov.GetNumIterations(), krylov.GetFinalNorm()); CHECK(krylov.GetConverged()); coordinates.denormalize_state({solution.GetData(), coordinates.state_size()}, {physical_correction.GetData(), coordinates.state_size()}); system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action); Vector measured_residual(preconditioned_action); measured_residual -= solve_rhs; Vector normalized_residual(size); coordinates.normalize_residual({measured_residual.GetData(), coordinates.residual_size()}, {normalized_residual.GetData(), coordinates.residual_size()}); CHECK(normalized_residual.Norml2() / normalized_rhs.Norml2() < 2.0e-10); CHECK(measured_residual.Norml2() / solve_rhs.Norml2() < 2.0e-8); check_rows(preconditioned_action, solve_rhs, layout, 2.0e-7, 2.0e-9); } } /** @brief Prepare the production stellar recipe selectively and retain its complete frozen numerical state. */ void check_moving_preconditioner() { namespace normalization = serif::solver::normalization; namespace preconditioner = serif::solver::preconditioner; using InverseKey = preconditioner::keys::Preconditioner; using PreparedKey = model::preconditioning::PreparedSystem; using Status = dependencies::PreparationStatus; #ifdef MFEM_USE_MPI // The AMG backend borrows the application's MPI session; the test is its application owner. if (!mfem::Mpi::IsInitialized()) { mfem::Mpi::Init(); } #endif auto fixture = std::make_shared(mfem::Ordering::byNODES); std::weak_ptr lifetime = fixture; Vector right_hand_side, frozen_correction, frozen_action; auto retained = [&]() { const auto physics = physical_model(); const auto discretization = discretize(fixture); using Description = decltype(model::resolve_equilibrium_equations(physics)); using Plan = model::EquilibriumPlan; const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002}; const auto layout = backend::equilibrium_layout(discretization, shape); const auto state = initial_state(layout, fixture->fields, shape); const auto recipe = preconditioner::make_primal_preconditioner(layout); static_assert(dependencies::ContainsKey); static_assert(dependencies::ContainsKey); static_assert(dependencies::ContainsKey); static_assert(!dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>); const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ .model = physics, .finite_elements = discretization, .normalization = normalization::make_normalization(normalization::NormalizationOptions{ .scheme = normalization::LaneEmdenApproximation{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}}, .coordinates = normalization::Dimensionless{}, .gravitational_constant = serif::dimensions::QuantityValue{2.3 / (4.0 * std::numbers::pi)} }), .preconditioner = recipe }); serif::discretization::quadrature::RuleSet rules; rules.set_fallback_control({.fixed_order = 8}); const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}}; auto evaluator = backend::bind_stellar_model(configured, state, volume_options); const auto physical = evaluator.evaluate(state); REQUIRE(physical); CHECK(evaluator.cache().report().template status() == Status::not_required); CHECK(evaluator.cache().report().template status() == Status::not_required); const auto prepared = evaluator.prepare_linear_system(state); if (!prepared) { UNSCOPED_INFO("Failed computation index: " << prepared.error().computation_index); if (std::holds_alternative(prepared.error().error)) { UNSCOPED_INFO(std::get(prepared.error().error).message); } } REQUIRE(prepared); const auto first = *prepared; CHECK(first->system() == *physical); CHECK(evaluator.cache().report().template status() == Status::reused); CHECK(evaluator.cache().report().template status() == Status::computed); const auto repeated = evaluator.prepare_linear_system(state); REQUIRE(repeated); CHECK(*repeated == first); CHECK(evaluator.cache().report().computed_count() == 0); CHECK(evaluator.cache().report().template status() == Status::reused); const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::StellarSurfaceBoundary>(fixture->fields.density_binding); check_stellar_linear_solve(*first, layout, boundary); right_hand_side = tests::deterministic(static_cast(state.values().size()), 0.82); frozen_correction.SetSize(right_hand_side.Size()); first->preconditioner().Mult(right_hand_side, frozen_correction); first->system()->jacobian().Mult(right_hand_side, frozen_action); const Vector first_residual = residual(*first->system()); auto candidate = state; candidate.template get()[0] += 0.01; candidate.template get()[0] += 0.0002; const auto trial = evaluator.evaluate(candidate); REQUIRE(trial); CHECK(*trial != first->system()); CHECK(evaluator.cache().report().template status() == Status::not_required); CHECK(evaluator.cache().report().template status() == Status::not_required); CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0); const auto updated = evaluator.prepare_linear_system(candidate); REQUIRE(updated); CHECK(*updated != first); CHECK((*updated)->system() == *trial); CHECK(evaluator.cache().report().template status() == Status::computed); const auto rebuilt = evaluator.prepare_linear_system(candidate, dependencies::make_dependency_context(), dependencies::PreparationMode::rebuild); REQUIRE(rebuilt); CHECK(*rebuilt != *updated); CHECK(evaluator.cache().report().template status() == Status::computed); CHECK(evaluator.cache().report().template status() == Status::computed); Vector repeated_correction(right_hand_side.Size()), repeated_action; first->preconditioner().Mult(right_hand_side, repeated_correction); first->system()->jacobian().Mult(right_hand_side, repeated_action); CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); // At n=1.5 the exact density Jacobian is undefined at zero density; reject the EOS trial. auto singular = state; std::ranges::fill(singular.template get(), 0.0); singular.template get()[0] = 0.0; const auto preparations = evaluator.cache().preparation_count(); const auto rejected = evaluator.prepare_linear_system(singular); REQUIRE_FALSE(rejected); REQUIRE(std::holds_alternative(rejected.error().error)); CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION); CHECK_FALSE(evaluator.cache().ready()); CHECK_FALSE(evaluator.cache().template contains()); CHECK(evaluator.cache().report().computed_count() == 0); CHECK(evaluator.cache().report().reused_count() == 0); CHECK(evaluator.cache().preparation_count() == preparations); first->preconditioner().Mult(right_hand_side, repeated_correction); first->system()->jacobian().Mult(right_hand_side, repeated_action); CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0); const auto recovered = evaluator.prepare_linear_system(candidate); REQUIRE(recovered); CHECK(evaluator.cache().ready()); return first; }(); fixture.reset(); CHECK_FALSE(lifetime.expired()); Vector repeated_correction(right_hand_side.Size()), repeated_action; retained->preconditioner().Mult(right_hand_side, repeated_correction); retained->system()->jacobian().Mult(right_hand_side, repeated_action); CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); retained.reset(); CHECK(lifetime.expired()); } /** @brief Evaluate the full moving star in both native displacement orderings and retain its frozen actions. */ template void check_moving_equilibrium(const mfem::Ordering::Type ordering) { auto fixture = std::make_shared(ordering); std::weak_ptr lifetime = fixture; Vector direction, dual, action, transpose, normalized_action, normalized_transpose; std::shared_ptr normalized_retained; const auto exercise = [&]() { auto& fields = fixture->fields; const auto physics = physical_model(); const auto discretization = discretize(fixture); using Description = decltype(model::resolve_equilibrium_equations(physics)); using Plan = model::EquilibriumPlan; const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002}; const auto layout = backend::equilibrium_layout(discretization, shape); REQUIRE(layout.state_sizes.size() == 5); REQUIRE(layout.residual_sizes.size() == 5); REQUIRE(layout.template total_size() == layout.template total_size()); REQUIRE(shape.size() < static_cast(fields.displacement_binding.size())); const auto state = initial_state(layout, fields, shape); const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::StellarSurfaceBoundary>(fields.density_binding); serif::discretization::quadrature::RuleSet rules; rules.set_fallback_control({.fixed_order = 8}); const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}}; auto evaluator = [&]() { if constexpr (Normalize) { namespace normalization = serif::solver::normalization; using Reference = normalization::LaneEmdenApproximation; const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ .model = physics, .finite_elements = discretization, .normalization = normalization::make_normalization(normalization::NormalizationOptions{ .scheme = Reference{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}}, .coordinates = normalization::Dimensionless{}, .gravitational_constant = serif::dimensions::QuantityValue{2.3 / (4.0 * std::numbers::pi)} }) }); return backend::bind_stellar_model(configured, state, volume_options); } else { return backend::bind_stellar_model(physics, discretization, state, volume_options); } }(); const auto initial = evaluator.evaluate(state); REQUIRE(initial); const auto first = *initial; using MassRow = model::computations::ScalarResidualRow; using AngularMomentumRow = model::computations::ScalarResidualRow; const auto mass_row = evaluator.cache().template share(); const auto angular_momentum_row = evaluator.cache().template share(); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); const auto repeated = evaluator.evaluate(state); REQUIRE(repeated); CHECK(*repeated == first); CHECK(evaluator.cache().report().computed_count() == 0); CHECK(evaluator.cache().template share() == mass_row); CHECK(evaluator.cache().template share() == angular_momentum_row); // A successful trial is merely another result; the caller retains the previous candidate. const auto original = state; auto copied = state; CHECK(copied.values().data() != state.values().data()); copied.template get()[0] += 0.01; copied.template get()[0] += 0.0002; auto moved = std::move(copied); CHECK(std::ranges::equal(state.values(), original.values())); const Vector first_residual = residual(*first); const auto discarded = evaluator.evaluate(moved); REQUIRE(discarded); CHECK(evaluator.cache().template share() != mass_row); CHECK(evaluator.cache().template share() != angular_momentum_row); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); CHECK(*discarded != first); CHECK(tests::relative_error(residual(**discarded), first_residual) > 1.0e-10); CHECK(tests::relative_error(residual(*first), first_residual) == 0.0); CHECK(std::ranges::equal(state.values(), original.values())); const auto restored = evaluator.evaluate(state); REQUIRE(restored); if constexpr (Normalize) { check_normalized_system(evaluator, state, boundary); } else { check_composition(evaluator, state, **restored, boundary); check_scalar_row(evaluator, **restored); check_scalar_row(evaluator, **restored); check_derivatives(evaluator, state); } const auto evaluated = evaluator.evaluate(state); REQUIRE(evaluated); const auto retained = *evaluated; const auto physical = [&]() { if constexpr (Normalize) { return retained->physical(); } else { return retained; } }(); const auto preparations = evaluator.cache().preparation_count(); const Vector retained_residual = residual(*retained); direction = tests::deterministic(static_cast(state.values().size()), 0.61); dual = tests::deterministic(static_cast(state.values().size()), 1.31); physical->jacobian().action()->Mult(direction, action); physical->jacobian().action()->MultTranspose(dual, transpose); if constexpr (Normalize) { normalized_retained = retained; normalized_retained->jacobian().Mult(direction, normalized_action); normalized_retained->jacobian().MultTranspose(dual, normalized_transpose); } auto invalid = state; invalid.template get()[0] = std::numeric_limits::quiet_NaN(); const auto rejected = evaluator.evaluate(invalid); REQUIRE_FALSE(rejected); CHECK(std::holds_alternative(rejected.error().error)); CHECK(tests::relative_error(residual(*retained), retained_residual) == 0.0); if constexpr (Normalize) { CHECK_FALSE(evaluator.cache().template contains()); } CHECK_FALSE(evaluator.cache().ready()); CHECK_FALSE(evaluator.cache().template contains()); CHECK(evaluator.cache().report().computed_count() == 0); CHECK(evaluator.cache().report().reused_count() == 0); CHECK(evaluator.cache().preparation_count() == preparations); const auto recovered = evaluator.evaluate(state); REQUIRE(recovered); CHECK(*recovered == retained); CHECK(evaluator.cache().report().computed_count() == 0); return physical; }; auto retained = exercise(); fixture.reset(); CHECK_FALSE(lifetime.expired()); Vector repeated_action, repeated_transpose; retained->jacobian().action()->Mult(direction, repeated_action); retained->jacobian().action()->MultTranspose(dual, repeated_transpose); CHECK(tests::relative_error(repeated_action, action) == 0.0); CHECK(tests::relative_error(repeated_transpose, transpose) == 0.0); retained.reset(); if constexpr (Normalize) { CHECK_FALSE(lifetime.expired()); normalized_retained->jacobian().Mult(direction, repeated_action); normalized_retained->jacobian().MultTranspose(dual, repeated_transpose); CHECK(tests::relative_error(repeated_action, normalized_action) == 0.0); CHECK(tests::relative_error(repeated_transpose, normalized_transpose) == 0.0); normalized_retained.reset(); } CHECK(lifetime.expired()); } } TEST_CASE("Complete moving stellar assembly preserves every coupled equation and boundary row", "[model][moving_equilibrium][integration]") { SECTION("Component-block displacement ordering") { check_moving_equilibrium(mfem::Ordering::byNODES); } SECTION("Interleaved displacement ordering") { check_moving_equilibrium(mfem::Ordering::byVDIM); } } TEST_CASE("Complete moving stellar normalization preserves solver coordinates and frozen ownership", "[model][moving_normalization][integration]") { check_moving_equilibrium(mfem::Ordering::byNODES); } TEST_CASE("Complete moving stellar preconditioning prepares a frozen solver system and solves its physical Jacobian", "[model][moving_preconditioner][integration]") { #ifdef MFEM_USE_MPI check_moving_preconditioner(); #else SKIP("The default stellar recipe uses the one-process BoomerAMG backend, which requires an MPI-enabled MFEM build."); #endif }