Files
MeanField_SERiF/tests/model/surface_refinement.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
2026-11-09 13:53:17 -05:00

233 lines
13 KiB
C++

#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <span>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
#include "serif/model/finite_elements.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace {
namespace model = serif::model;
namespace factory = model::backend::mfem;
namespace deformation = serif::geometry::deformation;
namespace geometry = deformation::backend::mfem;
namespace domain = serif::discretization::domain;
using Resources = serif::discretization::backend::mfem::Resources;
using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema;
constexpr int stellar_surface = Schema::boundary_id<domain::StellarSurfaceBoundary>();
constexpr int infinity_surface = Schema::boundary_id<domain::InfinitySurfaceBoundary>();
struct SurfaceStatistics {
int faces{};
int equatorial_faces{};
int polar_faces{};
double largest_equatorial_area{};
double smallest_polar_area{std::numeric_limits<double>::infinity()};
std::vector<double> absolute_latitudes;
std::vector<double> areas;
};
// Measure concentration in physical space, independently of the selector's
// logical coordinates and refinement-depth bookkeeping.
SurfaceStatistics surface_statistics(mfem::Mesh& mesh, std::array<double, 3> axis) {
double scale = 0.0;
for (const double value : axis) scale = std::max(scale, std::abs(value));
for (double& value : axis) value /= scale;
const double norm = std::hypot(axis[0], axis[1], axis[2]);
for (double& value : axis) value /= norm;
SurfaceStatistics result;
mfem::Vector position(3);
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) {
if (mesh.GetBdrAttribute(boundary) != stellar_surface) continue;
++result.faces;
auto& transform = *mesh.GetBdrElementTransformation(boundary);
transform.Transform(mfem::Geometries.GetCenter(mesh.GetBdrElementBaseGeometry(boundary)), position);
double latitude = 0.0;
for (int component = 0; component < 3; ++component) latitude += axis[component] * position[component];
latitude = std::abs(latitude) / position.Norml2();
double area = 0.0;
const auto& rule = mfem::IntRules.Get(mesh.GetBdrElementBaseGeometry(boundary), 8);
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
const auto& point = rule.IntPoint(sample);
transform.SetIntPoint(&point);
area += point.weight * transform.Weight();
}
REQUIRE(std::isfinite(area));
REQUIRE(area > 0.0);
result.absolute_latitudes.push_back(latitude);
result.areas.push_back(area);
if (latitude < std::sin(15.0 * std::numbers::pi / 180.0)) {
++result.equatorial_faces;
result.largest_equatorial_area = std::max(result.largest_equatorial_area, area);
}
if (latitude > 0.75) {
++result.polar_faces;
result.smallest_polar_area = std::min(result.smallest_polar_area, area);
}
}
std::ranges::sort(result.absolute_latitudes);
std::ranges::sort(result.areas);
return result;
}
void check_mesh_invariants(Resources& resources) {
REQUIRE(resources.meshes.size() == 2);
auto& physical = *resources.meshes[0];
auto& logical = *resources.meshes[1];
REQUIRE(physical.GetNE() == logical.GetNE());
REQUIRE(physical.GetNV() == logical.GetNV());
REQUIRE(physical.GetNBE() == logical.GetNBE());
CHECK(physical.Nonconforming() == logical.Nonconforming());
mfem::Array<int> physical_vertices, logical_vertices;
for (int element = 0; element < physical.GetNE(); ++element) {
CHECK(physical.GetAttribute(element) == logical.GetAttribute(element));
physical.GetElementVertices(element, physical_vertices);
logical.GetElementVertices(element, logical_vertices);
REQUIRE(physical_vertices.Size() == logical_vertices.Size());
CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast<std::size_t>(physical_vertices.Size())},
std::span{logical_vertices.GetData(), static_cast<std::size_t>(logical_vertices.Size())}));
auto& transform = *physical.GetElementTransformation(element);
const auto& rule = mfem::IntRules.Get(physical.GetElementBaseGeometry(element), 6);
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
transform.SetIntPoint(&rule.IntPoint(sample));
const double determinant = transform.Jacobian().Det();
REQUIRE(std::isfinite(determinant));
REQUIRE(determinant > 0.0);
}
}
REQUIRE(resources.compactification);
auto& coordinate = *resources.compactification;
auto& scalar_space = *coordinate.FESpace();
mfem::Array<int> dofs;
int stellar_faces = 0, infinity_faces = 0;
for (int boundary = 0; boundary < physical.GetNBE(); ++boundary) {
CHECK(physical.GetBdrAttribute(boundary) == logical.GetBdrAttribute(boundary));
physical.GetBdrElementVertices(boundary, physical_vertices);
logical.GetBdrElementVertices(boundary, logical_vertices);
REQUIRE(physical_vertices.Size() == logical_vertices.Size());
CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast<std::size_t>(physical_vertices.Size())},
std::span{logical_vertices.GetData(), static_cast<std::size_t>(logical_vertices.Size())}));
const int attribute = physical.GetBdrAttribute(boundary);
if (attribute != stellar_surface && attribute != infinity_surface) continue;
if (attribute == stellar_surface) ++stellar_faces;
else ++infinity_faces;
scalar_space.GetBdrElementDofs(boundary, dofs);
for (const int dof : dofs) {
CHECK(coordinate[mfem::FiniteElementSpace::DecodeDof(dof)] ==
Catch::Approx(attribute == stellar_surface ? 0.0 : 1.0).margin(2.0e-13));
}
}
REQUIRE(stellar_faces > 0);
REQUIRE(infinity_faces > 0);
// This retains the production guard: volume hanging nodes are allowed,
// but every stellar trace coefficient must remain independent.
const auto trace = geometry::select_surface_scalar_dofs(scalar_space, stellar_surface);
INFO((trace ? "Conforming stellar trace" : trace.error().message));
REQUIRE(trace);
}
}
TEST_CASE("Surface refinement adds levels beyond the existing global mesh", "[model][resources][surface_refinement]") {
for (const int base : {0, 1}) {
CAPTURE(base);
model::StellarMeshOptions options{.refinement = base, .geometry_order = 2};
auto original = factory::make_stellar_resources(options);
const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0});
options.surface_refinement.levels = 1;
auto refined = factory::make_stellar_resources(options);
const auto after = surface_statistics(*refined.meshes[0], {0.0, 0.0, 1.0});
CHECK(after.faces == 4 * before.faces);
check_mesh_invariants(refined);
if (base == 1) {
options.surface_refinement.equatorial_levels = 1;
auto combined = factory::make_stellar_resources(options);
const auto combined_stats = surface_statistics(*combined.meshes[0], {0.0, 0.0, 1.0});
CHECK(combined_stats.faces > after.faces);
CHECK(combined_stats.polar_faces == after.polar_faces);
check_mesh_invariants(combined);
}
}
}
TEST_CASE("Equatorial rings concentrate resolution around the requested physical axis", "[model][resources][surface_refinement]") {
model::StellarMeshOptions options{.geometry_order = 2};
auto original = factory::make_stellar_resources(options);
const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0});
options.surface_refinement.equatorial_levels = 3;
auto native = factory::make_stellar_resources(options);
const auto expected = surface_statistics(*native.meshes[0], options.surface_refinement.axis);
CHECK(expected.faces > before.faces);
CHECK(expected.equatorial_faces > before.equatorial_faces);
CHECK(expected.polar_faces == before.polar_faces);
CHECK(expected.largest_equatorial_area < 0.5 * expected.smallest_polar_area);
check_mesh_invariants(native);
const double largest = std::numeric_limits<double>::max();
const double smallest = std::numeric_limits<double>::min();
for (const std::array<double, 3> axis : {
std::array{1.0, 0.0, 0.0}, std::array{1.0, 1.0, 1.0},
std::array{-1.0, -1.0, -1.0}, std::array{0.0, 0.0, -1.0},
std::array{largest, largest, largest}, std::array{smallest, smallest, smallest}}) {
CAPTURE(axis);
options.surface_refinement.axis = axis;
auto rotated = factory::make_stellar_resources(options);
const auto actual = surface_statistics(*rotated.meshes[0], axis);
REQUIRE(actual.faces == expected.faces);
CHECK(actual.equatorial_faces == expected.equatorial_faces);
CHECK(actual.polar_faces == expected.polar_faces);
for (std::size_t index = 0; index < actual.areas.size(); ++index) {
CHECK(actual.absolute_latitudes[index] == Catch::Approx(expected.absolute_latitudes[index]).margin(2.0e-12));
CHECK(actual.areas[index] == Catch::Approx(expected.areas[index]).epsilon(2.0e-12));
}
check_mesh_invariants(rotated);
}
}
TEST_CASE("Refined rotated meshes retain one surface and volume deformation", "[model][resources][surface_refinement]") {
const auto star = serif::tests::solver::make_model();
const auto finite_elements = model::make_stellar_discretization(star,
{.geometry_order = 3, .surface_refinement = {.levels = 1, .equatorial_levels = 2, .axis = {1.0, 1.0, 1.0}}},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}});
std::vector<double> shape(9);
for (std::size_t mode = 0; mode < shape.size(); ++mode) shape[mode] = 0.002 * std::sin(0.37 * (mode + 1));
const auto inputs = geometry::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape);
auto cache = serif::utils::dependencies::make_computation_cache<deformation::DeformationFailure>(
geometry::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
const auto prepared = cache.try_prepare(inputs);
INFO((prepared ? "Deformation graph prepared" : prepared.error().error.message));
REQUIRE(prepared);
const auto& reference = *cache.get<deformation::keys::ReferenceSurface>();
const auto& surface = cache.get<deformation::keys::SurfaceDisplacement>();
const auto& volume = cache.get<deformation::keys::VolumeDisplacement>();
const auto& space = finite_elements.deformation.reference.displacement_space;
REQUIRE(std::ranges::any_of(surface, [](double value) { return std::abs(value) > 1.0e-6; }));
for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) {
for (int component = 0; component < 3; ++component) {
CHECK(volume[reference.scalar_dofs[node] + component * space.GetNDofs()] ==
Catch::Approx(surface[3 * node + component]).margin(2.0e-12));
}
}
}
TEST_CASE("Surface refinement rejects undefined levels bands and axes", "[model][resources][surface_refinement]") {
model::StellarMeshOptions options{.geometry_order = 2};
SECTION("negative surface levels") { options.surface_refinement.levels = -1; }
SECTION("negative equatorial levels") { options.surface_refinement.equatorial_levels = -1; }
SECTION("zero axis") { options.surface_refinement.axis = {0.0, 0.0, 0.0}; }
SECTION("nonfinite axis") { options.surface_refinement.axis = {0.0, std::numeric_limits<double>::quiet_NaN(), 1.0}; }
SECTION("nonfinite band") { options.surface_refinement.equatorial_half_angle_degrees = std::numeric_limits<double>::quiet_NaN(); }
SECTION("zero band") { options.surface_refinement.equatorial_half_angle_degrees = 0.0; }
SECTION("band outside the supported side patches") { options.surface_refinement.equatorial_half_angle_degrees = 35.1; }
CHECK_THROWS(factory::make_stellar_resources(options));
}