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,352 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_surface_tests {
namespace deformation = mean_field::deformation;
namespace field = mean_field::field;
namespace domain = mean_field::utils::domain;
using Schema = domain::CoreEnvelopeVacuumDomainSchema;
int ranks() {
int count = 0;
MPI_Comm_size(MPI_COMM_WORLD, &count);
return count;
}
auto context(const mean_field::fem::FEM &fem) {
return deformation::SurfaceDeformationCompilationContext{
*fem.surfaceDeformationFes, field::make_stellar_surface_scalar_dof_map<Schema>(*fem.surfaceDeformationFes)
};
}
int modeIndex(
const deformation::PreparedSphericalHarmonicSurface &surface,
int degree,
int order
) {
int index = 0;
for (const auto mode : surface.modes()) {
if (mode.degree == degree && mode.order == order)
return index;
++index;
}
throw std::logic_error("Required analytic harmonic mode was not compiled.");
}
double relativeDifference(
const mfem::Vector &a,
const mfem::Vector &b
) {
REQUIRE(a.Size() == b.Size());
mfem::Vector difference(a);
difference -= b;
return difference.Norml2() / std::max(b.Norml2(), 1e-300);
}
void fill(
mfem::Vector &values,
double scale = 1.
) {
for (int i = 0; i < values.Size(); ++i)
values(i) = scale * std::sin(.37 * (i + 1));
}
} // namespace spherical_harmonic_surface_tests
TEST_CASE(
"Spherical Harmonic Surface Validates Physical Mode Selection And Type Contracts",
tags::spherical_harmonic_surface_validation
) {
namespace d = mean_field::deformation;
STATIC_CHECK(d::SurfaceDeformationPrescription<d::SphericalHarmonicSurface>);
STATIC_CHECK(d::PreparedSurfaceDeformationPrescription<d::PreparedSphericalHarmonicSurface>);
STATIC_CHECK(d::SurfaceDeformationCompilable<d::SphericalHarmonicSurface, d::SurfaceDeformationCompilationContext>);
STATIC_CHECK_FALSE(d::PreparedSphericalHarmonicSurface::hasNodalRadialParameters);
const d::SphericalHarmonicSurface full;
const d::SphericalHarmonicSurface axisymmetric(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 0}
);
const d::SphericalHarmonicSurface orderOne(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 1}
);
const d::SphericalHarmonicSurface orderTwo(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 2}
);
const d::SphericalHarmonicSurface monopole(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 0});
CHECK(full.modeCount() == 25);
CHECK(axisymmetric.modeCount() == 5);
CHECK(orderOne.modeCount() == 13);
CHECK(orderTwo.modeCount() == 19);
CHECK(monopole.modeCount() == 1);
CHECK(full.referenceCenter().Size() == 3);
CHECK(full.referenceCenter().Norml2() == 0.);
CHECK(full.descriptor().motionKind == d::SurfaceMotionKind::Radial);
CHECK(full.descriptor().linearOnReferenceGeometry);
CHECK(full.descriptor().supportsExactNewtonLinearization());
for (const auto options :
{d::SphericalHarmonicSurfaceOptions{.maximumDegree = -1},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = -1},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 5},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = std::numeric_limits<int>::max()}})
CHECK_THROWS_AS(d::SphericalHarmonicSurface(options), std::invalid_argument);
mfem::Vector center(2);
center = 0.;
CHECK_THROWS_AS(d::SphericalHarmonicSurface(center), std::invalid_argument);
center.SetSize(3);
center = 0.;
center(1) = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(d::SphericalHarmonicSurface(center), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Matches Analytic Real Harmonics And Radial Lengths",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto context = t::context(fem);
if (t::ranks() != 1) {
CHECK_THROWS_AS(
d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context), std::invalid_argument
);
return;
}
const auto surface = d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context);
CHECK(surface.parameterCount() == 25);
CHECK(surface.globalParameterCount() == 25);
CHECK(surface.globalParameterOffset() == 0);
CHECK(surface.surfaceDisplacementSize() == 3 * surface.surfaceNodeCount());
REQUIRE(surface.surfaceNodeCount() > surface.parameterCount());
CHECK(surface.nodalBasis().Height() == surface.surfaceNodeCount());
CHECK(surface.nodalBasis().Width() == surface.parameterCount());
const double pi = std::numbers::pi;
for (int node = 0; node < surface.surfaceNodeCount(); ++node) {
const double x = surface.radialDirection(node, 0), y = surface.radialDirection(node, 1),
z = surface.radialDirection(node, 2);
const auto check = [&](int l, int m, double exact) {
CHECK(std::abs(surface.nodalBasis()(node, t::modeIndex(surface, l, m)) - exact) < 2e-14);
};
check(0, 0, 1. / std::sqrt(4. * pi));
check(1, 0, std::sqrt(3. / (4. * pi)) * z);
check(1, 1, -std::sqrt(3. / (4. * pi)) * x);
check(1, -1, -std::sqrt(3. / (4. * pi)) * y);
check(2, 0, std::sqrt(5. / (16. * pi)) * (3. * z * z - 1.));
check(2, -2, std::sqrt(15. / (4. * pi)) * x * y);
check(2, 2, std::sqrt(15. / (16. * pi)) * (x * x - y * y));
}
mfem::Vector parameters(surface.parameterCount()), displacement(surface.surfaceDisplacementSize());
parameters = 0.;
parameters(0) = .125 * std::sqrt(4. * pi);
surface.buildSurfaceDisplacement(parameters, displacement);
for (int node = 0; node < surface.surfaceNodeCount(); ++node)
for (int component = 0; component < 3; ++component)
CHECK(
std::abs(
displacement(surface.surfaceDisplacementDof(node, component)) -
.125 * surface.radialDirection(node, component)
) < 1e-15
);
const auto axisymmetric = d::compileSurfaceDeformationPrescription(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 0}),
context
);
const auto restricted = d::compileSurfaceDeformationPrescription(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 1}),
context
);
REQUIRE(axisymmetric.parameterCount() == 5);
REQUIRE(restricted.parameterCount() == 13);
for (int j = 0; j < axisymmetric.parameterCount(); ++j) {
CHECK(axisymmetric.modes()[j].degree == j);
CHECK(axisymmetric.modes()[j].order == 0);
for (int i = 0; i < surface.surfaceNodeCount(); ++i)
CHECK(axisymmetric.nodalBasis()(i, j) == surface.nodalBasis()(i, t::modeIndex(surface, j, 0)));
}
for (const auto mode : restricted.modes())
CHECK(std::abs(mode.order) <= 1);
const d::SphericalHarmonicSurface unresolved(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = surface.surfaceNodeCount()}
);
CHECK_THROWS_AS(d::compileSurfaceDeformationPrescription(unresolved, context), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Preserves Its Exact Derivative And Dual Work",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto context = t::context(fem);
if (t::ranks() != 1) {
CHECK_THROWS_AS(
d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context), std::invalid_argument
);
return;
}
const auto surface = d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context);
const mfem::DenseMatrix originalBasis(surface.nodalBasis());
mfem::Vector parameters(surface.parameterCount()), direction(surface.parameterCount()),
dual(surface.surfaceDisplacementSize()), action(surface.surfaceDisplacementSize()),
pulledBack(surface.parameterCount()), second(surface.parameterCount());
t::fill(parameters, .03);
t::fill(direction, .07);
t::fill(dual);
surface.applyJacobian(parameters, direction, action);
surface.applyJacobianTranspose(parameters, dual, pulledBack);
const double scale = std::max({1., action.Norml2() * dual.Norml2(), direction.Norml2() * pulledBack.Norml2()});
CHECK(std::abs((action * dual) - (direction * pulledBack)) < 2e-13 * scale);
constexpr double epsilon = 1e-5;
mfem::Vector plus(parameters), minus(parameters), plusValue(action.Size()), minusValue(action.Size());
plus.Add(epsilon, direction);
minus.Add(-epsilon, direction);
surface.buildSurfaceDisplacement(plus, plusValue);
surface.buildSurfaceDisplacement(minus, minusValue);
plusValue -= minusValue;
plusValue /= 2. * epsilon;
CHECK(t::relativeDifference(plusValue, action) < 2e-10);
second = 7.;
surface.applyPullbackDerivative(parameters, direction, dual, second);
CHECK(second.Norml2() == 0.);
CHECK(
std::memcmp(
originalBasis.GetData(), surface.nodalBasis().GetData(),
sizeof(mfem::real_t) * originalBasis.Height() * originalBasis.Width()
) == 0
);
mfem::Vector wrong(surface.parameterCount() + 1);
wrong = 0.;
CHECK_THROWS_AS(surface.buildSurfaceDisplacement(wrong, action), std::invalid_argument);
CHECK_THROWS_AS(surface.applyJacobian(parameters, wrong, action), std::invalid_argument);
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, wrong), std::invalid_argument);
CHECK_THROWS_AS(surface.applyPullbackDerivative(parameters, direction, dual, wrong), std::invalid_argument);
direction(0) = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(surface.applyJacobian(parameters, direction, action), std::invalid_argument);
dual(0) = std::numeric_limits<double>::infinity();
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, pulledBack), std::invalid_argument);
// Finite entries can still overflow an accumulated modal force; accepting
// an infinite dual would contaminate the complete Newton merit silently.
for (int node = 0; node < surface.surfaceNodeCount(); ++node)
for (int component = 0; component < 3; ++component)
dual(surface.surfaceDisplacementDof(node, component)) =
std::numeric_limits<double>::max() * surface.radialDirection(node, component);
for (int i = 0; i < dual.Size(); ++i)
REQUIRE(std::isfinite(dual(i)));
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, pulledBack), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Rejects Aliased Modes Even With Enough Nodes",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
if (t::ranks() != 1) {
SUCCEED("MPI compilation rejection is covered by the full surface fixture.");
return;
}
auto serial = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 1., 1., 1.);
for (int b = 0; b < serial.GetNBE(); ++b)
serial.GetBdrElement(b)->SetAttribute(1);
mfem::ParMesh mesh(MPI_COMM_WORLD, serial);
mfem::H1_FECollection collection(1, 3);
mfem::ParFiniteElementSpace space(&mesh, &collection);
const auto map = t::field::make_stellar_surface_scalar_dof_map<t::Schema>(space);
const d::SurfaceDeformationCompilationContext context(space, map);
mfem::Vector center(3);
center = .5;
const d::SphericalHarmonicSurface axisymmetric(
center, d::SphericalHarmonicSurfaceOptions{.maximumDegree = 2, .maximumAzimuthalOrder = 0}
);
// Eight cube corners offer only two distinct cos(theta), so the three
// axisymmetric polynomials cannot define three independent coordinates.
if (t::ranks() == 1)
REQUIRE(map.local_size() == 8);
CHECK_THROWS_AS(d::compileSurfaceDeformationPrescription(axisymmetric, context), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Domain Couples All Volume Fields Through The Existing Extensions",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto make = [&]() {
return d::compileDomainDeformation(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 2}),
d::PowerLawRadialInteriorExtension{}, d::FixedInfinityRadialVacuumExtension{}, fem
);
};
if (t::ranks() != 1) {
CHECK_THROWS_AS(make(), std::invalid_argument);
return;
}
auto harmonic = make();
const auto &surface = harmonic.surfaceDeformationPrescription();
mfem::Vector center(3);
center = 0.;
auto nodal = d::compileDomainDeformation(
d::NodalRadialSurface(center), d::PowerLawRadialInteriorExtension{}, d::FixedInfinityRadialVacuumExtension{},
fem
);
mfem::Vector parameters(harmonic.parameterCount()), direction(harmonic.parameterCount()),
nodalParameters(nodal.parameterCount()), volume(harmonic.volumeDisplacementSize()),
nodalVolume(nodal.volumeDisplacementSize()), action(volume.Size()), dual(volume.Size()),
pulledBack(parameters.Size()), pullbackDerivative(parameters.Size());
REQUIRE(harmonic.parameterCount() == 9);
REQUIRE(harmonic.surfaceDisplacementSize() == nodal.surfaceDisplacementSize());
REQUIRE(harmonic.volumeDisplacementSize() == nodal.volumeDisplacementSize());
t::fill(parameters, .001);
t::fill(direction, .004);
t::fill(dual);
surface.nodalBasis().Mult(parameters, nodalParameters);
harmonic.buildVolumeDisplacement(parameters, volume);
nodal.buildVolumeDisplacement(nodalParameters, nodalVolume);
CHECK(t::relativeDifference(volume, nodalVolume) < 3e-13);
harmonic.applyJacobian(parameters, direction, action);
harmonic.applyJacobianTranspose(parameters, dual, pulledBack);
CHECK(
std::abs((action * dual) - (direction * pulledBack)) <
1e-11 * std::max({1., action.Norml2() * dual.Norml2(), direction.Norml2() * pulledBack.Norml2()})
);
harmonic.applyPullbackDerivative(parameters, direction, dual, pullbackDerivative);
CHECK(pullbackDerivative.Norml2() == 0.);
constexpr double epsilon = 1e-5;
mfem::Vector plus(parameters), minus(parameters), plusVolume(volume.Size()), minusVolume(volume.Size());
plus.Add(epsilon, direction);
minus.Add(-epsilon, direction);
harmonic.buildVolumeDisplacement(plus, plusVolume);
harmonic.buildVolumeDisplacement(minus, minusVolume);
plusVolume -= minusVolume;
plusVolume /= 2. * epsilon;
CHECK(t::relativeDifference(plusVolume, action) < 2e-9);
const auto infinity =
t::field::make_scalar_boundary_dof_map<t::domain::InfinitySurface, t::Schema>(*fem.surfaceDeformationFes);
for (int i = 0; i < infinity.local_size(); ++i)
for (int component = 0; component < 3; ++component)
CHECK(volume(infinity.volume_true_dof(i) + component * fem.surfaceDeformationFes->GetTrueVSize()) == 0.);
}

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
);
}
}

