Files
MeanField_SERiF/tests/normalization/dimensionless.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

249 lines
16 KiB
C++

#include <array>
#include <cmath>
#include <limits>
#include <span>
#include <tuple>
#include <utility>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/gridfunc.hpp"
#include "mfem/fem/linearform.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp"
#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp"
#include "serif/backend/mfem/solver/normalization/metric_computations.hpp"
#include "serif/solver/normalization/normalization.hpp"
#include "serif/tests/deformation/mesh.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace {
namespace normalization = serif::solver::normalization;
namespace dimensions = serif::dimensions;
namespace dependencies = serif::utils::dependencies;
namespace discretization = serif::discretization;
namespace blocks = discretization::blocks;
namespace deformation = serif::geometry::deformation;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
using Kind = blocks::RepresentedValueKind;
/** @brief Represent the same reference star after changing numerical mass, length, and time units. */
[[nodiscard]] normalization::StellarReferenceScales reference_scales(double length, double mass, double time) {
const auto result = normalization::prepare_stellar_reference_scales(
dimensions::QuantityValue<dimensions::Mass>{2.0 * mass},
dimensions::QuantityValue<dimensions::Length>{3.0 * length},
dimensions::QuantityValue<dimensions::GravitationalConstant>{0.7 * length * length * length / (mass * time * time)});
REQUIRE(result);
return *result;
}
/** @brief Exercise the public dependency computations with independent physical state and residual quantities. */
template <typename Value, typename Residual, typename ValueQuantity, typename ResidualQuantity, typename Metric>
[[nodiscard]] normalization::DiagonalNormalization prepare_factors(const std::vector<double>& metric, const normalization::StellarReferenceScales& scales) {
using Layout = normalization::CoordinateLayout<dependencies::KeyList<Value>, dependencies::KeyList<Residual>>;
using Plan = normalization::NormalizationPlan<
dependencies::Bindings<dependencies::Binding<Value, normalization::DimensionlessRieszCoordinate<ValueQuantity, Metric>>>,
dependencies::Bindings<dependencies::Binding<Residual, normalization::DimensionlessRieszCoordinate<ResidualQuantity, Metric>>>>;
const Layout layout{.state_sizes = {metric.size()}, .residual_sizes = {metric.size()}};
const auto inputs = dependencies::make_dependency_context(
dependencies::borrow<normalization::keys::Layout>(layout),
dependencies::borrow<normalization::keys::Scales>(scales), dependencies::borrow<Metric>(metric));
auto graph = std::apply([](auto... nodes) {
return dependencies::make_computation_graph(std::move(nodes)...);
}, normalization::make_normalization_computations<Plan>(layout));
auto cache = dependencies::make_computation_cache<std::string>(std::move(graph), inputs);
REQUIRE(cache.try_prepare(inputs));
return cache.template get<normalization::keys::Normalization>();
}
/** @brief Keep dimensionless vectors and their measured norms together for cross-unit comparisons. */
struct MeasuredCoordinates {
std::vector<double> state;
std::vector<double> residual;
double state_norm_squared;
double residual_norm_squared;
};
/** @brief Compare actual coordinates, because equal norms alone could hide different block weights or permutations. */
void check_same_coordinates(const MeasuredCoordinates& first, const MeasuredCoordinates& second) {
REQUIRE(first.state.size() == second.state.size());
REQUIRE(first.residual.size() == second.residual.size());
for (std::size_t index = 0; index < first.state.size(); ++index) {
CHECK(second.state[index] == Catch::Approx(first.state[index]).epsilon(2.0e-11).margin(1.0e-13));
CHECK(second.residual[index] == Catch::Approx(first.residual[index]).epsilon(2.0e-11).margin(1.0e-13));
}
CHECK(second.state_norm_squared == Catch::Approx(first.state_norm_squared).epsilon(2.0e-11));
CHECK(second.residual_norm_squared == Catch::Approx(first.residual_norm_squared).epsilon(2.0e-11));
}
/** @brief Use real MFEM projection and weak integration of scalar fields. */
template <typename Term>
[[nodiscard]] MeasuredCoordinates volume_coordinates(mfem::FiniteElementCollection& collection, double length, double mass, double time) {
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0 * length, 1.5 * length, 0.75 * length);
for (int element = 0; element < mesh.GetNE(); ++element) {
mesh.GetElement(element)->SetAttribute(2);
}
mesh.SetAttributes();
mfem::FiniteElementSpace space(&mesh, &collection);
const discretization::backend::mfem::TermBinding<Term, Schema> binding{space};
const auto metric = discretization::backend::mfem::reference_mass_diagonal(binding);
REQUIRE(metric);
const normalization::StellarReferenceScales scales = reference_scales(length, mass, time);
const double physical_scale = normalization::characteristic_scale(scales, typename Term::Quantity{}).value();
mfem::GridFunction field(&space);
mfem::LinearForm residual(&space);
mfem::ConstantCoefficient scalar(0.4 * physical_scale);
field.ProjectCoefficient(scalar);
residual.AddDomainIntegrator(new mfem::DomainLFIntegrator(scalar));
residual.Assemble();
const mfem::Vector state_coefficients = binding.gather(field);
const mfem::Vector residual_coefficients = binding.gather(residual);
const std::span<const double> state{state_coefficients.GetData(), static_cast<std::size_t>(state_coefficients.Size())};
const std::span<const double> weak{residual_coefficients.GetData(), static_cast<std::size_t>(residual_coefficients.Size())};
const auto factors = prepare_factors<Term, Term, typename Term::Quantity, typename Term::Quantity, normalization::ReferenceVolumeMetric<Term>>(*metric, scales);
MeasuredCoordinates result{.state = std::vector<double>(state.size()), .residual = std::vector<double>(weak.size()),
.state_norm_squared = factors.state_norm_squared(state), .residual_norm_squared = factors.residual_norm_squared(weak)};
factors.normalize_state(state, result.state);
factors.normalize_residual(weak, result.residual);
return result;
}
/** @brief Supply an exact cellwise norm independent of a diagonal approximation to a higher-order Gram matrix. */
struct ConstantDensityField final : blocks::field {
struct Values final : blocks::term<ConstantDensityField> {
using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace<discretization::functional_space::L2, 0, 0>;
using Support = discretization::domain::StellarDomains;
using Quantity = dimensions::MassDensity;
};
};
/** @brief Build each native surface metric on the same physical sphere expressed in different length units. */
template <typename Surface>
[[nodiscard]] MeasuredCoordinates surface_coordinates(const Surface& surface, double length, double mass, double time) {
serif::tests::deformation::PairedMesh mesh(0, 0.0, 2);
mfem::VectorFunctionCoefficient dilation(3, [=](const mfem::Vector& point, mfem::Vector& image) {
image = point;
image *= length;
});
mesh.generated.mesh->Transform(dilation);
const auto reference = deformation::backend::mfem::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(surface, reference->positions);
REQUIRE(embedding);
const auto metric = deformation::backend::mfem::surface_parameter_mass_diagonal(mesh.scalar_space, mesh.surface_attribute, *reference, *embedding);
REQUIRE(metric);
const normalization::StellarReferenceScales scales = reference_scales(length, mass, time);
const auto factors = prepare_factors<blocks::surface_deformation::coefficients, blocks::surface_deformation::shape_equilibrium,
dimensions::Length, dimensions::Force, deformation::keys::SurfaceMetric>(*metric, scales);
std::vector<double> state(metric->size()), residual(metric->size());
double expected_state_norm = 0.0;
double expected_residual_norm = 0.0;
double expected_work = 0.0;
for (std::size_t index = 0; index < metric->size(); ++index) {
const double shape = std::sin(0.7 * (index + 1));
const double force = std::cos(0.3 * (index + 1));
state[index] = shape * scales.radius.value();
residual[index] = force * scales.force.value();
const double area_fraction = (*metric)[index] / std::pow(scales.radius.value(), 2);
expected_state_norm += area_fraction * shape * shape;
expected_residual_norm += force * force / area_fraction;
expected_work += shape * force;
}
MeasuredCoordinates result{.state = std::vector<double>(metric->size()), .residual = std::vector<double>(metric->size()),
.state_norm_squared = factors.state_norm_squared(state), .residual_norm_squared = factors.residual_norm_squared(residual)};
factors.normalize_state(state, result.state);
factors.normalize_residual(residual, result.residual);
CHECK(result.state_norm_squared == Catch::Approx(expected_state_norm).epsilon(2.0e-13));
CHECK(result.residual_norm_squared == Catch::Approx(expected_residual_norm).epsilon(2.0e-13));
double work = 0.0;
for (std::size_t index = 0; index < metric->size(); ++index) {
work += result.state[index] * result.residual[index];
}
CHECK(work == Catch::Approx(expected_work).epsilon(2.0e-12));
return result;
}
}
TEST_CASE("Dimensionless volume coordinates are independent of mass length and time units", "[normalization][dimensionless][integration]") {
mfem::H1_FECollection enthalpy(3, 3);
mfem::H1_FECollection density(blocks::density::mass::FunctionalSpace::order, 3);
const auto check = []<typename Term>(mfem::FiniteElementCollection& collection) {
const MeasuredCoordinates baseline = volume_coordinates<Term>(collection, 1.0, 1.0, 1.0);
for (const std::array<double, 3> conversion : {std::array{100.0, 1000.0, 1.0}, std::array{0.03, 7.0, 0.2}}) {
check_same_coordinates(baseline, volume_coordinates<Term>(collection, conversion[0], conversion[1], conversion[2]));
}
};
check.template operator()<::serif::tests::equations::Enthalpy>(enthalpy);
check.template operator()<blocks::density::mass>(density);
}
TEST_CASE("Dimensionless cellwise state and weak residual norms recover the volume integral", "[normalization][dimensionless][math]") {
mfem::L2_FECollection collection(0, 3);
for (const double length : {1.0, 100.0}) {
const MeasuredCoordinates result = volume_coordinates<ConstantDensityField::Values>(collection, length, 1000.0, 0.2);
const double integral = 0.4 * 0.4 * (2.0 * 1.5 * 0.75) / std::pow(3.0, 3);
CHECK(result.state_norm_squared == Catch::Approx(integral).epsilon(2.0e-13));
CHECK(result.residual_norm_squared == Catch::Approx(integral).epsilon(2.0e-13));
}
}
TEST_CASE("Dimensionless harmonic and nodal shape norms retain force work across unit changes", "[normalization][dimensionless][integration]") {
const auto check = []<typename Surface>(const Surface& surface) {
const MeasuredCoordinates baseline = surface_coordinates(surface, 1.0, 1.0, 1.0);
check_same_coordinates(baseline, surface_coordinates(surface, 100.0, 1000.0, 0.2));
};
check(deformation::SphericalHarmonicSurface{.maximum_degree = 2});
check(deformation::NodalRadialSurface{});
}
TEST_CASE("Dimensionless point replacements retain their own pressure scale", "[normalization][dimensionless][math]") {
using Enthalpy = ::serif::tests::equations::Enthalpy;
using Metric = normalization::ReferenceVolumeMetric<Enthalpy>;
struct Selection {};
using Layout = normalization::CoordinateLayout<dependencies::KeyList<>, dependencies::KeyList<Enthalpy>>;
using Method = normalization::WithPointRows<normalization::DimensionlessRieszCoordinate<dimensions::SpecificEnthalpy, Metric>, dimensions::Pressure, Selection>;
using Plan = normalization::NormalizationPlan<dependencies::Bindings<>, dependencies::Bindings<dependencies::Binding<Enthalpy, Method>>>;
const Layout layout{.state_sizes = {}, .residual_sizes = {2}};
const std::vector<int> rows{1};
for (const double length : {1.0, 100.0}) {
const auto scales = reference_scales(length, 1000.0, 0.2);
const std::vector<double> metric{4.0 * length * length * length, std::numeric_limits<double>::quiet_NaN()};
const auto inputs = dependencies::make_dependency_context(dependencies::borrow<normalization::keys::Layout>(layout),
dependencies::borrow<normalization::keys::Scales>(scales), dependencies::borrow<Metric>(metric), dependencies::borrow<Selection>(rows));
auto graph = std::apply([](auto... nodes) {
return dependencies::make_computation_graph(std::move(nodes)...);
}, normalization::make_normalization_computations<Plan>(layout));
auto cache = dependencies::make_computation_cache<std::string>(std::move(graph), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto& factors = cache.get<normalization::keys::Normalization>();
std::array<double, 2> residual{scales.specific_energy.value() * 8.0 * std::pow(length, 3), scales.pressure.value() * 0.7};
std::array<double, 2> normalized;
factors.normalize_residual(residual, normalized);
CHECK(normalized[0] == Catch::Approx(4.0 / std::sqrt(27.0)).epsilon(2.0e-13));
CHECK(normalized[1] == Catch::Approx(0.7).epsilon(2.0e-13));
}
}
TEST_CASE("Dimensionless geometry preparation reports invalid or unrepresentable scales", "[normalization][dimensionless][validation]") {
for (const double invalid : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, normalization::ReferenceGeometry::volume, 1.0, invalid));
CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::residual, normalization::ReferenceGeometry::volume, invalid, 1.0));
}
CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, normalization::ReferenceGeometry::volume, 1.0e300, 1.0e100));
CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, static_cast<normalization::ReferenceGeometry>(99), 1.0, 1.0));
CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(static_cast<Kind>(99), normalization::ReferenceGeometry::volume, 1.0, 1.0));
}
TEST_CASE("Dimensionless global coordinates retain independent unknown and constraint scales", "[normalization][dimensionless][math]") {
using MassConstant = blocks::barotropic_constant::mass_normalization;
for (const std::array<double, 3> conversion : {std::array{1.0, 1.0, 1.0}, std::array{100.0, 1000.0, 0.2}}) {
const auto scales = reference_scales(conversion[0], conversion[1], conversion[2]);
const auto factors = prepare_factors<MassConstant, MassConstant, dimensions::GravitationalPotential, dimensions::Mass, normalization::UnitMetric>({1.0}, scales);
const std::array<double, 1> state{0.3 * scales.specific_energy.value()}, residual{0.7 * scales.mass.value()};
CHECK(factors.state_norm_squared(state) == Catch::Approx(0.09).epsilon(2.0e-13));
CHECK(factors.residual_norm_squared(residual) == Catch::Approx(0.49).epsilon(2.0e-13));
}
}