Files
MeanField_SERiF/tests/equations/angular_momentum.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

428 lines
23 KiB
C++

#include <cmath>
#include <limits>
#include <catch2/catch_approx.hpp>
#include "serif/equations/angular_momentum.hpp"
#include "serif/equations/barotropic_density.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/tests/equations/gravity_port.hpp"
namespace {
using namespace serif;
using namespace serif::tests::equations;
using namespace serif::tests::equations::gravity;
using Equation = serif::equations::AngularMomentumConstraint;
using Prepared = VolumeEquationFixture<Equation, Schema>;
using Vector = ::mfem::Vector;
using Axis = serif::equations::RigidRotation::Vector;
struct Values {
Vector density, omega, displacement;
};
auto state(GravityFixture& f, const Values& values) {
return backend::make_term_coefficients(backend::bind(f.density_binding, values.density), backend::bind(f.angular_velocity_binding, values.omega));
}
auto make_operator(GravityFixture& f, double target, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}) {
return Prepared{Equation{target, axis, center}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
}
template <typename OperatorT> double residual(GravityFixture& f, OperatorT& op) {
Vector result;
op.residual(f.workspace, result);
REQUIRE(result.Size() == 1);
return result[0];
}
template <typename OperatorT> double residual(GravityFixture& f, OperatorT& op, const Values& values) {
op.prepare(state(f, values), values.displacement, f.workspace);
return residual(f, op);
}
template <typename OperatorT> double action(GravityFixture& f, OperatorT& op, const Values& direction) {
Vector result;
op.apply_jacobian(state(f, direction), direction.displacement, f.workspace, result);
REQUIRE(result.Size() == 1);
return result[0];
}
Values zeros(const GravityFixture& f) {
return {constant(f.density_binding.size(), 0.0), constant(1, 0.0), constant(f.displacement_binding.size(), 0.0)};
}
// MeanField tests/operators/prepared_angular_momentum.cpp: preserve its projected profiles.
Vector density(GravityFixture& f, double phase, bool direction = false) {
return f.project(f.density_binding, [=](const Vector& x) {
if (direction) { return 0.17 * std::sin(0.83 * x[0] + phase) - 0.12 * std::cos(0.79 * x[1] - phase) + 0.06 * x[2]; }
return 0.94 + 0.08 * std::sin(0.71 * x[0] + phase) + 0.05 * std::cos(0.63 * x[1] - phase) + 0.03 * x[2] * x[2];
});
}
Vector displacement(GravityFixture& f, double scale) {
return gravity_port::project_vector(f.displacement_binding, [=](const Vector& x, Vector& value) {
value.SetSize(3);
value[0] = scale * (0.07 * x[0] + 0.018 * x[1] * x[2]);
value[1] = scale * (-0.05 * x[1] + 0.013 * x[0] * x[2]);
value[2] = scale * (0.04 * x[2] - 0.011 * x[0] * x[1]);
});
}
double relative(double actual, double expected) {
return std::abs(actual - expected) / std::max({std::abs(actual), std::abs(expected), 100.0 * std::numeric_limits<double>::epsilon()});
}
Values shifted(Values values, const Values& direction, double step) {
values.density.Add(step, direction.density);
values.omega.Add(step, direction.omega);
values.displacement.Add(step, direction.displacement);
return values;
}
struct Integrals {
double moment = 0.0, mass = 0.0, action = 0.0;
Axis first_moment{};
};
// Direct MFEM quadrature, independent of the equation and operator evaluators.
// delta(I) = integral [delta(rho) s^2 + 2 rho r_perp . delta(x) + rho s^2 trace(A^-1 delta(A))] dV.
Integrals reference(GravityFixture& f, const Values& values, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}, const Values* direction = nullptr) {
const double norm = std::hypot(axis[0], axis[1], axis[2]);
for (double& component : axis) { component /= norm; }
::mfem::GridFunction rho(&f.density_space), u(&f.displacement_space), drho(&f.density_space), du(&f.displacement_space);
rho = f.density_binding.scatter(values.density);
u = f.displacement_binding.scatter(values.displacement);
if (direction) {
drho = f.density_binding.scatter(direction->density);
du = f.displacement_binding.scatter(direction->displacement);
}
Integrals result;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (!f.density_binding.supports(element)) { continue; }
auto& transform = *f.mesh.GetElementTransformation(element);
const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order);
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto& ip = rule.IntPoint(point);
transform.SetIntPoint(&ip);
Vector x(3), displacement_value(3), dx(3);
transform.Transform(ip, x);
u.GetVectorValue(transform, ip, displacement_value);
x += displacement_value;
::mfem::DenseMatrix A(3), dA(3), inverse(3);
u.GetVectorGradient(transform, A);
for (int i = 0; i < 3; ++i) { A(i, i) += 1.0; }
double axial_distance = 0.0;
for (int i = 0; i < 3; ++i) { axial_distance += axis[i] * (x[i] - center[i]); }
Axis perpendicular{};
double radius_squared = 0.0;
for (int i = 0; i < 3; ++i) {
perpendicular[i] = x[i] - center[i] - axis[i] * axial_distance;
radius_squared += perpendicular[i] * perpendicular[i];
}
const double weight = ip.weight * transform.Weight() * A.Det();
const double density_value = rho.GetValue(transform, ip);
result.mass += weight * density_value;
result.moment += weight * density_value * radius_squared;
for (int i = 0; i < 3; ++i) { result.first_moment[i] += weight * density_value * x[i]; }
if (direction) {
du.GetVectorValue(transform, ip, dx);
du.GetVectorGradient(transform, dA);
::mfem::CalcInverse(A, inverse);
double trace = 0.0, radius_variation = 0.0;
for (int i = 0; i < 3; ++i) {
radius_variation += 2.0 * perpendicular[i] * dx[i];
for (int j = 0; j < 3; ++j) { trace += inverse(i, j) * dA(j, i); }
}
result.action += weight * (direction->omega[0] * density_value * radius_squared + values.omega[0] *
(drho.GetValue(transform, ip) * radius_squared + density_value * (radius_variation + radius_squared * trace)));
}
}
}
return result;
}
double moment(GravityFixture& f, Prepared& op) {
auto direction = zeros(f);
direction.omega = 1.0;
return action(f, op, direction);
}
void check_columns(GravityFixture& f, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}) {
CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering(), axis, center);
const Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)};
const Values direction{density(f, 0.67, true), constant(1, -0.37), displacement(f, -0.79)};
auto op = make_operator(f, 0.81, axis, center);
(void)residual(f, op, values);
const double complete = action(f, op, direction);
CHECK(relative(complete, reference(f, values, axis, center, &direction).action) < 2.0e-11);
auto density_direction = zeros(f), geometry_direction = zeros(f), omega_direction = zeros(f);
density_direction.density = direction.density;
geometry_direction.displacement = direction.displacement;
omega_direction.omega = direction.omega;
const double density_action = action(f, op, density_direction), geometry_action = action(f, op, geometry_direction), omega_action = action(f, op, omega_direction);
CHECK(relative(complete, density_action + geometry_action + omega_action) < 3.0e-15);
CHECK(omega_action == Catch::Approx(moment(f, op) * direction.omega[0]).epsilon(2.0e-15));
backend::JacobianOperator adapter(op.cache());
CHECK(adapter.Height() == 1);
CHECK(adapter.Width() == direction.density.Size() + 1 + direction.displacement.Size());
Vector result;
adapter.Mult(packed(direction.density, direction.omega, direction.displacement), result);
CHECK(result[0] == complete);
// Preserve all three original difference steps and thresholds.
const std::array<Values, 3> columns{density_direction, geometry_direction, omega_direction};
const std::array<double, 3> actions{density_action, geometry_action, omega_action};
const std::array<double, 3> steps{1.0e-3, 1.0e-6, 1.0e-6}, tolerances{4.0e-8, 4.0e-7, 2.0e-10};
for (int column = 0; column < 3; ++column) {
const double plus = residual(f, op, shifted(values, columns[column], steps[column]));
const double minus = residual(f, op, shifted(values, columns[column], -steps[column]));
const double difference = (plus - minus) / (2.0 * steps[column]);
CAPTURE(column, actions[column], difference);
CHECK(relative(actions[column], difference) < tolerances[column]);
}
}
}
TEST_CASE("Angular momentum retains moment scaling and the parallel axis theorem", "[equations][angular_momentum][integration]") {
STATIC_CHECK_FALSE(std::is_copy_constructible_v<Prepared::Cache>);
STATIC_CHECK(std::is_move_constructible_v<Prepared::Cache>);
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
auto values = zeros(f);
values.density = 1.37;
values.omega = 0.73;
auto op = make_operator(f, 0.41, {0.0, 0.0, 4.0});
const double initial = residual(f, op, values), origin_moment = moment(f, op);
const auto independent = reference(f, values);
CHECK(relative(origin_moment, independent.moment) < 2.0e-13);
CHECK(initial + 0.41 == Catch::Approx(0.73 * origin_moment).epsilon(2.0e-15));
CHECK(initial == Catch::Approx(0.73 * origin_moment - 0.41).epsilon(2.0e-15));
values.displacement = gravity_port::affine_displacement(f, 0.086);
(void)residual(f, op, values);
CHECK(relative(moment(f, op) / independent.moment, std::pow(1.0 + 0.086, 5)) < 7.0e-7);
values.displacement = 0.0;
const Axis center{0.27, -0.19, 0.31};
auto shifted_op = make_operator(f, 0.41, {0.0, 0.0, 1.0}, center);
(void)residual(f, shifted_op, values);
const double expected = origin_moment + independent.mass * (center[0] * center[0] + center[1] * center[1]) -
2.0 * (center[0] * independent.first_moment[0] + center[1] * independent.first_moment[1]);
CHECK(relative(moment(f, shifted_op), expected) < 3.0e-13);
}
TEST_CASE("Angular momentum retains density geometry and angular velocity action accuracy", "[equations][angular_momentum][integration]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture f(geometry, ordering);
f.set_compactification();
check_columns(f);
check_columns(f, {0.17, -0.12, 0.43}, {0.031, -0.024, 0.018});
}
}
auto fixture = gravity_port::stellar_fixture();
check_columns(*fixture);
check_columns(*fixture, {0.17, -0.12, 0.43}, {0.031, -0.024, 0.018});
}
TEST_CASE("Angular momentum snapshots and refreshed states preserve the old lifecycle numerics", "[equations][angular_momentum][integration]") {
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
Values values{density(f, 0.29), constant(1, 0.52), displacement(f, 0.41)};
const Values direction{density(f, 0.67, true), constant(1, -0.19), displacement(f, -0.53)};
auto op = make_operator(f, 0.71);
const double initial = residual(f, op, values), initial_moment = moment(f, op);
auto check_action = [&] { CHECK(relative(action(f, op, direction), reference(f, values, {0.0, 0.0, 1.0}, {}, &direction).action) < 2.0e-11); };
check_action();
CHECK(residual(f, op, values) == initial);
check_action();
auto potential = f.potential();
potential *= 1.7;
op.prepare(backend::make_term_coefficients(backend::bind(f.density_binding, values.density), backend::bind(f.angular_velocity_binding, values.omega), backend::bind(f.potential_binding, potential)), values.displacement, f.workspace);
CHECK(residual(f, op) == initial);
check_action();
values.omega = 0.81;
CHECK(residual(f, op) == initial);
CHECK(residual(f, op, values) - initial == Catch::Approx((0.81 - 0.52) * initial_moment).epsilon(3.0e-15));
CHECK(moment(f, op) == initial_moment);
check_action();
const double before_density = residual(f, op);
values.density = density(f, 0.83);
CHECK(residual(f, op) == before_density);
(void)residual(f, op, values);
check_action();
const double before_geometry = residual(f, op);
values.displacement = displacement(f, 0.87);
CHECK(residual(f, op) == before_geometry);
(void)residual(f, op, values);
check_action();
}
TEST_CASE("Angular momentum rejects inadmissible moments and angular speeds then recovers", "[equations][angular_momentum][unit]") {
GravityFixture f;
auto values = zeros(f);
values.density = 0.91;
values.omega = 0.52;
auto op = make_operator(f, 0.71);
const double initial = residual(f, op, values);
for (const double omega : {0.52, 0.0, -0.52}) {
values.omega = omega;
values.density = -0.91;
const auto count = op.preparation_count();
const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION);
CHECK(op.preparation_count() == count);
CHECK_FALSE(op.is_prepared());
CHECK_THROWS_AS(op.prepare(state(f, values), values.displacement, f.workspace), std::domain_error);
values.density = 0.91;
values.omega = 0.52;
CHECK(residual(f, op, values) == initial);
}
for (double bad : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
values.omega = bad;
const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE);
CHECK_FALSE(op.is_prepared());
CHECK_THROWS_AS(op.prepare(state(f, values), values.displacement, f.workspace), std::domain_error);
values.omega = 0.52;
CHECK(residual(f, op, values) == initial);
}
// Local negative density is allowed when the total moment remains nonnegative.
values.density = f.project(f.density_binding, [](const Vector& x) { return x[0] - 0.25; });
REQUIRE(reference(f, values).moment > 0.0);
CHECK(relative(residual(f, op, values), 0.52 * reference(f, values).moment - 0.71) < 2.0e-13);
}
TEST_CASE("Angular momentum target axis and center retain the old validity requirements", "[equations][angular_momentum][unit]") {
for (double target : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS(Equation{target}, std::invalid_argument);
}
CHECK_NOTHROW(Equation{0.0});
CHECK_THROWS_AS((Equation{1.0, {0.0, 0.0, 0.0}}), std::invalid_argument);
CHECK_THROWS_AS((Equation{1.0, {0.0, 0.0, 1.0}, {0.0, std::numeric_limits<double>::quiet_NaN(), 0.0}}), std::invalid_argument);
}
TEST_CASE("Angular momentum rejects overflow in integrated moments and residuals", "[equations][angular_momentum][unit]") {
// A long cylinder-like box keeps point values finite while their volume integral overflows.
auto mesh = make_mesh(::mfem::Element::HEXAHEDRON);
for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) { mesh.GetVertex(vertex)[2] *= 100.0; }
GravityFixture f(std::move(mesh));
auto values = zeros(f);
const double maximum = std::numeric_limits<double>::max();
for (const double target : {0.0, maximum}) {
auto op = make_operator(f, target);
values.density = 1.0;
values.omega = target == 0.0 ? 0.1 * maximum : -0.01 * maximum;
const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE);
CHECK_FALSE(op.is_prepared());
}
auto op = make_operator(f, 0.0);
values.density = 0.1 * maximum;
values.omega = 0.0;
const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION);
CHECK_FALSE(op.is_prepared());
values.density = 1.0;
CHECK(residual(f, op, values) == 0.0);
}
TEST_CASE("Angular momentum handles zero speed signed speed and default quadrature", "[equations][angular_momentum][integration]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
GravityFixture f(geometry);
Values values{density(f, 0.31), constant(1, 0.0), displacement(f, 0.43)};
Prepared op{Equation{0.41}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper};
CHECK(residual(f, op, values) == -0.41);
CHECK(relative(moment(f, op), reference(f, values).moment) < 2.0e-13);
const Values direction{density(f, 0.67, true), constant(1, 0.0), displacement(f, -0.79)};
CHECK(action(f, op, direction) == 0.0);
values.omega = -0.63;
CHECK(relative(residual(f, op, values), -0.63 * reference(f, values).moment - 0.41) < 2.0e-13);
values.density = 0.0;
CHECK(residual(f, op, values) == -0.41);
CHECK(moment(f, op) == 0.0);
}
}
TEST_CASE("Angular momentum preserves physical admissibility", "[equations][angular_momentum][unit]") {
GravityFixture f;
Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)};
VolumeEquationFixture op{Equation{0.41}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
CHECK(relative(residual(f, op, values), 0.63 * reference(f, values).moment - 0.41) < 2.0e-13);
const Values direction{density(f, 0.67, true), constant(1, -0.37), displacement(f, -0.79)};
CHECK(relative(action(f, op, direction), reference(f, values, {0.0, 0.0, 1.0}, {}, &direction).action) < 2.0e-11);
values.density = -0.91;
const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION);
CHECK_FALSE(op.is_prepared());
}
TEST_CASE("One angular speed drives all supported equilibrium rows consistently", "[equations][angular_momentum][integration]") {
// Port the physical columns of MeanField stellar_equilibrium_system.cpp:253.
// Root manifests, surface projection, and Lane-Emden seed generation are separate infrastructure.
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)};
auto direction = zeros(f);
direction.omega = -0.37;
const auto h = f.enthalpy(), phi = f.potential(), c = constant(1, 0.39);
const auto zero_h = constant(h.Size(), 0.0), zero_phi = constant(phi.Size(), 0.0), zero_c = constant(1, 0.0);
auto bind_all = [&](const Values& v, const Vector& enthalpy, const Vector& potential, const Vector& bernoulli) {
return backend::make_term_coefficients(backend::bind(f.density_binding, v.density), backend::bind(f.angular_velocity_binding, v.omega),
backend::bind(f.enthalpy_binding, enthalpy), backend::bind(f.potential_binding, potential),
backend::bind(f.constant_binding, bernoulli));
};
const auto base = bind_all(values, h, phi, c), delta = bind_all(direction, zero_h, zero_phi, zero_c);
auto make_row = [&](auto equation, const auto& binding) {
return VolumeEquationFixture{equation, binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
};
const Axis axis{0.0, 0.0, 3.0};
const eos::models::Polytrope eos{1.0, 0.25};
auto momentum = make_row(Equation{0.1, axis}, f.angular_velocity_binding);
auto density_balance = make_row(serif::equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.0}, axis}, f.density_binding);
auto mass = make_row(serif::equations::MassNormalization{1.0}, f.constant_binding);
auto closure = make_row(::serif::tests::equations::BarotropicClosure{eos}, f.density_binding);
auto poisson = make_row(serif::equations::PrimalPoissonBalance{four_pi_G}, f.potential_binding);
auto check_rotating_row = [&](auto& row, double tolerance, bool zero_at_rest) {
for (double omega : {0.63, 0.0}) {
values.omega = omega;
row.prepare(base, values.displacement, f.workspace);
Vector actual, plus, minus;
row.apply_jacobian(delta, direction.displacement, f.workspace, actual);
if (omega == 0.0 && zero_at_rest) { CHECK(actual.Norml2() == 0.0); }
else { CHECK(actual.Norml2() > 0.0); }
constexpr double step = 1.0e-5;
values.omega = omega + step * direction.omega[0];
row.prepare(base, values.displacement, f.workspace);
row.residual(f.workspace, plus);
values.omega = omega - step * direction.omega[0];
row.prepare(base, values.displacement, f.workspace);
row.residual(f.workspace, minus);
plus -= minus;
plus /= 2.0 * step;
const double scale = std::max({1.0, actual.Norml2(), plus.Norml2()});
plus -= actual;
CHECK(plus.Norml2() / scale < tolerance);
}
};
check_rotating_row(momentum, 3.0e-10, false);
check_rotating_row(density_balance, 3.0e-7, true);
values.omega = 0.63;
auto check_independent_row = [&](auto& row) {
row.prepare(base, values.displacement, f.workspace);
Vector actual;
row.apply_jacobian(delta, direction.displacement, f.workspace, actual);
CHECK(actual.Norml2() == 0.0);
};
check_independent_row(mass);
check_independent_row(closure);
check_independent_row(poisson);
}