View File

@@ -0,0 +1,427 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <optional>
#include <span>
#include <string_view>
#include <type_traits>
#include <utility>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_equilibrium_test {
using namespace mean_field;
using Block = operators::RootBlockDescriptor;
[[nodiscard]] const Block &findBlock(
const std::span<const Block> blocks,
const std::string_view name
) {
const auto found =
std::find_if(blocks.begin(), blocks.end(), [name](const Block &block) { return block.stableId == name; });
REQUIRE(found != blocks.end());
return *found;
}
[[nodiscard]] mfem::Vector readBlock(
const mfem::Vector &vector,
const Block &block
) {
REQUIRE(block.offset >= 0);
REQUIRE(block.offset + block.size <= vector.Size());
mfem::Vector result(block.size);
for (int i = 0; i < block.size; ++i)
result(i) = vector(block.offset + i);
return result;
}
void writeBlock(
mfem::Vector &vector,
const Block &block,
const mfem::Vector &values
) {
REQUIRE(values.Size() == block.size);
for (int i = 0; i < block.size; ++i)
vector(block.offset + i) = values(i);
}
void checkClose(
const mfem::Vector &actual,
const mfem::Vector &expected,
const double relative = 2.0e-9,
const double absolute = 2.0e-12
) {
REQUIRE(actual.Size() == expected.Size());
mfem::Vector difference(actual);
difference -= expected;
const double scale = std::max(actual.Norml2(), expected.Norml2());
INFO("error = " << difference.Norml2() << ", scale = " << scale);
REQUIRE(std::isfinite(difference.Norml2()));
CHECK(difference.Norml2() <= absolute + relative * scale);
}
void checkBlocks(
const mfem::Vector &actual,
const mfem::Vector &expected,
const std::span<const Block> blocks,
const double relative = 2.0e-9,
const double absolute = 2.0e-12
) {
for (const Block &block : blocks) {
INFO("residual block = " << block.stableId);
checkClose(readBlock(actual, block), readBlock(expected, block), relative, absolute);
}
}
[[nodiscard]] operators::StellarEquilibriumDependencies dependencies(const std::uint64_t revision) {
return {
.discretization = {.identity = 8101, .revision = 1},
.density = {.identity = 8103, .revision = revision},
.surfaceDeformation = {.identity = 8107, .revision = revision},
.gravityGradient = {.identity = 8111, .revision = revision},
.gravityPotential = {.identity = 8117, .revision = revision},
.enthalpy = {.identity = 8123, .revision = revision},
.bernoulliConstant = {.identity = 8129, .revision = revision},
.rotation = {.identity = 8131, .revision = revision},
.targetMass = {.identity = 8147, .revision = 1}
};
}
[[nodiscard]] physics::RigidRotation zeroRotation() {
mfem::Vector omega(3), center(3);
omega = 0.0;
center = 0.0;
return {omega, center};
}
template <bool GeneratedRotation> [[nodiscard]] auto makeModel() {
if constexpr (GeneratedRotation) {
return model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}}),
integral::FixedAngularMomentum({.Jtotal = dimensions::AngularMomentumValue{0.01}})
);
} else {
return model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
}
}
template <typename Problem>
void prepare(
Problem &problem,
const mfem::Vector &state,
const std::uint64_t revision
) {
if constexpr (Problem::hasFixedAngularMomentum) {
REQUIRE(problem.TryPrepare(state, dependencies(revision)).has_value());
} else {
REQUIRE(problem.TryPrepare(state, dependencies(revision), zeroRotation()).has_value());
}
}
// The oracle is the existing nodal problem, with explicit coefficient and
// dual-force maps. It never calls the native modal residual or Jacobian to
// construct an expected coupled result.
template <
typename ModalProblem,
typename NodalProblem>
[[nodiscard]] mfem::Vector liftState(
const ModalProblem &modal,
const NodalProblem &nodal,
const mfem::DenseMatrix &basis,
const mfem::Vector &source
) {
mfem::Vector result(nodal.StateSize());
result = 0.0;
for (const Block &destination : nodal.GetManifest().valueBlocks()) {
const Block &origin = findBlock(modal.GetManifest().valueBlocks(), destination.stableId);
const mfem::Vector values = readBlock(source, origin);
if (destination.stableId == "surface_deformation") {
REQUIRE(basis.Width() == origin.size);
REQUIRE(basis.Height() == destination.size);
mfem::Vector lifted(destination.size);
basis.Mult(values, lifted);
writeBlock(result, destination, lifted);
} else {
writeBlock(result, destination, values);
}
}
return result;
}
template <
typename ModalProblem,
typename NodalProblem>
[[nodiscard]] mfem::Vector pullResidual(
const ModalProblem &modal,
const NodalProblem &nodal,
const mfem::DenseMatrix &basis,
const mfem::Vector &source
) {
mfem::Vector result(modal.EquationSize());
result = 0.0;
for (const Block &destination : modal.GetManifest().residualBlocks()) {
const Block &origin = findBlock(nodal.GetManifest().residualBlocks(), destination.stableId);
const mfem::Vector values = readBlock(source, origin);
if (destination.stableId == "surface_shape_balance") {
REQUIRE(basis.Height() == origin.size);
REQUIRE(basis.Width() == destination.size);
mfem::Vector pulled(destination.size);
basis.MultTranspose(values, pulled);
writeBlock(result, destination, pulled);
} else {
writeBlock(result, destination, values);
}
}
return result;
}
template <bool GeneratedRotation> void checkCoupledEquivalence() {
const utils::Args args = test_utils::setup_args();
fem::FEM modalFem = fem::setup_fem(args.mesh_file, args, 0);
fem::FEM nodalFem = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(modalFem.okay());
REQUIRE(nodalFem.okay());
const deformation::SphericalHarmonicSurface prescription{{.maximumDegree = 4}};
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const auto surfaceMap = field::make_stellar_surface_scalar_dof_map<Schema>(*modalFem.surfaceDeformationFes);
const auto preparedSurface = deformation::compileSurfaceDeformationPrescription(
prescription, deformation::SurfaceDeformationCompilationContext{*modalFem.surfaceDeformationFes, surfaceMap}
);
const mfem::DenseMatrix basis(preparedSurface.nodalBasis());
REQUIRE(basis.Width() == 25);
const normalization::PhysicalRieszDiagonal policy{dimensions::LengthValue{1.0}};
const auto stellarModel = makeModel<GeneratedRotation>();
auto modal = equilibrium::discretize(
stellarModel, equilibrium::makeStellarDiscretization(std::move(modalFem), policy, prescription)
);
auto nodal =
equilibrium::discretize(stellarModel, equilibrium::makeStellarDiscretization(std::move(nodalFem), policy));
using ModalProblem = std::remove_cvref_t<decltype(modal)>;
STATIC_CHECK_FALSE(ModalProblem::hasFixedCentralDensity);
STATIC_CHECK(ModalProblem::hasFixedAngularMomentum == GeneratedRotation);
STATIC_CHECK(ModalProblem::symbolicallySquare);
REQUIRE(modal.StateSize() == modal.EquationSize());
const Block &shape = findBlock(modal.GetManifest().valueBlocks(), "surface_deformation");
const Block &shapeRows = findBlock(modal.GetManifest().residualBlocks(), "surface_shape_balance");
REQUIRE(shape.size == 25);
REQUIRE(shapeRows.size == shape.size);
CHECK(nodal.StateSize() - modal.StateSize() == basis.Height() - basis.Width());
mfem::Vector state(modal.StateSize()), direction(modal.StateSize());
state = 0.0;
for (int i = 0; i < direction.Size(); ++i)
direction(i) = 0.02 * std::sin(0.31 * (i + 1));
const auto view = modal.GetManifest().stateView(state);
view.block(utils::blocks::density_field.mass_term) = 1.0;
view.block(utils::blocks::enthalpy_field.specific_term) = 0.7;
view.block(utils::blocks::fixed_total_mass_constraint.mass_normalization_term) = 0.8;
if constexpr (GeneratedRotation) {
view.block(utils::blocks::fixed_angular_momentum_constraint.angular_velocity_term) = 0.04;
modal.GetManifest().stateView(direction).block(
utils::blocks::fixed_angular_momentum_constraint.angular_velocity_term
) = 0.015;
}
// Nonzero orders on both real branches make an accidental m=0-only
// integration fail. Positive h keeps fractional powers differentiable;
// its nonzero boundary value is handled by the physical replacement rows.
for (int mode = 0; mode < shape.size; ++mode) {
state(shape.offset + mode) = 0.001 * std::sin(0.7 * (mode + 1));
direction(shape.offset + mode) = 0.002 * std::cos(0.41 * (mode + 1));
}
for (const std::string_view name : {"gravity_gradient", "gravity_potential"}) {
const Block &block = findBlock(modal.GetManifest().valueBlocks(), name);
for (int i = 0; i < block.size; ++i)
state(block.offset + i) = 0.01 * std::cos(0.13 * (i + 1));
}
const mfem::Vector nodalState = liftState(modal, nodal, basis, state);
const mfem::Vector nodalDirection = liftState(modal, nodal, basis, direction);
prepare(modal, state, 1);
prepare(nodal, nodalState, 1);
checkClose(
modal.GetPhysicalOperator().GetGeneratedVolumeDisplacement(),
nodal.GetPhysicalOperator().GetGeneratedVolumeDisplacement(), 2.0e-12, 2.0e-14
);
const auto &modalDomain = modal.GetPhysicalOperator().GetDomainDeformation();
const auto &nodalDomain = nodal.GetPhysicalOperator().GetDomainDeformation();
CHECK_FALSE(modalDomain.hasNodalRadialParameters());
CHECK(nodalDomain.hasNodalRadialParameters());
const Block &nodalShape = findBlock(nodal.GetManifest().valueBlocks(), "surface_deformation");
const mfem::Vector modalParameters = readBlock(state, shape);
const mfem::Vector nodalParameters = readBlock(nodalState, nodalShape);
mfem::Vector modalTrace(modalDomain.surfaceDisplacementSize());
mfem::Vector nodalTrace(nodalDomain.surfaceDisplacementSize());
modalDomain.applySurfaceJacobian(modalParameters, readBlock(direction, shape), modalTrace);
nodalDomain.applySurfaceJacobian(nodalParameters, readBlock(nodalDirection, nodalShape), nodalTrace);
checkClose(modalTrace, nodalTrace, 3.0e-13, 2.0e-14);
mfem::Vector traceDual(modalTrace.Size());
for (int i = 0; i < traceDual.Size(); ++i)
traceDual(i) = std::sin(0.23 * (i + 1));
mfem::Vector modalDual(shape.size), nodalDual(nodalShape.size), expectedDual(shape.size);
modalDomain.applySurfaceJacobianTranspose(modalParameters, traceDual, modalDual);
nodalDomain.applySurfaceJacobianTranspose(nodalParameters, traceDual, nodalDual);
basis.MultTranspose(nodalDual, expectedDual);
checkClose(modalDual, expectedDual, 3.0e-13, 2.0e-13);
mfem::Vector residual, nodalResidual, action, nodalAction;
modal.BuildResidual(residual);
nodal.BuildResidual(nodalResidual);
modal.ApplyLinearization(direction, action);
nodal.ApplyLinearization(nodalDirection, nodalAction);
checkBlocks(residual, pullResidual(modal, nodal, basis, nodalResidual), modal.GetManifest().residualBlocks());
checkBlocks(action, pullResidual(modal, nodal, basis, nodalAction), modal.GetManifest().residualBlocks());
// A surface-only perturbation must also reach the retained field rows;
// a full coupled comparison alone could overlook an inert shape block.
mfem::Vector shapeDirection(direction.Size());
shapeDirection = 0.0;
writeBlock(shapeDirection, shape, readBlock(direction, shape));
mfem::Vector shapeAction;
modal.ApplyLinearization(shapeDirection, shapeAction);
CHECK(
readBlock(shapeAction, findBlock(modal.GetManifest().residualBlocks(), "poisson_balance")).Norml2() >
1.0e-10
);
CHECK(readBlock(shapeAction, shapeRows).Norml2() > 1.0e-10);
constexpr double step = 2.0e-5;
mfem::Vector plus(state), minus(state), plusResidual, minusResidual;
plus.Add(step, direction);
minus.Add(-step, direction);
prepare(modal, plus, 2);
modal.BuildResidual(plusResidual);
prepare(modal, minus, 3);
modal.BuildResidual(minusResidual);
plusResidual -= minusResidual;
plusResidual /= 2.0 * step;
checkBlocks(plusResidual, action, modal.GetManifest().residualBlocks(), 2.0e-5, 2.0e-9);
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(modal);
const mfem::Vector fixedStateFactors(normalized.GetNormalization().StateFactors());
const mfem::Vector fixedResidualFactors(normalized.GetNormalization().ResidualFactors());
REQUIRE(fixedStateFactors.Size() == modal.StateSize());
REQUIRE(fixedResidualFactors.Size() == modal.EquationSize());
for (int i = 0; i < fixedStateFactors.Size(); ++i) {
REQUIRE(std::isfinite(fixedStateFactors(i)));
REQUIRE(fixedStateFactors(i) > 0.0);
}
for (int i = 0; i < fixedResidualFactors.Size(); ++i) {
REQUIRE(std::isfinite(fixedResidualFactors(i)));
REQUIRE(fixedResidualFactors(i) > 0.0);
}
const auto prepareNormalized = [&](const mfem::Vector &physical, const std::uint64_t revision) {
mfem::Vector coordinates;
normalized.NormalizeState(physical, coordinates);
if constexpr (GeneratedRotation) {
(void)normalized.Prepare(coordinates, dependencies(revision));
} else {
(void)normalized.Prepare(coordinates, dependencies(revision), zeroRotation());
}
REQUIRE(normalized.IsPrepared());
};
prepareNormalized(state, 4);
checkClose(normalized.GetPhysicalState(), state, 3.0e-14, 2.0e-14);
mfem::Vector normalizedDirection, normalizedAction, expectedAction;
normalized.NormalizeState(direction, normalizedDirection);
normalized.Mult(normalizedDirection, normalizedAction);
normalized.NormalizeResidual(action, expectedAction);
checkBlocks(normalizedAction, expectedAction, modal.GetManifest().residualBlocks(), 3.0e-10, 2.0e-12);
mfem::Vector normalizedResidual, expectedResidual;
normalized.BuildResidual(normalizedResidual);
normalized.NormalizeResidual(residual, expectedResidual);
checkBlocks(normalizedResidual, expectedResidual, modal.GetManifest().residualBlocks(), 3.0e-10, 2.0e-12);
// Repeated accepted-state-sized changes must not silently rotate or
// rescale persistent harmonic coordinates through a current-shape metric.
prepareNormalized(plus, 5);
prepareNormalized(state, 6);
for (int i = 0; i < fixedStateFactors.Size(); ++i)
CHECK(normalized.GetNormalization().StateFactors()(i) == fixedStateFactors(i));
for (int i = 0; i < fixedResidualFactors.Size(); ++i)
CHECK(normalized.GetNormalization().ResidualFactors()(i) == fixedResidualFactors(i));
mfem::Vector restoredResidual, restoredAction;
modal.BuildResidual(restoredResidual);
modal.ApplyLinearization(direction, restoredAction);
checkBlocks(restoredResidual, residual, modal.GetManifest().residualBlocks());
checkBlocks(restoredAction, action, modal.GetManifest().residualBlocks());
}
} // namespace spherical_harmonic_equilibrium_test
TEST_CASE(
"Native Harmonic Stellar Equilibrium Equals The Nodal Pullback Without A Central Constraint",
tags::stellar_equilibrium_problem_integration & tags::spherical_harmonic_surface
) {
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1) {
SUCCEED("The current radial domain extension and harmonic prescription require one MPI rank.");
return;
}
SECTION("Prescribed zero rotation") {
spherical_harmonic_equilibrium_test::checkCoupledEquivalence<false>();
}
SECTION("Generated rotation from nonzero target angular momentum") {
spherical_harmonic_equilibrium_test::checkCoupledEquivalence<true>();
}
}
TEST_CASE(
"Harmonic Degree And Azimuthal Cutoffs Determine Native Coupled Block Sizes",
tags::stellar_equilibrium_problem_integration & tags::spherical_harmonic_surface
) {
using namespace mean_field;
using namespace spherical_harmonic_equilibrium_test;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1) {
SUCCEED("The current radial domain extension and harmonic prescription require one MPI rank.");
return;
}
struct Case final {
int degree;
std::optional<int> order;
int count;
};
const std::array<Case, 3> cases{{{0, std::nullopt, 1}, {4, 0, 5}, {4, std::nullopt, 25}}};
for (const Case &test : cases) {
INFO("maximum degree = " << test.degree << ", expected parameters = " << test.count);
const utils::Args args = test_utils::setup_args();
fem::FEM finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
auto problem = equilibrium::discretize(
makeModel<false>(),
equilibrium::makeStellarDiscretization(
std::move(finiteElements), normalization::PhysicalRieszDiagonal{dimensions::LengthValue{1.0}},
deformation::SphericalHarmonicSurface{
{.maximumDegree = test.degree, .maximumAzimuthalOrder = test.order}
}
)
);
CHECK(findBlock(problem.GetManifest().valueBlocks(), "surface_deformation").size == test.count);
CHECK(findBlock(problem.GetManifest().residualBlocks(), "surface_shape_balance").size == test.count);
CHECK(problem.StateSize() == problem.EquationSize());
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(problem);
REQUIRE(normalized.GetNormalization().StateSize() == problem.StateSize());
REQUIRE(normalized.GetNormalization().ResidualSize() == problem.EquationSize());
for (const mfem::Vector *factors :
{&normalized.GetNormalization().StateFactors(), &normalized.GetNormalization().ResidualFactors()}) {
for (int i = 0; i < factors->Size(); ++i) {
REQUIRE(std::isfinite((*factors)(i)));
REQUIRE((*factors)(i) > 0.0);
}
}
}
}

