// MeanField field_dof_map.cpp and field_mfem.cpp invariants expressed through serial TermBinding. #include #include #include #include #include #include #include "mfem/fem/gridfunc.hpp" #include "serif/tests/equations/test_equations.hpp" #include "serif/backend/mfem/discretization/dof_selection.hpp" #include "serif/discretization/domain/schema/schemas.hpp" #include "serif/tests/deformation/mesh.hpp" namespace { namespace discretization = serif::discretization; namespace backend = discretization::backend::mfem; namespace blocks = discretization::blocks; namespace domain = discretization::domain; using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema; using AlternateSchema = domain::schema::DomainSchema, domain::ids::DomainID, domain::ids::DomainID>, domain::ids::lists::BoundaryIDList<>, domain::relation::lists::RelationList<>>; /** @brief A stellar velocity represented by independent discontinuous scalar components. */ struct DiscontinuousVelocity final : blocks::field { struct vector final : blocks::term { using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; using Support = domain::StellarDomains; }; }; /** @brief Two adjacent cells expose both inactive vacuum DOFs and shared continuous trace DOFs. */ [[nodiscard]] mfem::Mesh split_mesh(const int stellar_attribute = 2, const int vacuum_attribute = 3) { mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0); mesh.GetElement(0)->SetAttribute(stellar_attribute); mesh.GetElement(1)->SetAttribute(vacuum_attribute); mesh.SetAttributes(); return mesh; } /** @brief Independently mark the native element DOFs selected by semantic support. */ template [[nodiscard]] std::vector expected_support(const mfem::FiniteElementSpace& space) { std::vector active(space.GetVSize(), false); mfem::Array dofs; for (int element = 0; element < space.GetNE(); ++element) { if (!DomainSchema::template domain_id_belongs_to(space.GetMesh()->GetAttribute(element))) { continue; } space.GetElementVDofs(element, dofs); for (const int encoded : dofs) { active[encoded < 0 ? -1 - encoded : encoded] = true; } } return active; } /** @brief Compare canonical indexing, projection, and GridFunction transfer with the independent marker. */ template void check_supported_projection(mfem::FiniteElementSpace& space) { const backend::TermBinding binding{space}; const std::vector active = expected_support(space); REQUIRE(binding.full_size() == space.GetVSize()); CHECK(binding.size() == std::ranges::count(active, true)); mfem::Vector full(binding.full_size()); for (int dof = 0; dof < full.Size(); ++dof) { full[dof] = 10.0 + dof; } const mfem::Vector reduced = binding.gather(full); const mfem::Vector projected = binding.scatter(reduced); const mfem::Vector recovered = binding.gather(projected); int next_reduced = 0; for (int dof = 0; dof < full.Size(); ++dof) { CAPTURE(dof); const std::optional index = binding.reduced_dof(dof); CHECK(index.has_value() == active[dof]); if (active[dof]) { REQUIRE(index); CHECK(*index == next_reduced++); CHECK(reduced[*index] == full[dof]); CHECK(recovered[*index] == reduced[*index]); CHECK(projected[dof] == full[dof]); } else { CHECK(projected[dof] == 0.0); } } // MFEM owns the GridFunction conversion; no additional field-adapter lifecycle is needed. mfem::GridFunction grid(&space); grid.SetFromTrueDofs(projected); mfem::Vector true_values; grid.GetTrueDofs(true_values); const mfem::Vector grid_values = binding.gather(true_values); for (int dof = 0; dof < reduced.Size(); ++dof) { CHECK(grid_values[dof] == reduced[dof]); } std::vector storage(full.Size() + 2, -77.0); std::copy_n(full.GetData(), full.Size(), storage.data() + 1); const mfem::Vector full_view(storage.data() + 1, full.Size()); const mfem::Vector from_view = binding.gather(full_view); for (int dof = 0; dof < reduced.Size(); ++dof) { CHECK(from_view[dof] == reduced[dof]); } CHECK(storage.front() == -77.0); CHECK(storage.back() == -77.0); CHECK_THROWS_AS(binding.reduced_dof(-1), std::out_of_range); CHECK_THROWS_AS(binding.reduced_dof(binding.full_size()), std::out_of_range); CHECK_THROWS_AS(binding.supports(-1), std::out_of_range); CHECK_THROWS_AS(binding.supports(space.GetNE()), std::out_of_range); CHECK_THROWS_AS(binding.gather(mfem::Vector(binding.full_size() + 1)), std::invalid_argument); CHECK_THROWS_AS(binding.scatter(mfem::Vector(binding.size() + 1)), std::invalid_argument); } } TEST_CASE("MeanField supported field projections preserve canonical indexing and native GridFunctions", "[unit][discretization][meanfield]") { mfem::Mesh mesh = split_mesh(); mfem::H1_FECollection density_collection(blocks::density::mass::FunctionalSpace::order, 3); mfem::L2_FECollection velocity_collection(2, 3); mfem::H1_FECollection enthalpy_collection(3, 3); mfem::FiniteElementSpace density(&mesh, &density_collection); mfem::FiniteElementSpace enthalpy(&mesh, &enthalpy_collection); mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); mfem::FiniteElementSpace potential(&mesh, &potential_collection); check_supported_projection(density); check_supported_projection<::serif::tests::equations::Enthalpy>(enthalpy); check_supported_projection(potential); for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { mfem::FiniteElementSpace displacement(&mesh, &enthalpy_collection, 3, ordering); check_supported_projection(displacement); mfem::FiniteElementSpace velocity(&mesh, &velocity_collection, 3, ordering); CHECK(velocity.GetFE(0)->GetOrder() == 2); CHECK(velocity.GetVDim() == 3); CHECK(velocity.GetOrdering() == ordering); check_supported_projection(velocity); } CHECK_THROWS_AS((backend::TermBinding{density}), std::invalid_argument); } TEST_CASE("MeanField stellar H1 fields retain interface values and exclude vacuum-only coefficients", "[unit][discretization][meanfield]") { mfem::Mesh mesh = split_mesh(); mfem::H1_FECollection h1(3, 3); mfem::FiniteElementSpace enthalpy_space(&mesh, &h1), density_space(&mesh, &h1); const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{enthalpy_space}; const backend::TermBinding density{density_space}; mfem::Array stellar, vacuum; enthalpy_space.GetElementDofs(0, stellar); enthalpy_space.GetElementDofs(1, vacuum); int shared = 0; int excluded = 0; for (const int dof : vacuum) { const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end(); CHECK(enthalpy.reduced_dof(dof).has_value() == on_interface); shared += on_interface; excluded += !on_interface; } CHECK(shared > 0); CHECK(excluded > 0); density_space.GetElementDofs(0, stellar); density_space.GetElementDofs(1, vacuum); shared = 0; excluded = 0; for (const int dof : vacuum) { const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end(); CHECK(density.reduced_dof(dof).has_value() == on_interface); shared += on_interface; excluded += !on_interface; } CHECK(shared > 0); CHECK(excluded > 0); CHECK(enthalpy.supports(0)); CHECK_FALSE(enthalpy.supports(1)); CHECK(density.supports(0)); CHECK_FALSE(density.supports(1)); } TEST_CASE("MeanField field support follows alternate semantic material IDs", "[unit][discretization][meanfield]") { mfem::Mesh mesh = split_mesh(17, 29); mfem::H1_FECollection h1(3, 3); mfem::FiniteElementSpace density(&mesh, &h1), enthalpy(&mesh, &h1); check_supported_projection(density); check_supported_projection<::serif::tests::equations::Enthalpy, AlternateSchema>(enthalpy); } TEST_CASE("MeanField supported maps reject stale bindings and malformed selections", "[unit][discretization][meanfield]") { mfem::Mesh mesh = split_mesh(); mfem::H1_FECollection collection(3, 3); mfem::FiniteElementSpace space(&mesh, &collection, 3); const backend::TermBinding binding{space}; for (const std::vector& entries : {std::vector{-1, 2}, {1, binding.size()}, {1, 1, 3}, {1, 3, 2}}) { mfem::Array selected(static_cast(entries.size())); std::copy(entries.begin(), entries.end(), selected.begin()); CHECK_THROWS_AS(backend::DofSelection(binding, selected), std::invalid_argument); } mfem::Vector values(binding.size()); values = 0.0; CHECK_NOTHROW(binding.validate_values(values)); values[0] = std::numeric_limits::quiet_NaN(); CHECK_THROWS_AS(binding.scatter(values), std::domain_error); mesh.GetElement(0)->SetAttribute(3); CHECK_THROWS(binding.validate()); } TEST_CASE("MeanField registered spaces retain family order vector dimension and global scalar storage", "[unit][discretization][meanfield]") { mfem::Mesh mesh = split_mesh(); mfem::H1_FECollection h1(3, 3); mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); mfem::FiniteElementSpace potential(&mesh, &potential_collection), displacement(&mesh, &h1, 3); const backend::TermBinding potential_binding{potential}; const backend::TermBinding displacement_binding{displacement}; CHECK(potential.GetFE(0)->GetOrder() == blocks::gravity::potential::FunctionalSpace::order); CHECK(displacement.GetFE(0)->GetOrder() == 3); CHECK(potential.GetVDim() == 1); CHECK(displacement.GetVDim() == 3); CHECK(potential_binding.size() == potential.GetVSize()); CHECK(displacement_binding.size() == displacement.GetVSize()); const backend::TermBinding global{mesh}; CHECK(global.size() == 1); CHECK(global.full_size() == 1); CHECK_THROWS_AS(global.space(), std::logic_error); mfem::Vector scalar(1); scalar[0] = 2.7; CHECK(global.gather(global.scatter(scalar))[0] == 2.7); mfem::Vector local; global.element_values(0, scalar, local); CHECK(local[0] == 2.7); global.element_values(1, scalar, local); CHECK(local[0] == 0.0); CHECK_THROWS_AS((backend::TermBinding{displacement}), std::invalid_argument); } TEST_CASE("MeanField stellar boundary selections preserve exact reduced field ordering", "[unit][discretization][meanfield]") { serif::tests::deformation::PairedMesh fixture(0, 0.0, 3, 4); const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{fixture.scalar_space}; const backend::DofSelection selected = backend::select_boundary_dofs(enthalpy); REQUIRE(selected.size() > 0); CHECK(selected.size() < enthalpy.size()); CHECK(&selected.binding() == &enthalpy); mfem::Array marker{1, 0}; mfem::Array full_boundary; fixture.scalar_space.GetEssentialTrueDofs(marker, full_boundary); REQUIRE(selected.size() == full_boundary.Size()); for (int index = 0; index < full_boundary.Size(); ++index) { const std::optional reduced = enthalpy.reduced_dof(full_boundary[index]); REQUIRE(reduced); CHECK(selected.reduced_dofs()[index] == *reduced); if (index > 0) { CHECK(selected.reduced_dofs()[index] > selected.reduced_dofs()[index - 1]); } } CHECK_NOTHROW(selected.validate()); }