#include #include #include #include #include #include #include #include #include #include #include #include #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/backend/mfem/model/discretization.hpp" #include "serif/backend/mfem/model/graph.hpp" #include "serif/tests/equations/gravity_fixture.hpp" #include "serif/tests/deformation/mesh.hpp" 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 surface = serif::geometry::surface; namespace deformation = serif::geometry::deformation; namespace blocks = serif::discretization::blocks; namespace test = 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 Displacement = blocks::displacement::vector; using Kind = blocks::RepresentedValueKind; using Vector = mfem::Vector; /** @brief Keep the physical composition explicit while exercising the public binding factory. */ template [[nodiscard]] auto make_physical_model() { const Polytrope eos{2.0, 0.3}; const auto rotation = []() { if constexpr (SolveOmega) { return std::tuple{equations::AngularMomentumConstraint{0.14}}; } else { return std::tuple{}; } }(); const auto interior = std::apply([](auto... equation) { return equations::EquationRegistry{std::move(equation)...}; }, std::tuple_cat(std::tuple{equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}}, equations::PrimalPoissonBalance{2.3}, equations::MassNormalization{1.1}}, rotation)); const auto definition = model::EquilibriumEquations { .interior_equations = interior, .shape_force = equations::SurfacePotentialBalance{}, .surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}} }; const auto invariants = [&]() { if constexpr (SolveOmega) { return std::tuple{model::FixedAngularMomentum{serif::dimensions::AngularMomentumValue{0.14}}}; } else { return std::tuple{}; } }(); return std::apply([&](const auto&... invariant) { return model::make_stellar_model(eos, definition, model::FixedTotalMass{serif::dimensions::MassValue{1.1}}, invariant...); }, invariants); } /** @brief Keep the fixed-mass model's surface and exterior resources under one lifetime owner. */ struct Fixture { stroid::StroidMesh generated{serif::tests::deformation::make_mesh()}; test::gravity::GravityFixture fields{mfem::Mesh(*generated.mesh)}; 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}; Fixture() { REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size()); compactification = *generated.exterior_coordinate->values; } }; /** @brief Borrow bindings and the existing radial mesh extension from their retained owner. */ [[nodiscard]] auto discretize(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 = {} }; auto discretization = backend::with_deformation( backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference); return std::make_shared(std::move(discretization)); } /** @brief A persistent coefficient view follows the independent state ordering declared by the plan. */ template [[nodiscard]] Vector field(Vector& state, const Layout& layout) { return Vector(state.GetData() + layout.template offset(), static_cast(layout.template size())); } /** @brief Compare every residual row so scalar constraints cannot disappear behind a volume norm. */ void check_close(const Vector& actual, const Vector& expected, const double tolerance) { REQUIRE(actual.Size() == expected.Size()); for (int index = 0; index < actual.Size(); ++index) { CHECK(actual[index] == Catch::Approx(expected[index]).margin(tolerance)); } } /** @brief Copy an immutable evaluated residual into independent storage for finite differences. */ [[nodiscard]] Vector residual(const backend::PhysicalSystem& system) { Vector result(static_cast(system.residual().size())); std::copy(system.residual().begin(), system.residual().end(), result.GetData()); return result; } /** @brief A rejected candidate must expose neither stale results nor a stale work report. */ template void check_unavailable(const Evaluator& evaluator) { CHECK_FALSE(evaluator.cache().ready()); CHECK_FALSE(evaluator.cache().template contains()); CHECK_THROWS_AS(evaluator.cache().template get(), std::logic_error); CHECK_THROWS_AS(evaluator.cache().template share(), std::logic_error); CHECK(evaluator.cache().report().computed_count() == 0); CHECK(evaluator.cache().report().reused_count() == 0); } /** @brief Check physical assembly and candidate publication through the public stellar-model interface. */ template void check_equilibrium_graph() { std::weak_ptr resources; std::shared_ptr retained; Vector retained_direction, retained_action; { auto fixture = std::make_shared(); resources = fixture; const auto discretization = discretize(fixture); const auto physical = std::make_shared())>(make_physical_model()); using Description = std::remove_cvref_tequations())>; using Prescribed = std::conditional_t, dependencies::KeyList>; using Plan = model::EquilibriumPlan; const std::array initial_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, initial_shape); REQUIRE(layout.template total_size() == layout.template total_size()); STATIC_CHECK_FALSE(dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>); const int size = static_cast(layout.template total_size()); Vector state(size); Vector density = field(state, layout); Vector potential = field(state, layout); Vector constant = field(state, layout); Vector omega = [&]() { if constexpr (SolveOmega) { return field(state, layout); } else { return test::constant(1, 0.31); } }(); Vector shape = field(state, layout); REQUIRE(static_cast(shape.Size()) == initial_shape.size()); std::copy(initial_shape.begin(), initial_shape.end(), shape.GetData()); const auto geometry = [&]() { return std::span{shape.GetData(), static_cast(shape.Size())}; }; // Copy from lvalues: moving an MFEM vector would detach these views from the packed state. const Vector initial_density = fixture->fields.density(); const Vector initial_potential = fixture->fields.potential(); density = initial_density; potential = initial_potential; constant = 0.72; omega = 0.31; const auto make_coefficients = [&](const auto& density_binding, const auto& omega_binding) { return execution::make_term_coefficients( execution::bind(density_binding, density), execution::bind(fixture->fields.potential_binding, potential), execution::bind(fixture->fields.constant_binding, constant), execution::bind(omega_binding, omega)); }; const auto coefficients = make_coefficients(fixture->fields.density_binding, fixture->fields.angular_velocity_binding); const auto bound = backend::bind_model_definition(physical, discretization, coefficients); REQUIRE(bound->boundary->selected_rows->size() > 0); REQUIRE(bound->boundary->selected_rows->size() < density.Size()); auto evaluator = backend::bind_stellar_model(*physical, *discretization, coefficients, geometry()); check_unavailable(evaluator); const double initial_value = density[0]; density[0] = std::numeric_limits::quiet_NaN(); REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry())); check_unavailable(evaluator); CHECK(evaluator.cache().preparation_count() == 0); density[0] = initial_value; auto evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); const auto initial_system = *evaluation; const Vector original_state(state); const Vector original_residual = residual(*initial_system); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); const Vector frozen_direction = test::deterministic(size, 0.8); Vector frozen_action, frozen_transpose; initial_system->jacobian().action()->Mult(frozen_direction, frozen_action); initial_system->jacobian().action()->MultTranspose(frozen_direction, frozen_transpose); // Changing input representation must not create a new physical candidate or invalidate reusable work. const model::StellarState packed_state(layout, std::vector(state.GetData(), state.GetData() + state.Size())); const auto prescribed = [&]() { if constexpr (SolveOmega) { return dependencies::make_dependency_context(); } else { return execution::make_term_coefficients(execution::bind(fixture->fields.angular_velocity_binding, omega)); } }(); STATIC_CHECK(dependencies::ContainsKey == SolveOmega); evaluation = evaluator.evaluate(packed_state, prescribed); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); // Identical compile-time coordinates can still describe incompatible meshes or equation sizes. for (const bool wrong_state_size : {true, false}) { CAPTURE(SolveOmega, wrong_state_size); auto incompatible_layout = layout; if (wrong_state_size) { ++incompatible_layout.state_sizes[0]; } else { ++incompatible_layout.residual_sizes[0]; } const model::StellarState incompatible_state(incompatible_layout); const auto preparations = evaluator.cache().preparation_count(); CHECK_THROWS_WITH(evaluator.evaluate(incompatible_state, prescribed), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); check_unavailable(evaluator); CHECK(evaluator.cache().preparation_count() == preparations); check_close(residual(*initial_system), original_residual, 0.0); Vector action, transpose; initial_system->jacobian().action()->Mult(frozen_direction, action); initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose); check_close(action, frozen_action, 0.0); check_close(transpose, frozen_transpose, 0.0); evaluation = evaluator.evaluate(packed_state, prescribed); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); } const std::array fields{&density, &potential, &constant, &omega}; for (std::size_t field_index = 0; field_index < fields.size(); ++field_index) { Vector& values = *fields[field_index]; const double original_value = values[0]; for (double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { CAPTURE(SolveOmega, field_index, invalid); const auto preparations = evaluator.cache().preparation_count(); values[0] = invalid; const auto rejected = evaluator.evaluate(coefficients, geometry()); REQUIRE_FALSE(rejected); REQUIRE(std::holds_alternative(rejected.error().error)); CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::NON_FINITE); check_unavailable(evaluator); CHECK(evaluator.cache().preparation_count() == preparations); check_close(residual(*initial_system), original_residual, 0.0); Vector action, transpose; initial_system->jacobian().action()->Mult(frozen_direction, action); initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose); check_close(action, frozen_action, 0.0); check_close(transpose, frozen_transpose, 0.0); values[0] = original_value; evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); } } // Geometry failures originate in mapping, after the input context has been built. for (Vector* values : {&shape, static_cast(&fixture->compactification)}) { INFO((values == &shape ? "Invalid surface shape" : "Invalid compactification")); const Vector original_values(*values); *values = std::numeric_limits::quiet_NaN(); const auto preparations = evaluator.cache().preparation_count(); REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry())); check_unavailable(evaluator); CHECK(evaluator.cache().preparation_count() == preparations); check_close(residual(*initial_system), original_residual, 0.0); *values = original_values; evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(*evaluation == initial_system); CHECK(evaluator.cache().report().computed_count() == 0); } // Invalid sizes remain caller errors, but they must still clear preparation availability. Vector wrong_size(density.Size() + 1); wrong_size = 1.0; density.Swap(wrong_size); CHECK_THROWS_AS(evaluator.evaluate(coefficients, geometry()), std::invalid_argument); check_unavailable(evaluator); check_close(residual(*initial_system), original_residual, 0.0); density.Swap(wrong_size); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(evaluator.cache().report().computed_count() == 0); // Identical meshes and coefficient counts do not give an external binding the owner's lifetime. const serif::discretization::backend::mfem::TermBinding other_density(fixture->fields.density_space); const serif::discretization::backend::mfem::TermBinding other_omega(fixture->fields.mesh); const auto foreign_density = make_coefficients(other_density, fixture->fields.angular_velocity_binding); const auto foreign_omega = make_coefficients(fixture->fields.density_binding, other_omega); for (const auto& foreign : {foreign_density, foreign_omega}) { CHECK_THROWS_WITH(backend::bind_stellar_model(*physical, *discretization, foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR")); CHECK_THROWS_WITH(evaluator.evaluate(foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR")); check_unavailable(evaluator); check_close(residual(*initial_system), original_residual, 0.0); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); CHECK(evaluator.cache().report().computed_count() == 0); } const int density_row = static_cast(layout.template offset()); const double boundary_density = serif::eos::evaluate( physical->equation_of_state(), serif::dimensions::PressureValue{0.025}).value(); for (const int row : bound->boundary->selected_rows->reduced_dofs()) { CHECK(initial_system->residual()[density_row + row] == Catch::Approx(density[row] - boundary_density).margin(2.0e-14)); } const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< serif::discretization::domain::InfinitySurfaceBoundary>(fixture->fields.potential_binding); REQUIRE(infinity.size() > 0); const int potential_row = static_cast(layout.template offset()); for (const int row : infinity.reduced_dofs()) { CHECK(initial_system->residual()[potential_row + row] == Catch::Approx(potential[row]).margin(2.0e-14)); } const Vector dual = test::deterministic(size, 1.7); Vector transpose(size); initial_system->jacobian().action()->MultTranspose(dual, transpose); for (int variation = 0; variation < (SolveOmega ? 4 : 3); ++variation) { CAPTURE(variation); Vector direction = test::deterministic(size, 0.4); if (variation > 0) { direction = 0.0; std::size_t column = variation == 1 ? layout.template offset() : layout.template offset(); if constexpr (SolveOmega) { if (variation == 3) { column = layout.template offset(); } } direction[static_cast(column)] = 0.13; } Vector action(size); initial_system->jacobian().action()->Mult(direction, action); CHECK((action * dual) == Catch::Approx(direction * transpose).margin(2.0e-12)); // Tiny geometry differences lose digits in the exterior residual. Cancel the leading // central-difference truncation term while keeping the original absolute error bound. const auto centered_difference = [&](const double step) { state = original_state; state.Add(step, direction); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); Vector difference = residual(**evaluation); state = original_state; state.Add(-step, direction); evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); difference -= residual(**evaluation); difference /= 2.0 * step; return difference; }; const double step = 1.0e-3; const Vector coarse = centered_difference(step); Vector difference = centered_difference(0.5 * step); difference *= 4.0; difference -= coarse; difference /= 3.0; check_close(action, difference, 2.0e-9); state = original_state; evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); check_close(residual(**evaluation), original_residual, 2.0e-14); Vector frozen_action(size); initial_system->jacobian().action()->Mult(direction, frozen_action); check_close(frozen_action, action, 0.0); } using PoissonResult = execution::volume_keys::Scoped::Linearization; using MomentumResult = execution::volume_keys::Scoped::Linearization; const auto poisson = evaluator.cache().template share(); const auto momentum = evaluator.cache().template share(); omega[0] += 0.02; if constexpr (SolveOmega) { evaluation = evaluator.evaluate(coefficients, geometry()); } else { evaluation = evaluator.evaluate(packed_state, prescribed); } REQUIRE(evaluation); CHECK((*evaluation)->jacobian().action()->Width() == size); if constexpr (!SolveOmega) { const auto external_evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(external_evaluation); CHECK(*external_evaluation == *evaluation); CHECK(evaluator.cache().report().computed_count() == 0); } CHECK(evaluator.cache().template share() == poisson); CHECK(evaluator.cache().template share() != momentum); CHECK(*evaluation != initial_system); check_close(residual(*initial_system), original_residual, 0.0); const auto before_rejection = *evaluation; const Vector before_rejection_residual = residual(*before_rejection); Vector before_rejection_action, before_rejection_transpose; before_rejection->jacobian().action()->Mult(frozen_direction, before_rejection_action); before_rejection->jacobian().action()->MultTranspose(frozen_direction, before_rejection_transpose); density = -1.0; const auto rejected = evaluator.evaluate(coefficients, geometry()); REQUIRE_FALSE(rejected); REQUIRE(std::holds_alternative(rejected.error().error)); CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION); check_unavailable(evaluator); check_close(residual(*before_rejection), before_rejection_residual, 0.0); Vector rejected_action, rejected_transpose; before_rejection->jacobian().action()->Mult(frozen_direction, rejected_action); before_rejection->jacobian().action()->MultTranspose(frozen_direction, rejected_transpose); check_close(rejected_action, before_rejection_action, 0.0); check_close(rejected_transpose, before_rejection_transpose, 0.0); check_close(residual(*initial_system), original_residual, 0.0); state = original_state; omega = 0.31; evaluation = evaluator.evaluate(coefficients, geometry()); REQUIRE(evaluation); check_close(residual(**evaluation), original_residual, 2.0e-14); retained = *evaluation; retained_direction = test::deterministic(size, 0.8); retained->jacobian().action()->Mult(retained_direction, retained_action); } CHECK_FALSE(resources.expired()); Vector after_owner_release; retained->jacobian().action()->Mult(retained_direction, after_owner_release); check_close(after_owner_release, retained_action, 0.0); retained.reset(); CHECK(resources.expired()); } } // namespace TEST_CASE("Equilibrium graph combines the density balance, primal gravity and moving isobar rows", "[unit][model][equilibrium_graph]") { check_equilibrium_graph(); } TEST_CASE("Prescribed rotation remains a physical input without a Newton column or angular momentum equation", "[unit][model][equilibrium_graph]") { check_equilibrium_graph(); }