View File

@@ -0,0 +1,182 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_preconditioner_test {
using namespace mean_field;
[[nodiscard]] operators::StellarEquilibriumDependencies dependencies() {
return {
.discretization = {.identity = 9201, .revision = 1},
.density = {.identity = 9203, .revision = 1},
.surfaceDeformation = {.identity = 9209, .revision = 1},
.gravityGradient = {.identity = 9221, .revision = 1},
.gravityPotential = {.identity = 9227, .revision = 1},
.enthalpy = {.identity = 9239, .revision = 1},
.bernoulliConstant = {.identity = 9241, .revision = 1},
.rotation = {.identity = 9257, .revision = 1},
.targetMass = {.identity = 9277, .revision = 1}
};
}
void checkClose(
const mfem::Vector &actual,
const mfem::Vector &expected
) {
REQUIRE(actual.Size() == expected.Size());
mfem::Vector difference(actual);
difference -= expected;
const double error = difference.Norml2();
const double scale = std::max(actual.Norml2(), expected.Norml2());
REQUIRE(std::isfinite(error));
REQUIRE(std::isfinite(scale));
INFO("inverse error = " << error << ", scale = " << scale);
CHECK(error <= 2.e-12 + 3.e-11 * scale);
}
// Construct the advertised triangular surrogate's RHS from a known
// correction. This checks the inverse's parameter ordering and material
// coupling, without asking an approximate inverse to invert the full J.
template <typename Prepared>
void checkTriangularInverse(
const Prepared &inverse,
const int modes
) {
const auto &couplings = inverse.GetCoupledOperator();
const auto &offsets = couplings.GetOffsets();
REQUIRE(offsets[2] - offsets[1] == modes);
REQUIRE(inverse.Width() == offsets[3]);
REQUIRE(inverse.Height() == inverse.Width());
REQUIRE(inverse.GetSurfaceDiagonal().Size() == modes);
mfem::Vector expected(inverse.Height()), rhs(inverse.Width()), actual(inverse.Height());
for (int i = 0; i < expected.Size(); ++i)
expected(i) = 0.02 * std::sin(0.31 * (i + 1));
const mfem::Vector density(expected.GetData(), offsets[1]);
const mfem::Vector shape(expected.GetData() + offsets[1], modes);
const mfem::Vector enthalpy(expected.GetData() + offsets[2], offsets[3] - offsets[2]);
mfem::Vector densityCoupling(density.Size()), enthalpyCoupling(enthalpy.Size()), cross(density.Size());
couplings.ApplySurfaceToMaterial(shape, densityCoupling, enthalpyCoupling);
couplings.ApplyEnthalpyToDensity(enthalpy, cross);
for (int i = 0; i < density.Size(); ++i)
rhs(i) = inverse.GetDensityDiagonal()(i) * density(i) + densityCoupling(i) + cross(i);
for (int i = 0; i < modes; ++i)
rhs(offsets[1] + i) = inverse.GetSurfaceDiagonal()(i) * shape(i);
for (int i = 0; i < enthalpy.Size(); ++i)
rhs(offsets[2] + i) = inverse.GetEnthalpyDiagonal()(i) * enthalpy(i) + enthalpyCoupling(i);
actual = 0.0;
inverse.Mult(rhs, actual);
checkClose(actual, expected);
const mfem::Vector actualShape(actual.GetData() + offsets[1], modes);
checkClose(actualShape, shape);
rhs = 0.0;
actual = 1.0;
inverse.Mult(rhs, actual);
CHECK(actual.Norml2() == 0.0);
CHECK(inverse.IsCurrent());
}
} // namespace spherical_harmonic_preconditioner_test
TEST_CASE(
"Harmonic Surface Mass And Calibrated Inverses Use Native Parameters And Reject Nodal H1",
"[preconditioning][material_surface][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
using namespace spherical_harmonic_preconditioner_test;
namespace backend = preconditioning::backend;
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());
const auto stellarModel = model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
// A restricted azimuthal set also rules out using (L+1)^2 as the block
// length when the actual mode catalog has fewer independent coefficients.
const deformation::SphericalHarmonicSurface prescription{{.maximumDegree = 2, .maximumAzimuthalOrder = 1}};
constexpr int modes = 7;
auto problem = equilibrium::discretize(
stellarModel,
equilibrium::makeStellarDiscretization(std::move(finiteElements), normalization::Unnormalized{}, prescription)
);
mfem::Vector state(problem.StateSize());
state = 0.0;
const auto view = problem.GetManifest().stateView(state);
view.block(utils::blocks::density_field.mass_term) = 1.0;
view.block(utils::blocks::enthalpy_field.specific_term) = 0.7;
view.block(utils::blocks::fixed_total_mass_constraint.mass_normalization_term) = 0.8;
auto shape = view.block(utils::blocks::surface_deformation_field.parameters_term);
REQUIRE(shape.Size() == modes);
for (int i = 0; i < modes; ++i)
shape(i) = 0.001 * std::sin(0.7 * (i + 1));
mfem::Vector omega(3), center(3);
omega = 0.0;
center = 0.0;
const auto preparation = problem.TryPrepare(state, dependencies(), physics::RigidRotation{omega, center});
REQUIRE(preparation.has_value());
const auto &physical = problem.GetPhysicalOperator();
REQUIRE(physical.IsPrepared());
REQUIRE_FALSE(physical.GetDomainDeformation().hasNodalRadialParameters());
const auto block = preconditioning::materialSurfaceBlock(problem);
STATIC_CHECK(
std::is_same_v<
typename std::remove_cvref_t<decltype(block)>::SurfaceSurrogate, preconditioning::SurfaceMassDiagonal>
);
auto prepared = preconditioning::prepare(problem, block);
CHECK(prepared.GetStatistics().surfaceRieszAssemblies == 1);
CHECK(prepared.GetStatistics().surfaceJacobianProbes == 0);
CHECK(prepared.GetSurfaceDiagonalQuality().minimumAbsoluteEntryBeforeRegularization > 0.0);
checkTriangularInverse(prepared, modes);
const auto unchanged = prepared.Refresh(physical);
CHECK_FALSE(unchanged.DidAnyWork());
const auto calibratedBlock = preconditioning::materialSurfaceBlock(
problem, backend::Diagonal{}, backend::Diagonal{}, preconditioning::SurfaceThenMaterialTriangular{},
{.surfaceCalibration = {
.target = preconditioning::SurfaceRieszCalibrationTarget::surface_jacobian,
.probeCount = 3,
.objective = preconditioning::SurfaceRieszCalibrationObjective::operator_action
}}
);
auto calibrated = preconditioning::prepare(problem, calibratedBlock);
const auto &calibration = calibrated.GetSurfaceCalibration();
REQUIRE(calibration.WasCalibrated());
REQUIRE(std::isfinite(calibration.scale));
REQUIRE(calibration.scale != 0.0);
CHECK(calibration.probeCount == 3);
CHECK(calibrated.GetStatistics().surfaceJacobianProbes == 3);
for (int i = 0; i < modes; ++i)
CHECK(
calibrated.GetSurfaceDiagonal()(i) ==
Catch::Approx(calibration.scale * prepared.GetSurfaceDiagonal()(i)).epsilon(2.e-13)
);
checkTriangularInverse(calibrated, modes);
using FixedAMG = backend::HypreBoomerAMG<backend::FixedCycles>;
const auto h1 = preconditioning::materialSurfaceBlock(
problem, backend::Diagonal{}, FixedAMG{backend::FixedCycles{.cycles = 1}},
preconditioning::ApproximateMaterialSurfaceLDU{}, preconditioning::SurfaceH1MassStiffness{}
);
CHECK_THROWS_WITH(
preconditioning::prepare(problem, h1), Catch::Matchers::ContainsSubstring("requires nodal radial parameters")
);
CHECK(prepared.IsCurrent());
CHECK(calibrated.IsCurrent());
}

View File

@@ -347,6 +347,14 @@ export namespace tags {
nodal_radial_surface & integration & accuracy & make_tag("analytic_comparison");
inline constexpr auto nodal_radial_surface_linearization =
nodal_radial_surface & integration & make_tag("jacobian") & make_tag("adjoint");
inline constexpr auto spherical_harmonic_surface =
surface & geometry & make_tag("surface_deformation") & make_tag("spherical_harmonic");
inline constexpr auto spherical_harmonic_surface_validation =
spherical_harmonic_surface & unit & make_tag("validation");
inline constexpr auto spherical_harmonic_surface_analytic =
spherical_harmonic_surface & integration & accuracy & make_tag("analytic_comparison");
inline constexpr auto spherical_harmonic_surface_linearization =
spherical_harmonic_surface & integration & make_tag("jacobian") & make_tag("adjoint");
inline constexpr auto radial_deformation_extension = deformation & mapping & make_tag("radial_extension");
inline constexpr auto radial_deformation_extension_validation =
radial_deformation_extension & unit & make_tag("validation");