Add native spherical harmonic surface coordinates to coupled equilibrium

This commit is contained in:
2026-09-19 08:51:42 -04:00
parent 2171b8b76a
commit 7b0eb8d92d
23 changed files with 3131 additions and 57 deletions

View File

@@ -0,0 +1,294 @@
#include <cmath>
#include <concepts>
#include <numbers>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace harmonic_surface_metric_tests {
// Use analytic Cartesian formulas and direct boundary quadrature, rather
// than either the harmonic basis implementation or its assembled mass
// action. This distinguishes vector trace work from scalar radial work.
double analyticReferenceWork(
const mean_field::fem::FEM &fem,
int degree
) {
mfem::VectorFunctionCoefficient coefficient(3, [degree](const mfem::Vector &position, mfem::Vector &value) {
const double radius = position.Norml2();
if (!(radius > 0.)) {
// Interior center values cannot affect the boundary trace.
value.SetSize(3);
value = 0.;
return;
}
const double z = position(2) / radius;
const double harmonic =
degree == 0 ? 1. / std::sqrt(4. * std::numbers::pi) : std::sqrt(3. / (4. * std::numbers::pi)) * z;
value.SetSize(3);
for (int component = 0; component < 3; ++component)
value(component) = harmonic * position(component) / radius;
});
mfem::ParGridFunction tangent(fem.displacementFes.get());
tangent.ProjectCoefficient(coefficient);
mfem::Array<int> dofs;
mfem::Vector local, shape;
long double work = 0.;
for (int b = 0; b < fem.mesh->GetNBE(); ++b) {
if (fem.mesh->GetBdrAttribute(b) != 1)
continue;
const auto &element = *fem.displacementFes->GetBE(b);
auto &transformation = *fem.displacementFes->GetBdrElementTransformation(b);
REQUIRE(fem.displacementFes->GetBdrElementVDofs(b, dofs) == nullptr);
tangent.GetSubVector(dofs, local);
REQUIRE(local.Size() == 3 * element.GetDof());
shape.SetSize(element.GetDof());
// Match the declared FE quadrature rule, not the assembled matrix
// or its diagonal; the integrand is independently reconstructed.
const auto &rule = mfem::MassIntegrator::GetRule(element, element, transformation);
for (int q = 0; q < rule.GetNPoints(); ++q) {
const auto &point = rule.IntPoint(q);
transformation.SetIntPoint(&point);
element.CalcShape(point, shape);
long double normSquared = 0.;
for (int component = 0; component < 3; ++component) {
long double value = 0.;
for (int i = 0; i < element.GetDof(); ++i)
value += static_cast<long double>(shape(i)) * local(i + component * element.GetDof());
normSquared += value * value;
}
work += point.weight * transformation.Weight() * normSquared;
}
}
REQUIRE(std::isfinite(work));
REQUIRE(work > 0.);
return static_cast<double>(work);
}
// A valid surface implementation does not automatically promise the
// canonical nodal ordering required by this particular Riesz metric.
class SurfaceWithoutTraceMetadata final {
public:
explicit SurfaceWithoutTraceMetadata(mean_field::deformation::PreparedSphericalHarmonicSurface surface)
: m_surface(std::move(surface)) {
}
auto descriptor() const noexcept {
return m_surface.descriptor();
}
int parameterCount() const noexcept {
return m_surface.parameterCount();
}
int surfaceDisplacementSize() const noexcept {
return m_surface.surfaceDisplacementSize();
}
void buildSurfaceDisplacement(
const mfem::Vector &x,
mfem::Vector &u
) const {
m_surface.buildSurfaceDisplacement(x, u);
}
void applyJacobian(
const mfem::Vector &x,
const mfem::Vector &v,
mfem::Vector &u
) const {
m_surface.applyJacobian(x, v, u);
}
void applyJacobianTranspose(
const mfem::Vector &x,
const mfem::Vector &u,
mfem::Vector &v
) const {
m_surface.applyJacobianTranspose(x, u, v);
}
void applyPullbackDerivative(
const mfem::Vector &x,
const mfem::Vector &v,
const mfem::Vector &u,
mfem::Vector &result
) const {
m_surface.applyPullbackDerivative(x, v, u, result);
}
private:
mean_field::deformation::PreparedSphericalHarmonicSurface m_surface;
};
static_assert(mean_field::deformation::PreparedSurfaceDeformationPrescription<SurfaceWithoutTraceMetadata>);
} // namespace harmonic_surface_metric_tests
TEST_CASE(
"Harmonic Surface Coordinates Receive Their Own Primal And Dual Riesz Scales Before Physical Preparation",
"[normalization][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1)
SKIP("The current radial domain extension supports one MPI rank.");
// Both configurations have the same ambient trace space. Only the
// independent surface coordinate count changes, so a nodal-diagonal
// shortcut would fail the block-size and scale checks below.
deformation::SphericalHarmonicSurface prescription({.maximumDegree = 4});
int expectedModes = 25;
SECTION("All real azimuthal modes") {
}
SECTION("Axisymmetric modes") {
prescription = deformation::SphericalHarmonicSurface({.maximumDegree = 4, .maximumAzimuthalOrder = 0});
expectedModes = 5;
}
const auto args = test_utils::setup_args();
auto finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
const normalization::PhysicalRieszDiagonal policy{dimensions::LengthValue{utils::RADIUS}, utils::G};
auto stellarModel = model::StellarModel(
eos::Polytrope({.n = 1.0, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
auto problem = equilibrium::discretize(
stellarModel, equilibrium::makeStellarDiscretization(std::move(finiteElements), policy, prescription)
);
using Problem = std::remove_cvref_t<decltype(problem)>;
using Form = typename Problem::FormType;
using ShapeValue = utils::blocks::surface_deformation::parameters::value;
using ShapeResidual = utils::blocks::surface_deformation::shape_equilibrium::residual;
constexpr int valueBlock = utils::blocks::type_index_v<ShapeValue, typename Form::value_blocks>;
constexpr int residualBlock = utils::blocks::type_index_v<ShapeResidual, typename Form::residual_blocks>;
STATIC_CHECK(normalization::NormalizableStellarEquilibriumProblem<Problem>);
const auto &layout = problem.GetManifest().layout();
const int valueOffset = layout.value_offsets()[valueBlock];
const int residualOffset = layout.residual_offsets()[residualBlock];
REQUIRE(layout.value_offsets()[valueBlock + 1] - valueOffset == expectedModes);
REQUIRE(layout.residual_offsets()[residualBlock + 1] - residualOffset == expectedModes);
REQUIRE(problem.StateSize() == problem.EquationSize());
const auto &physical = problem.GetPhysicalOperator();
const auto &domain = physical.GetDomainDeformation();
REQUIRE_FALSE(domain.hasNodalRadialParameters());
REQUIRE(domain.parameterCount() == expectedModes);
REQUIRE_FALSE(physical.IsPrepared());
const auto generation = problem.GetPreparationGeneration();
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(problem);
CHECK_FALSE(normalized.IsPrepared());
CHECK_FALSE(physical.IsPrepared());
CHECK(problem.GetPreparationGeneration() == generation);
const auto diagonal =
deformation::assembleSurfaceParameterMassDiagonal(physical.GetHydrostaticOperator().GetFEM(), domain);
REQUIRE(diagonal.Size() == expectedModes);
const auto &referenceFEM = physical.GetHydrostaticOperator().GetFEM();
CHECK(
diagonal(0) ==
Catch::Approx(harmonic_surface_metric_tests::analyticReferenceWork(referenceFEM, 0)).epsilon(5e-13)
);
const int axialDipole = expectedModes == 5 ? 1 : 2;
CHECK(
diagonal(axialDipole) ==
Catch::Approx(harmonic_surface_metric_tests::analyticReferenceWork(referenceFEM, 1)).epsilon(5e-13)
);
const auto scales = normalization::deriveStellarCharacteristicScales(policy, stellarModel);
const double valueScale = normalization::physicalScale<ShapeValue>(scales);
const double residualScale = normalization::physicalScale<ShapeResidual>(scales);
const auto &stateFactors = normalized.GetNormalization().StateFactors();
const auto &residualFactors = normalized.GetNormalization().ResidualFactors();
REQUIRE(stateFactors.Size() == problem.StateSize());
REQUIRE(residualFactors.Size() == problem.EquationSize());
for (int i = 0; i < expectedModes; ++i) {
REQUIRE(std::isfinite(diagonal[i]));
REQUIRE(diagonal[i] > 0.0);
CHECK(stateFactors[valueOffset + i] == Catch::Approx(std::sqrt(diagonal[i]) / valueScale).epsilon(3.e-14));
CHECK(
residualFactors[residualOffset + i] ==
Catch::Approx(1.0 / (residualScale * std::sqrt(diagonal[i]))).epsilon(3.e-14)
);
// Primal and dual factors act on different root block offsets. Their
// Gram weights cancel in the work pairing, leaving physical scales.
CHECK(
stateFactors[valueOffset + i] * residualFactors[residualOffset + i] ==
Catch::Approx(1.0 / (valueScale * residualScale)).epsilon(3.e-14)
);
}
bool allFactorsValid = true;
for (int i = 0; i < stateFactors.Size(); ++i)
allFactorsValid = allFactorsValid && std::isfinite(stateFactors[i]) && stateFactors[i] > 0.0 &&
std::isfinite(residualFactors[i]) && residualFactors[i] > 0.0;
CHECK(allFactorsValid);
CHECK_FALSE(physical.IsPrepared());
CHECK(problem.GetPreparationGeneration() == generation);
}
TEST_CASE(
"Harmonic Reference Metric Rejects Unrelated Current Spaces And Unadvertised Trace Layouts",
"[normalization][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1)
SKIP("The current radial domain extension supports one MPI rank.");
const auto args = test_utils::setup_args();
auto finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
deformation::SphericalHarmonicSurface prescription({.maximumDegree = 2});
auto prepared = deformation::compileDomainDeformation(
prescription, deformation::PowerLawRadialInteriorExtension{}, deformation::FixedInfinityRadialVacuumExtension{},
finiteElements
);
deformation::PreparedDomainDeformationRuntime runtime(std::move(prepared));
REQUIRE(runtime.matchesCurrentDiscretization());
REQUIRE(runtime.supportsSurfaceParameterMetric());
SECTION("A different current FEM with identical dimensions is not interchangeable") {
auto other = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(other.okay());
REQUIRE(other.surfaceDeformationFes->GetTrueVSize() == finiteElements.surfaceDeformationFes->GetTrueVSize());
REQUIRE(other.displacementFes->GetTrueVSize() == finiteElements.displacementFes->GetTrueVSize());
REQUIRE(other.mesh.get() != finiteElements.mesh.get());
CHECK_THROWS_AS(deformation::assembleSurfaceParameterMassDiagonal(other, runtime), std::invalid_argument);
CHECK(runtime.matchesCurrentDiscretization());
}
SECTION("A surface tangent alone cannot certify canonical nodal vector ordering") {
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const deformation::SurfaceDeformationCompilationContext surfaceContext{
*finiteElements.surfaceDeformationFes,
field::make_stellar_surface_scalar_dof_map<Schema>(*finiteElements.surfaceDeformationFes)
};
auto surface = deformation::compileSurfaceDeformationPrescription(prescription, surfaceContext);
auto extensionContext = deformation::makeRadialDeformationExtensionCompilationContext<Schema>(
*finiteElements.surfaceDeformationFes, *finiteElements.displacementFes, *finiteElements.logicalReferenceMesh
);
auto custom = deformation::composePreparedDomainDeformation(
harmonic_surface_metric_tests::SurfaceWithoutTraceMetadata(std::move(surface)),
deformation::compileInteriorDeformationExtension(
deformation::PowerLawRadialInteriorExtension{}, extensionContext
),
deformation::compileVacuumDeformationExtension(
deformation::FixedInfinityRadialVacuumExtension{}, extensionContext
),
*finiteElements.surfaceDeformationFes, *finiteElements.displacementFes, *finiteElements.logicalReferenceMesh
);
STATIC_CHECK(deformation::PreparedDomainDeformationOperator<decltype(custom)>);
REQUIRE_FALSE(custom.supportsSurfaceParameterMetric());
deformation::PreparedDomainDeformationRuntime customRuntime(std::move(custom));
REQUIRE(customRuntime.matchesCurrentDiscretization());
REQUIRE_FALSE(customRuntime.supportsSurfaceParameterMetric());
mfem::Vector parameters(customRuntime.parameterCount()), direction(parameters.Size()),
trace(customRuntime.surfaceDisplacementSize());
parameters = 0.;
direction = 1.;
CHECK_NOTHROW(customRuntime.applySurfaceJacobian(parameters, direction, trace));
CHECK(trace.Norml2() > 0.);
CHECK_THROWS_AS(
deformation::assembleSurfaceParameterMassDiagonal(finiteElements, customRuntime), std::invalid_argument
);
}
}