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.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

@@ -0,0 +1,556 @@
// The nonlinear surface and bilinear extension retain MeanField's independent chain-rule fixture.
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <numeric>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/geometry/deformation/composition.hpp"
#include "serif/geometry/deformation/linearization.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace spaces = deformation::spaces;
using Catch::Matchers::WithinAbs;
/** @brief Reject fixture misuse as an error value so failure recovery exercises the production composition. */
[[nodiscard]] deformation::DeformationStatus validate_output(const std::span<double> output, const std::size_t expected_size)
{
if (output.size() != expected_size)
{
return std::unexpected(deformation::DeformationFailure{.reason = deformation::DeformationFailureReason::invalid_size, .message = "The analytic deformation fixture received an incompatible output."});
}
return {};
}
/** @brief Provide independently known quadratic maps with two inputs and a variable number of outputs. */
template <typename Input, typename Output, std::size_t OutputSize>
struct QuadraticMap
{
using InputSpace = Input;
using OutputSpace = Output;
static constexpr bool is_linear = false;
struct Workspace
{
std::size_t actions{0};
};
// Each row defines a*x + b*y + c*x*x + d*x*y + e*y*y.
std::array<std::array<double, 5>, OutputSize> coefficients;
/** @brief Keep the analytic parameter space small enough to inspect by hand. */
[[nodiscard]] std::size_t input_size() const noexcept
{
return 2;
}
/** @brief Allow the extension to have more outputs than surface coordinates. */
[[nodiscard]] std::size_t output_size() const noexcept
{
return OutputSize;
}
/** @brief Make caller-owned action state visible in recovery tests. */
[[nodiscard]] Workspace make_workspace() const
{
return {};
}
/** @brief Evaluate the independent quadratic definition, including both pure and mixed terms. */
[[nodiscard]] deformation::DeformationStatus build_displacement(const std::span<const double> state, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
++workspace.actions;
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 5>& values = coefficients[row];
output[row] = values[0] * state[0] + values[1] * state[1] + values[2] * state[0] * state[0] + values[3] * state[0] * state[1] + values[4] * state[1] * state[1];
}
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Apply the analytic gradient of each quadratic row. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian(const std::span<const double> state, const std::span<const double> direction, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
++workspace.actions;
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 2> row_gradient = gradient(row, state);
output[row] = row_gradient[0] * direction[0] + row_gradient[1] * direction[1];
}
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Reverse the exact same mathematical derivative without constructing a matrix. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(const std::span<const double> state, const std::span<const double> dual, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
++workspace.actions;
std::ranges::fill(output, 0.0);
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 2> row_gradient = gradient(row, state);
output[0] += row_gradient[0] * dual[row];
output[1] += row_gradient[1] * dual[row];
}
return deformation::validate_deformation_values(output, input_size());
}
/** @brief Contract the constant quadratic Hessians with direction and fixed output dual. */
[[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(const std::span<const double> state, const std::span<const double> direction, const std::span<const double> dual, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
++workspace.actions;
std::ranges::fill(output, 0.0);
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 5>& values = coefficients[row];
output[0] += (2.0 * values[2] * direction[0] + values[3] * direction[1]) * dual[row];
output[1] += (values[3] * direction[0] + 2.0 * values[4] * direction[1]) * dual[row];
}
return deformation::validate_deformation_values(output, input_size());
}
/** @brief Keep the fixture's analytic row derivative in one easily checked expression. */
[[nodiscard]] std::array<double, 2> gradient(const std::size_t row, const std::span<const double> state) const
{
const std::array<double, 5>& values = coefficients[row];
return {values[0] + 2.0 * values[2] * state[0] + values[3] * state[1], values[1] + values[3] * state[0] + 2.0 * values[4] * state[1]};
}
};
using NonlinearSurface = QuadraticMap<spaces::ShapeParameters, spaces::SurfaceDisplacement, 2>;
using NonlinearExtension = QuadraticMap<spaces::SurfaceDisplacement, spaces::VolumeDisplacement, 3>;
/** @brief Retain MeanField's surface definition (x*x + y, x*y). */
[[nodiscard]] std::shared_ptr<const NonlinearSurface> nonlinear_surface()
{
return std::make_shared<const NonlinearSurface>(NonlinearSurface{.coefficients = {{{0.0, 1.0, 1.0, 0.0, 0.0}, {0.0, 0.0, 0.0, 1.0, 0.0}}}});
}
/** @brief Retain the bilinear extension with distinct row coefficients from MeanField's nonlinear fixture. */
[[nodiscard]] std::shared_ptr<const NonlinearExtension> nonlinear_extension()
{
return std::make_shared<const NonlinearExtension>(NonlinearExtension{.coefficients = {{{0.01, -0.02, 0.0, 0.0005, 0.0}, {0.011, -0.018, 0.0, 0.001, 0.0}, {0.012, -0.016, 0.0, 0.0015, 0.0}}}});
}
/** @brief Supply the minimal linear-map contract with an independently known nonsymmetric matrix. */
template <typename Input, typename Output>
struct LinearMap
{
using InputSpace = Input;
using OutputSpace = Output;
using Workspace = deformation::EmptyDeformationWorkspace;
static constexpr bool is_linear = true;
std::size_t input_count{2};
/** @brief Permit an intentionally incompatible count for factory validation. */
[[nodiscard]] std::size_t input_size() const noexcept
{
return input_count;
}
/** @brief Fix two rows for the explicit matrix used in the test. */
[[nodiscard]] std::size_t output_size() const noexcept
{
return 2;
}
/** @brief Apply [[2, 1], [-1, 3]], with no derivative-specific implementation. */
[[nodiscard]] deformation::DeformationStatus apply(const std::span<const double> input, const std::span<double> output, Workspace = {}) const
{
if (const auto status = deformation::validate_deformation_values(input, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
output[0] = 2.0 * input[0] + input[1];
output[1] = -input[0] + 3.0 * input[1];
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Use an analytic transpose to test the generic linear derivative branch. */
[[nodiscard]] deformation::DeformationStatus apply_transpose(const std::span<const double> input, const std::span<double> output, Workspace = {}) const
{
if (const auto status = deformation::validate_deformation_values(input, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
output[0] = 2.0 * input[0] - input[1];
output[1] = input[0] + 3.0 * input[1];
return deformation::validate_deformation_values(output, input_size());
}
};
using LinearSurface = LinearMap<spaces::ShapeParameters, spaces::SurfaceDisplacement>;
using LinearExtension = LinearMap<spaces::SurfaceDisplacement, spaces::VolumeDisplacement>;
/** @brief Build centered differences without borrowing any production derivative calculation. */
[[nodiscard]] std::array<double, 2> perturb(const std::array<double, 2>& state, const std::array<double, 2>& direction, const double step)
{
return {state[0] + step * direction[0], state[1] + step * direction[1]};
}
/** @brief Compare work without depending on a backend vector representation. */
[[nodiscard]] double inner_product(const std::span<const double> first, const std::span<const double> second)
{
REQUIRE(first.size() == second.size());
return std::inner_product(first.begin(), first.end(), second.begin(), 0.0);
}
/** @brief Give a nonlinear physical residual whose derivative contains cross-component couplings. */
[[nodiscard]] std::array<double, 3> volume_force(const std::span<const double> displacement)
{
return {1.0 + displacement[0] * displacement[0] + 0.5 * displacement[1], -0.2 + displacement[1] * displacement[1] - 0.3 * displacement[2], 0.7 + displacement[2] * displacement[2] + 0.1 * displacement[0]};
}
/** @brief Verify the surviving curvature term when either factor is linear. */
template <deformation::DeformationMap Map>
void check_mixed_pullback(const Map& map)
{
auto workspace = deformation::make_deformation_workspace(map);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const std::vector<double> dual(map.output_size(), 0.7);
std::array<double, 2> derivative{};
std::array<double, 2> plus_transpose{};
std::array<double, 2> minus_transpose{};
REQUIRE(deformation::apply_pullback_derivative(map, state, direction, dual, derivative, workspace));
REQUIRE(deformation::apply_jacobian_transpose(map, plus, dual, plus_transpose, workspace));
REQUIRE(deformation::apply_jacobian_transpose(map, minus, dual, minus_transpose, workspace));
for (std::size_t parameter = 0; parameter < derivative.size(); ++parameter)
{
CHECK_THAT(derivative[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 2.0e-10));
}
CHECK(std::abs(derivative[0]) + std::abs(derivative[1]) > 1.0e-5);
}
} // namespace
TEST_CASE("Nonlinear domain deformation preserves the complete chain rule and virtual work", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const auto plus_displacement = deformation::evaluate_displacement(*composed, plus);
const auto minus_displacement = deformation::evaluate_displacement(*composed, minus);
REQUIRE(plus_displacement);
REQUIRE(minus_displacement);
std::array<double, 3> tangent{};
std::array<double, 2> transpose{};
std::array<double, 2> pullback{};
std::array<double, 2> plus_transpose{};
std::array<double, 2> minus_transpose{};
REQUIRE(deformation::apply_jacobian(*composed, state, direction, tangent, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, transpose, workspace));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, pullback, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, dual, plus_transpose, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, dual, minus_transpose, workspace));
for (std::size_t row = 0; row < tangent.size(); ++row)
{
CHECK_THAT(tangent[row], WithinAbs(((*plus_displacement)[row] - (*minus_displacement)[row]) / (2.0 * step), 2.0e-12));
}
CHECK_THAT(inner_product(tangent, dual), WithinAbs(inner_product(direction, transpose), 2.0e-15));
for (std::size_t parameter = 0; parameter < pullback.size(); ++parameter)
{
CHECK_THAT(pullback[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 3.0e-12));
}
CHECK(std::abs(pullback[0]) > 1.0e-3);
CHECK(std::abs(pullback[1]) > 1.0e-3);
}
TEST_CASE("Nonlinear shape force derivative includes geometric and physical residual contributions", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const auto displacement = deformation::evaluate_displacement(*composed, state);
REQUIRE(displacement);
const std::array force = volume_force(*displacement);
std::array<double, 3> displacement_direction{};
REQUIRE(deformation::apply_jacobian(*composed, state, direction, displacement_direction, workspace));
const std::array force_direction{
2.0 * (*displacement)[0] * displacement_direction[0] + 0.5 * displacement_direction[1],
2.0 * (*displacement)[1] * displacement_direction[1] - 0.3 * displacement_direction[2],
2.0 * (*displacement)[2] * displacement_direction[2] + 0.1 * displacement_direction[0]};
std::array<double, 2> geometric_contribution{};
std::array<double, 2> physical_contribution{};
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, force, geometric_contribution, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, force_direction, physical_contribution, workspace));
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const auto plus_displacement = deformation::evaluate_displacement(*composed, plus);
const auto minus_displacement = deformation::evaluate_displacement(*composed, minus);
REQUIRE(plus_displacement);
REQUIRE(minus_displacement);
std::array<double, 2> plus_force{};
std::array<double, 2> minus_force{};
REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, volume_force(*plus_displacement), plus_force, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, volume_force(*minus_displacement), minus_force, workspace));
for (std::size_t parameter = 0; parameter < plus_force.size(); ++parameter)
{
const double centered_difference = (plus_force[parameter] - minus_force[parameter]) / (2.0 * step);
CHECK_THAT(geometric_contribution[parameter] + physical_contribution[parameter], WithinAbs(centered_difference, 3.0e-12));
CHECK(std::abs(geometric_contribution[parameter]) > 1.0e-3);
CHECK(std::abs(physical_contribution[parameter]) > 1.0e-7);
}
}
TEST_CASE("Linear composition needs only forward and transpose actions and recovers after rejected scratch", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(std::make_shared<const LinearSurface>(), std::make_shared<const LinearExtension>());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35};
std::array<double, 2> output{};
REQUIRE(deformation::build_displacement(*composed, state, output, workspace));
CHECK_THAT(output[0], WithinAbs(3.0 * state[0] + 5.0 * state[1], 1.0e-15));
CHECK_THAT(output[1], WithinAbs(-5.0 * state[0] + 8.0 * state[1], 1.0e-15));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace));
CHECK(output == std::array<double, 2>{0.0, 0.0});
const std::array enormous{std::numeric_limits<double>::max(), std::numeric_limits<double>::max()};
CHECK_FALSE(deformation::build_displacement(*composed, enormous, output, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, output, workspace));
CHECK_THAT(output[0], WithinAbs(3.0 * dual[0] - 5.0 * dual[1], 1.0e-15));
CHECK_THAT(output[1], WithinAbs(5.0 * dual[0] + 8.0 * dual[1], 1.0e-15));
}
TEST_CASE("Mixed linear and nonlinear deformation factors retain their curvature", "[unit][deformation][composition]")
{
const auto nonlinear_surface_only = deformation::compose_deformation(nonlinear_surface(), std::make_shared<const LinearExtension>());
const auto nonlinear_extension_only = deformation::compose_deformation(std::make_shared<const LinearSurface>(), nonlinear_extension());
REQUIRE(nonlinear_surface_only);
REQUIRE(nonlinear_extension_only);
check_mixed_pullback(*nonlinear_surface_only);
check_mixed_pullback(*nonlinear_extension_only);
}
TEST_CASE("Composition rejects incompatible factors and invalid actions without preventing workspace reuse", "[unit][deformation][composition]")
{
const auto surface = nonlinear_surface();
CHECK_FALSE(deformation::compose_deformation(surface, std::make_shared<const LinearExtension>(LinearExtension{.input_count = 3})));
CHECK_FALSE(deformation::compose_deformation(std::shared_ptr<const NonlinearSurface>{}, nonlinear_extension()));
CHECK_FALSE(deformation::compose_deformation(surface, std::shared_ptr<const NonlinearExtension>{}));
const auto composed = deformation::compose_deformation(surface, nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
const std::array invalid{std::numeric_limits<double>::quiet_NaN(), 0.0};
std::array<double, 3> volume{};
std::array<double, 2> output{};
std::array<double, 1> wrong{};
CHECK_FALSE(deformation::build_displacement(*composed, wrong, volume, workspace));
CHECK_FALSE(deformation::apply_jacobian(*composed, state, wrong, volume, workspace));
CHECK_FALSE(deformation::apply_jacobian_transpose(*composed, state, wrong, output, workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*composed, state, direction, dual, wrong, workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*composed, invalid, direction, dual, output, workspace));
REQUIRE(deformation::build_displacement(*composed, state, volume, workspace));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace));
CHECK(workspace.surface_workspace.actions > 0);
CHECK(workspace.extension_workspace.actions > 0);
}
TEST_CASE("A deformation linearization retains its accepted shape independently of subsequent trials", "[unit][deformation][composition]")
{
const auto surface = nonlinear_surface();
std::vector<double> trial{0.37, -0.21};
const deformation::DeformationLinearization<NonlinearSurface> accepted{.map = surface, .parameters = trial};
auto workspace = deformation::make_deformation_workspace(*surface);
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35};
std::array<double, 2> tangent{};
std::array<double, 2> transpose{};
std::array<double, 2> pullback{};
trial = {8.0, 9.0};
REQUIRE(deformation::apply_jacobian(accepted, direction, tangent, workspace));
REQUIRE(deformation::apply_jacobian_transpose(accepted, dual, transpose, workspace));
REQUIRE(deformation::apply_pullback_derivative(accepted, direction, dual, pullback, workspace));
CHECK_THAT(tangent[0], WithinAbs(2.0 * 0.37 * direction[0] + direction[1], 1.0e-15));
CHECK_THAT(tangent[1], WithinAbs(-0.21 * direction[0] + 0.37 * direction[1], 1.0e-15));
CHECK_THAT(transpose[0], WithinAbs(2.0 * 0.37 * dual[0] - 0.21 * dual[1], 1.0e-15));
CHECK_THAT(transpose[1], WithinAbs(dual[0] + 0.37 * dual[1], 1.0e-15));
CHECK_THAT(pullback[0], WithinAbs(2.0 * direction[0] * dual[0] + direction[1] * dual[1], 1.0e-15));
CHECK_THAT(pullback[1], WithinAbs(direction[0] * dual[1], 1.0e-15));
}
TEST_CASE("Nonlinear composed actions reject overlapping state direction and dual before changing any data", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
// Exercise direct map methods as well as the generic interface, with exact and partial overlap.
for (const bool direct : {false, true})
{
for (int action = 0; action < 4; ++action)
{
for (int aliased_input = 0; aliased_input < 3; ++aliased_input)
{
if ((aliased_input == 1 && action != 1 && action != 3) || (aliased_input == 2 && action < 2))
{
continue;
}
for (const int offset : {0, 1})
{
CAPTURE(direct, action, aliased_input, offset);
std::array<double, 6> storage{0.37, -0.21, 0.24, 5.0, 7.0, 9.0};
const std::array original = storage;
std::span<const double> input_state = state;
std::span<const double> input_direction = direction;
std::span<const double> input_dual = dual;
if (aliased_input == 0)
{
input_state = std::span<const double>(storage).first(2);
}
else if (aliased_input == 1)
{
input_direction = std::span<const double>(storage).first(2);
}
else
{
input_dual = std::span<const double>(storage).first(3);
}
const std::size_t output_size = action < 2 ? 3 : 2;
const std::span<double> output = std::span<double>(storage).subspan(offset, output_size);
deformation::DeformationStatus status;
if (action == 0)
{
status = direct ? composed->build_displacement(input_state, output, workspace) : deformation::build_displacement(*composed, input_state, output, workspace);
}
else if (action == 1)
{
status = direct ? composed->apply_jacobian(input_state, input_direction, output, workspace) : deformation::apply_jacobian(*composed, input_state, input_direction, output, workspace);
}
else if (action == 2)
{
status = direct ? composed->apply_jacobian_transpose(input_state, input_dual, output, workspace) : deformation::apply_jacobian_transpose(*composed, input_state, input_dual, output, workspace);
}
else
{
status = direct ? composed->apply_pullback_derivative(input_state, input_direction, input_dual, output, workspace) : deformation::apply_pullback_derivative(*composed, input_state, input_direction, input_dual, output, workspace);
}
REQUIRE_FALSE(status);
CHECK(status.error().reason == deformation::DeformationFailureReason::invalid_configuration);
CHECK(storage == original);
CHECK(workspace.surface_workspace.actions == 0);
CHECK(workspace.extension_workspace.actions == 0);
}
}
}
}
std::array<double, 2> recovered{}, reference{};
auto independent_workspace = composed->make_workspace();
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, recovered, workspace));
REQUIRE(composed->apply_pullback_derivative(state, direction, dual, reference, independent_workspace));
CHECK(recovered == reference);
}
TEST_CASE("Generic actions protect custom nonlinear maps and linear zero derivatives from aliases", "[unit][deformation][composition]")
{
const auto nonlinear = nonlinear_surface();
auto nonlinear_workspace = deformation::make_deformation_workspace(*nonlinear);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
std::array<double, 2> storage{0.9, -0.35};
const std::array original = storage;
CHECK_FALSE(deformation::build_displacement(*nonlinear, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_jacobian(*nonlinear, state, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_jacobian_transpose(*nonlinear, state, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*nonlinear, state, direction, storage, storage, nonlinear_workspace));
CHECK(storage == original);
CHECK(nonlinear_workspace.actions == 0);
const LinearSurface linear;
auto linear_workspace = deformation::make_deformation_workspace(linear);
CHECK_FALSE(deformation::apply_pullback_derivative(linear, state, direction, storage, storage, linear_workspace));
CHECK(storage == original);
// Adjacent slices in one allocation are disjoint and remain a valid allocation-free calling pattern.
std::array<double, 4> adjacent{0.37, -0.21, 0.0, 0.0};
REQUIRE(deformation::build_displacement(*nonlinear, std::span<const double>(adjacent).first(2), std::span<double>(adjacent).last(2), nonlinear_workspace));
CHECK_THAT(adjacent[2], WithinAbs(state[0] * state[0] + state[1], 1.0e-15));
CHECK_THAT(adjacent[3], WithinAbs(state[0] * state[1], 1.0e-15));
}

View File

@@ -0,0 +1,706 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <limits>
#include <memory>
#include <span>
#include <stdexcept>
#include <tuple>
#include <utility>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include "mfem/fem/coefficient.hpp"
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
#include "serif/tests/deformation/mesh.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace keys = deformation::keys;
using Status = dependencies::PreparationStatus;
using Model = deformation::DomainDeformation<deformation::SphericalHarmonicSurface>;
/** @brief Keep borrowed meshes and shape coefficients alive through every graph preparation. */
struct GraphFixture
{
serif::tests::deformation::PairedMesh mesh;
backend::ReferenceDiscretization discretization;
Model model{.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}};
std::vector<double> coefficients = std::vector<double>(9, 0.01);
/// Resolve domain attributes through the same schema used by the stellar mesh.
GraphFixture() : discretization(backend::deformation_discretization(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh))
{
}
/// Reconstructed context wrappers must not invalidate unchanged physical values.
[[nodiscard]] auto inputs() const
{
return backend::make_deformation_inputs(model, discretization, coefficients);
}
};
/** @brief Construct the dependency cache without performing numerical reference compilation. */
[[nodiscard]] auto make_cache(const GraphFixture& fixture)
{
return dependencies::make_computation_cache<deformation::DeformationFailure>(backend::make_deformation_graph<deformation::SphericalHarmonicSurface>(), fixture.inputs());
}
/** @brief A one-coordinate nonlinear surface distinguishes equal displacements from unequal tangents. */
struct SquaredSurface
{
using InputSpace = deformation::spaces::ShapeParameters;
using OutputSpace = deformation::spaces::SurfaceDisplacement;
struct Workspace
{
};
static constexpr bool is_linear = false;
/// A single shape coordinate suffices to expose the derivative-sign regression.
[[nodiscard]] std::size_t input_size() const
{
return 1;
}
/// This algebraic fixture has one trace component.
[[nodiscard]] std::size_t output_size() const
{
return 1;
}
/// The polynomial needs no temporary numerical storage.
[[nodiscard]] Workspace make_workspace() const
{
return {};
}
/// Opposite coefficients deliberately produce identical displacement values.
[[nodiscard]] deformation::DeformationStatus build_displacement(std::span<const double> state, std::span<double> output, Workspace&) const
{
if (const auto status = deformation::validate_deformation_values(state, 1); !status)
{
return status;
}
output[0] = state[0] * state[0];
return {};
}
/// Retain the base-state sign in the tangent even when the displacement is unchanged.
[[nodiscard]] deformation::DeformationStatus apply_jacobian(std::span<const double> state, std::span<const double> direction, std::span<double> output, Workspace&) const
{
output[0] = 2.0 * state[0] * direction[0];
return {};
}
/// A scalar transpose preserves the same work-conjugate derivative.
[[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(std::span<const double> state, std::span<const double> dual, std::span<double> output, Workspace&) const
{
output[0] = 2.0 * state[0] * dual[0];
return {};
}
/// Nonlinear shape coordinates contribute the Hessian term to a Newton force action.
[[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(std::span<const double>, std::span<const double> direction, std::span<const double> dual, std::span<double> output, Workspace&) const
{
output[0] = 2.0 * direction[0] * dual[0];
return {};
}
};
/** @brief Preserve the nonlinear surface value through a simple exact linear extension. */
struct UnitExtension
{
using InputSpace = deformation::spaces::SurfaceDisplacement;
using OutputSpace = deformation::spaces::VolumeDisplacement;
using Workspace = deformation::EmptyDeformationWorkspace;
static constexpr bool is_linear = true;
/// This extension accepts the fixture's single trace component.
[[nodiscard]] std::size_t input_size() const
{
return 1;
}
/// A single volume value makes the expected derivative independent of a mesh.
[[nodiscard]] std::size_t output_size() const
{
return 1;
}
/// Identity extension preserves the physical value exactly.
[[nodiscard]] deformation::DeformationStatus apply(std::span<const double> input, std::span<double> output, Workspace = {}) const
{
output[0] = input[0];
return {};
}
/// Identity force transfer preserves virtual work exactly.
[[nodiscard]] deformation::DeformationStatus apply_transpose(std::span<const double> input, std::span<double> output, Workspace = {}) const
{
return apply(input, output);
}
};
} // namespace
TEST_CASE("Deformation graph construction is lazy and equal physical inputs reuse reference work", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == 0);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto surface = cache.get<keys::SurfaceMap>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
const auto map = cache.get<keys::DomainMap>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
const std::vector<double> copied_coefficients = fixture.coefficients;
const auto equal_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization, copied_coefficients);
const auto reused = cache.try_prepare(equal_inputs);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(reused->status<keys::ReferenceSurface>() == Status::reused);
CHECK(reused->status<keys::SurfaceMap>() == Status::reused);
CHECK(reused->status<keys::VolumeExtension>() == Status::reused);
CHECK(reused->status<keys::Linearization>() == Status::reused);
CHECK(cache.get<keys::SurfaceMap>() == surface);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::DomainMap>() == map);
CHECK(cache.get<keys::VolumeDisplacement>() == original);
}
TEST_CASE("Changing shape coefficients recomputes only deformation values and their linearization", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto reference = cache.get<keys::ReferenceSurface>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
const auto map = cache.get<keys::DomainMap>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
fixture.coefficients[0] += 0.03;
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::ReferenceSurface>() == Status::reused);
CHECK(report->status<keys::RadialInterpolation>() == Status::reused);
CHECK(report->status<keys::SurfaceMap>() == Status::reused);
CHECK(report->status<keys::VolumeExtension>() == Status::reused);
CHECK(report->status<keys::DomainMap>() == Status::reused);
CHECK(report->status<keys::SurfaceDisplacement>() == Status::computed);
CHECK(report->status<keys::VolumeDisplacement>() == Status::computed);
CHECK(report->status<keys::Linearization>() == Status::computed);
CHECK(cache.get<keys::ReferenceSurface>() == reference);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::DomainMap>() == map);
CHECK(cache.get<keys::VolumeDisplacement>() != original);
}
TEST_CASE("Changing the stellar radial power retains interpolation and the surface representation", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto interpolation = cache.get<keys::RadialInterpolation>();
const auto surface = cache.get<keys::SurfaceMap>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
fixture.model.stellar_interior.radial_power = 3.0;
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::ReferenceSurface>() == Status::reused);
CHECK(report->status<keys::RadialInterpolation>() == Status::reused);
CHECK(report->status<keys::SurfaceMap>() == Status::reused);
CHECK(report->status<keys::SurfaceDisplacement>() == Status::reused);
CHECK(report->status<keys::VolumeExtension>() == Status::computed);
CHECK(report->status<keys::DomainMap>() == Status::computed);
CHECK(report->status<keys::VolumeDisplacement>() == Status::computed);
CHECK(report->status<keys::Linearization>() == Status::computed);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::SurfaceMap>() == surface);
CHECK(cache.get<keys::VolumeExtension>()->interpolation() == interpolation);
CHECK(cache.get<keys::VolumeDisplacement>() != original);
}
TEST_CASE("Changing the reference center invalidates radial surface directions without rebuilding logical interpolation", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto interpolation = cache.get<keys::RadialInterpolation>();
const auto extension = cache.get<keys::VolumeExtension>();
const std::vector<double> original = cache.get<keys::SurfaceDisplacement>();
fixture.model.surface.reference_center[0] = 0.02;
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::ReferenceSurface>() == Status::reused);
CHECK(report->status<keys::RadialInterpolation>() == Status::reused);
CHECK(report->status<keys::VolumeExtension>() == Status::reused);
CHECK(report->status<keys::SurfaceMap>() == Status::computed);
CHECK(report->status<keys::DomainMap>() == Status::computed);
CHECK(report->status<keys::SurfaceDisplacement>() == Status::computed);
CHECK(report->status<keys::VolumeDisplacement>() == Status::computed);
CHECK(report->status<keys::Linearization>() == Status::computed);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::VolumeExtension>() == extension);
CHECK(cache.get<keys::SurfaceDisplacement>() != original);
}
TEST_CASE("Cached deformation linearizations freeze borrowed coefficients until the next accepted preparation", "[integration][deformation]")
{
GraphFixture fixture;
const auto inputs = fixture.inputs();
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(inputs));
const auto accepted = cache.get<keys::Linearization>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
auto workspace = deformation::make_deformation_workspace(*accepted.map);
std::vector<double> action(original.size());
REQUIRE(deformation::apply_jacobian(accepted, accepted.parameters, action, workspace));
CHECK(action == original);
fixture.coefficients[0] += 0.2;
CHECK(accepted.parameters != fixture.coefficients);
CHECK(cache.get<keys::Linearization>().parameters == accepted.parameters);
CHECK(cache.get<keys::VolumeDisplacement>() == original);
REQUIRE(deformation::apply_jacobian(accepted, accepted.parameters, action, workspace));
CHECK(action == original);
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.get<keys::Linearization>().parameters == fixture.coefficients);
CHECK(accepted.parameters != fixture.coefficients);
CHECK(cache.get<keys::VolumeDisplacement>() != original);
}
TEST_CASE("Physical and logical coordinate revisions invalidate only their own reference mathematics", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto reference = cache.get<keys::ReferenceSurface>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
bool physical = false;
SECTION("Physical mesh node revision")
{
physical = true;
fixture.mesh.generated.mesh->NodesUpdated();
}
SECTION("Logical mesh node revision")
{
fixture.mesh.generated.reference_mesh->NodesUpdated();
}
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::SurfaceNodes>() == Status::reused);
CHECK(report->status<keys::ReferenceSurface>() == (physical ? Status::computed : Status::reused));
CHECK(report->status<keys::SurfaceMap>() == (physical ? Status::computed : Status::reused));
CHECK(report->status<keys::RadialInterpolation>() == (physical ? Status::reused : Status::computed));
CHECK(report->status<keys::VolumeExtension>() == (physical ? Status::reused : Status::computed));
CHECK(report->status<keys::Linearization>() == Status::computed);
CHECK((cache.get<keys::ReferenceSurface>() != reference) == physical);
CHECK((cache.get<keys::RadialInterpolation>() == interpolation) == physical);
CHECK(cache.get<keys::VolumeDisplacement>() == original);
}
TEST_CASE("Physical coordinate edits reuse interpolation and agree with a fresh deformation compilation", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto reference = cache.get<keys::ReferenceSurface>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
::mfem::VectorFunctionCoefficient stretch(3, [](const ::mfem::Vector& input, ::mfem::Vector& output)
{
output = input;
output[2] *= 0.85;
});
fixture.mesh.generated.mesh->Transform(stretch);
fixture.mesh.generated.mesh->NodesUpdated();
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::SurfaceNodes>() == Status::reused);
CHECK(report->status<keys::ReferenceSurface>() == Status::computed);
CHECK(report->status<keys::RadialInterpolation>() == Status::reused);
CHECK(cache.get<keys::ReferenceSurface>()->positions != reference->positions);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::VolumeDisplacement>() != original);
auto fresh = make_cache(fixture);
REQUIRE(fresh.try_prepare(fixture.inputs()));
CHECK(cache.get<keys::ReferenceSurface>()->positions == fresh.get<keys::ReferenceSurface>()->positions);
CHECK(cache.get<keys::VolumeDisplacement>() == fresh.get<keys::VolumeDisplacement>());
}
TEST_CASE("Logical coordinate edits reuse the physical surface and agree with a fresh deformation compilation", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto reference = cache.get<keys::ReferenceSurface>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
::mfem::VectorFunctionCoefficient scale(3, [](const ::mfem::Vector& input, ::mfem::Vector& output)
{
output = input;
output *= 2.0;
});
fixture.mesh.generated.reference_mesh->Transform(scale);
fixture.mesh.generated.reference_mesh->NodesUpdated();
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::SurfaceNodes>() == Status::reused);
CHECK(report->status<keys::ReferenceSurface>() == Status::reused);
CHECK(report->status<keys::RadialInterpolation>() == Status::computed);
CHECK(cache.get<keys::ReferenceSurface>() == reference);
CHECK(cache.get<keys::RadialInterpolation>()->data().stellar_radius == Catch::Approx(2.0 * interpolation->data().stellar_radius));
auto fresh = make_cache(fixture);
REQUIRE(fresh.try_prepare(fixture.inputs()));
CHECK(cache.get<keys::RadialInterpolation>()->data().logical_radii == fresh.get<keys::RadialInterpolation>()->data().logical_radii);
CHECK(cache.get<keys::VolumeDisplacement>() == fresh.get<keys::VolumeDisplacement>());
for (std::size_t index = 0; index < original.size(); ++index)
{
CHECK(cache.get<keys::VolumeDisplacement>()[index] == Catch::Approx(original[index]).margin(1.0e-12));
}
}
TEST_CASE("Material attribute edits are tracked without requiring a mesh revision bump", "[integration][deformation]")
{
namespace domains = serif::discretization::domain;
using Schema = serif::tests::deformation::Schema;
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto interpolation = cache.get<keys::RadialInterpolation>();
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
const int core = Schema::domain_id<domains::CoreDomain>();
const int envelope = Schema::domain_id<domains::EnvelopeDomain>();
int selected = -1;
for (int element = 0; element < fixture.mesh.generated.mesh->GetNE(); ++element)
{
if (fixture.mesh.generated.mesh->GetAttribute(element) == core)
{
selected = element;
break;
}
}
REQUIRE(selected >= 0);
fixture.mesh.generated.mesh->GetElement(selected)->SetAttribute(envelope);
fixture.mesh.generated.reference_mesh->GetElement(selected)->SetAttribute(envelope);
const auto report = cache.try_prepare(fixture.inputs());
REQUIRE(report);
CHECK(report->status<keys::SurfaceNodes>() == Status::reused);
CHECK(report->status<keys::ReferenceSurface>() == Status::reused);
CHECK(report->status<keys::RadialInterpolation>() == Status::computed);
CHECK(cache.get<keys::RadialInterpolation>() != interpolation);
CHECK(cache.get<keys::VolumeDisplacement>() == original);
}
TEST_CASE("Distinct mesh and space identities invalidate same-sized deformation data", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto reference = cache.get<keys::ReferenceSurface>();
const auto interpolation = cache.get<keys::RadialInterpolation>();
GraphFixture other;
REQUIRE(fixture.mesh.scalar_space.GetVSize() == other.mesh.scalar_space.GetVSize());
REQUIRE(fixture.mesh.vector_space.GetVSize() == other.mesh.vector_space.GetVSize());
const auto report = cache.try_prepare(other.inputs());
REQUIRE(report);
CHECK(report->status<keys::ReferenceSurface>() == Status::computed);
CHECK(report->status<keys::RadialInterpolation>() == Status::computed);
CHECK(report->status<keys::SurfaceMap>() == Status::computed);
CHECK(report->status<keys::VolumeExtension>() == Status::computed);
CHECK(cache.get<keys::ReferenceSurface>() != reference);
CHECK(cache.get<keys::RadialInterpolation>() != interpolation);
}
TEST_CASE("Requested reference results do not evaluate invalid trial shape coefficients", "[integration][deformation]")
{
GraphFixture fixture;
std::ranges::fill(fixture.coefficients, std::numeric_limits<double>::quiet_NaN());
auto cache = make_cache(fixture);
const auto reference_report = cache.try_prepare(fixture.inputs(), dependencies::RequestedResults<keys::ReferenceSurface>{});
REQUIRE(reference_report);
CHECK(reference_report->computed_count() == 2);
CHECK(cache.contains<keys::ReferenceSurface>());
CHECK_FALSE(cache.contains<keys::SurfaceDisplacement>());
CHECK_FALSE(cache.contains<keys::VolumeDisplacement>());
CHECK_FALSE(cache.contains<keys::Linearization>());
REQUIRE(cache.try_prepare(fixture.inputs(), dependencies::RequestedResults<keys::RadialInterpolation>{}));
const auto interpolation = cache.get<keys::RadialInterpolation>();
CHECK_FALSE(cache.try_prepare(fixture.inputs()));
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<keys::RadialInterpolation>(), std::logic_error);
std::ranges::fill(fixture.coefficients, 0.01);
const auto recovered = cache.try_prepare(fixture.inputs());
REQUIRE(recovered);
CHECK(recovered->status<keys::RadialInterpolation>() == Status::reused);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
}
TEST_CASE("Rejected trial coefficients hide old results and preserve accepted reference factors for recovery", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto interpolation = cache.get<keys::RadialInterpolation>();
const auto surface = cache.get<keys::SurfaceMap>();
const auto accepted_count = cache.preparation_count();
const std::vector<double> accepted_coefficients = fixture.coefficients;
SECTION("Nonfinite trial parameter")
{
fixture.coefficients.back() = std::numeric_limits<double>::quiet_NaN();
}
SECTION("Wrong coefficient count")
{
fixture.coefficients.push_back(0.01);
}
const auto failed = cache.try_prepare(fixture.inputs());
REQUIRE_FALSE(failed);
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == accepted_count);
CHECK_THROWS_AS(cache.get<keys::SurfaceDisplacement>(), std::logic_error);
CHECK_THROWS_AS(cache.get<keys::VolumeDisplacement>(), std::logic_error);
CHECK_THROWS_AS(cache.get<keys::Linearization>(), std::logic_error);
fixture.coefficients = accepted_coefficients;
const auto recovered = cache.try_prepare(fixture.inputs());
REQUIRE(recovered);
CHECK(recovered->status<keys::RadialInterpolation>() == Status::reused);
CHECK(recovered->status<keys::SurfaceMap>() == Status::reused);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
CHECK(cache.get<keys::SurfaceMap>() == surface);
CHECK(cache.get<keys::Linearization>().parameters == accepted_coefficients);
}
TEST_CASE("Invalid extension options cannot leave a deformation cache publicly ready", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
const auto interpolation = cache.get<keys::RadialInterpolation>();
fixture.model.stellar_interior.radial_power = 0.5;
const auto failed = cache.try_prepare(fixture.inputs());
REQUIRE_FALSE(failed);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<keys::DomainMap>(), std::logic_error);
fixture.model.stellar_interior.radial_power = 2.0;
const auto recovered = cache.try_prepare(fixture.inputs());
REQUIRE(recovered);
CHECK(recovered->status<keys::RadialInterpolation>() == Status::reused);
CHECK(cache.get<keys::RadialInterpolation>() == interpolation);
}
TEST_CASE("Removing the surface boundary label invalidates graph preparation immediately", "[integration][deformation]")
{
GraphFixture fixture;
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(fixture.inputs()));
std::vector<int> attributes(fixture.mesh.generated.mesh->GetNBE());
for (int boundary = 0; boundary < fixture.mesh.generated.mesh->GetNBE(); ++boundary)
{
attributes[boundary] = fixture.mesh.generated.mesh->GetBdrAttribute(boundary);
if (attributes[boundary] == fixture.mesh.surface_attribute)
{
fixture.mesh.generated.mesh->GetBdrElement(boundary)->SetAttribute(999);
}
}
const auto failed = cache.try_prepare(fixture.inputs());
REQUIRE_FALSE(failed);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<keys::VolumeDisplacement>(), std::logic_error);
for (int boundary = 0; boundary < fixture.mesh.generated.mesh->GetNBE(); ++boundary)
{
fixture.mesh.generated.mesh->GetBdrElement(boundary)->SetAttribute(attributes[boundary]);
}
REQUIRE(cache.try_prepare(fixture.inputs()));
}
TEST_CASE("Equal nonlinear displacements still refresh the deformation tangent and its accepted state", "[unit][deformation]")
{
std::vector<double> coefficients{1.0};
const auto surface = std::make_shared<const SquaredSurface>();
const auto extension = std::make_shared<const UnitExtension>();
const auto inputs = dependencies::make_dependency_context(
dependencies::own<keys::SurfaceMap>(surface),
dependencies::own<keys::VolumeExtension>(extension),
dependencies::borrow<keys::ShapeCoefficients>(coefficients));
auto cache = dependencies::make_computation_cache<deformation::DeformationFailure>(deformation::make_deformation_graph<SquaredSurface, UnitExtension>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto accepted = cache.get<keys::Linearization>();
auto workspace = deformation::make_deformation_workspace(*accepted.map);
const std::array<double, 1> direction{1.0};
std::array<double, 1> action{};
REQUIRE(deformation::apply_jacobian(accepted, direction, action, workspace));
CHECK(action[0] == 2.0);
const std::vector<double> original = cache.get<keys::VolumeDisplacement>();
coefficients[0] = -1.0;
REQUIRE(deformation::apply_jacobian(accepted, direction, action, workspace));
CHECK(action[0] == 2.0);
const auto report = cache.try_prepare(inputs);
REQUIRE(report);
CHECK(report->status<keys::DomainMap>() == Status::reused);
CHECK(report->status<keys::SurfaceDisplacement>() == Status::computed);
CHECK(report->status<keys::VolumeDisplacement>() == Status::computed);
CHECK(report->status<keys::Linearization>() == Status::computed);
CHECK(cache.get<keys::VolumeDisplacement>() == original);
REQUIRE(deformation::apply_jacobian(cache.get<keys::Linearization>(), direction, action, workspace));
CHECK(action[0] == -2.0);
REQUIRE(deformation::apply_jacobian_transpose(cache.get<keys::Linearization>(), direction, action, workspace));
CHECK(action[0] == -2.0);
REQUIRE(deformation::apply_pullback_derivative(cache.get<keys::Linearization>(), direction, direction, action, workspace));
CHECK(action[0] == 2.0);
CHECK(accepted.parameters[0] == 1.0);
}
TEST_CASE("Requested shape metrics prepare only the reference surface and ignore trial shape and volume extension choices", "[integration][deformation]")
{
GraphFixture fixture;
fixture.coefficients[0] = std::numeric_limits<double>::quiet_NaN();
fixture.model.stellar_interior.radial_power = 0.5;
auto computations = std::tuple_cat(backend::make_deformation_computations<deformation::SphericalHarmonicSurface>(), std::tuple{backend::make_surface_metric_computation()});
/// Additional reference physics joins the same dependency graph without adding a preparation lifecycle.
auto graph = std::apply([](auto... nodes)
{
return dependencies::make_computation_graph(std::move(nodes)...);
},
std::move(computations));
auto cache = dependencies::make_computation_cache<deformation::DeformationFailure>(std::move(graph), fixture.inputs());
const auto requested = dependencies::RequestedResults<keys::SurfaceMetric>{};
const auto initial = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(initial);
CHECK(initial->computed_count() == 4);
CHECK(initial->status<keys::SurfaceNodes>() == Status::computed);
CHECK(initial->status<keys::ReferenceSurface>() == Status::computed);
CHECK(initial->status<keys::SurfaceMap>() == Status::computed);
CHECK(initial->status<keys::SurfaceMetric>() == Status::computed);
CHECK(initial->status<keys::RadialInterpolation>() == Status::not_required);
CHECK(initial->status<keys::VolumeExtension>() == Status::not_required);
CHECK(initial->status<keys::VolumeDisplacement>() == Status::not_required);
CHECK_FALSE(cache.contains<keys::RadialInterpolation>());
CHECK_FALSE(cache.contains<keys::VolumeExtension>());
CHECK_FALSE(cache.contains<keys::DomainMap>());
CHECK_FALSE(cache.contains<keys::SurfaceDisplacement>());
CHECK_FALSE(cache.contains<keys::VolumeDisplacement>());
const std::vector<double> metric = cache.get<keys::SurfaceMetric>();
REQUIRE(metric.size() == fixture.coefficients.size());
CHECK(std::ranges::all_of(metric, [](double value)
{
return std::isfinite(value) && value > 0.0;
}));
fixture.coefficients[0] = 0.2;
const auto changed_coefficients = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(changed_coefficients);
CHECK(changed_coefficients->computed_count() == 0);
CHECK(changed_coefficients->status<keys::SurfaceMetric>() == Status::reused);
fixture.model.stellar_interior.radial_power = 3.0;
const auto changed_extension = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(changed_extension);
CHECK(changed_extension->computed_count() == 0);
CHECK(changed_extension->status<keys::SurfaceMetric>() == Status::reused);
CHECK(cache.get<keys::SurfaceMetric>() == metric);
fixture.mesh.generated.reference_mesh->NodesUpdated();
const auto changed_logical_reference = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(changed_logical_reference);
CHECK(changed_logical_reference->computed_count() == 0);
CHECK(changed_logical_reference->status<keys::SurfaceMetric>() == Status::reused);
fixture.model.surface.reference_center[0] = 0.02;
const auto changed_surface = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(changed_surface);
CHECK(changed_surface->status<keys::ReferenceSurface>() == Status::reused);
CHECK(changed_surface->status<keys::SurfaceMap>() == Status::computed);
CHECK(changed_surface->status<keys::SurfaceMetric>() == Status::computed);
::mfem::VectorFunctionCoefficient stretch(3, [](const ::mfem::Vector& input, ::mfem::Vector& output)
{
output = input;
output[2] *= 0.85;
});
fixture.mesh.generated.mesh->Transform(stretch);
fixture.mesh.generated.mesh->NodesUpdated();
const auto changed_reference = cache.try_prepare(fixture.inputs(), requested);
REQUIRE(changed_reference);
CHECK(changed_reference->status<keys::ReferenceSurface>() == Status::computed);
CHECK(changed_reference->status<keys::SurfaceMap>() == Status::computed);
CHECK(changed_reference->status<keys::SurfaceMetric>() == Status::computed);
const auto reference = backend::sample_reference_surface(fixture.mesh.scalar_space, fixture.mesh.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(fixture.model.surface, reference->positions);
REQUIRE(embedding);
const auto fresh_metric = backend::surface_parameter_mass_diagonal(fixture.mesh.scalar_space, fixture.mesh.surface_attribute, *reference, *embedding);
REQUIRE(fresh_metric);
CHECK(cache.get<keys::SurfaceMetric>() == *fresh_metric);
CHECK(cache.get<keys::SurfaceMetric>() != metric);
CHECK_FALSE(cache.contains<keys::RadialInterpolation>());
CHECK_FALSE(cache.contains<keys::VolumeExtension>());
CHECK_FALSE(cache.contains<keys::VolumeDisplacement>());
}
TEST_CASE("Packed shape views track only their values and retain independent linearizations", "[integration][deformation][shape_view]") {
GraphFixture fixture;
constexpr std::size_t offset = 4;
std::vector<double> packed(offset + fixture.coefficients.size() + 3, 0.0);
std::ranges::copy(fixture.coefficients, packed.begin() + offset);
const auto inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization,
std::span<const double>(packed).subspan(offset, fixture.coefficients.size()));
auto cache = dependencies::make_computation_cache<deformation::DeformationFailure>(
backend::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto frozen = cache.get<keys::Linearization>();
CHECK(frozen.parameters == fixture.coefficients);
packed.front() = 2.0;
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.report().computed_count() == 0);
packed[offset] += 0.02;
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.report().status<keys::ReferenceSurface>() == Status::reused);
CHECK(cache.report().status<keys::RadialInterpolation>() == Status::reused);
CHECK(cache.report().status<keys::SurfaceDisplacement>() == Status::computed);
CHECK(cache.report().status<keys::Linearization>() == Status::computed);
CHECK(cache.get<keys::Linearization>().parameters.front() == packed[offset]);
CHECK(frozen.parameters == fixture.coefficients);
std::vector<double> equal_coefficients(packed.begin() + offset, packed.end() - 3);
const auto equal_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization,
std::span<const double>(equal_coefficients));
REQUIRE(cache.try_prepare(equal_inputs));
CHECK(cache.report().computed_count() == 0);
equal_coefficients.front() = std::numeric_limits<double>::quiet_NaN();
REQUIRE_FALSE(cache.try_prepare(equal_inputs));
CHECK_FALSE(cache.contains<keys::Linearization>());
CHECK(frozen.parameters == fixture.coefficients);
REQUIRE(cache.try_prepare(inputs));
const auto short_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization,
std::span<const double>(packed).subspan(offset, fixture.coefficients.size() - 1));
REQUIRE_FALSE(cache.try_prepare(short_inputs));
CHECK_FALSE(cache.contains<keys::Linearization>());
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.get<keys::Linearization>().parameters.front() == packed[offset]);
}
TEST_CASE("Borrowed shape vectors retain current storage after resizing", "[integration][deformation][shape_view]") {
GraphFixture fixture;
const auto inputs = fixture.inputs();
auto cache = make_cache(fixture);
REQUIRE(cache.try_prepare(inputs));
const std::vector<double> original = fixture.coefficients;
fixture.coefficients.resize(fixture.coefficients.capacity() + 1, 0.0);
REQUIRE_FALSE(cache.try_prepare(inputs));
CHECK_FALSE(cache.contains<keys::Linearization>());
fixture.coefficients = original;
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.get<keys::Linearization>().parameters == original);
}

View File

@@ -0,0 +1,115 @@
#include <algorithm>
#include <cmath>
#include <memory>
#include <numeric>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp"
#include "serif/geometry/deformation/composition.hpp"
#include "serif/geometry/deformation/linearization.hpp"
#include "serif/geometry/deformation/surface_embedding.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
using DomainMap = deformation::ComposedDeformation<deformation::SurfaceEmbedding, deformation::RadialExtension>;
/** @brief Pair complete surface and volume actions on the same serial STROID geometry. */
template <typename Model>
[[nodiscard]] DomainMap make_domain_map(serif::tests::deformation::PairedMesh& mesh, const Model& model)
{
const auto reference = backend::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute);
REQUIRE(reference);
auto surface = deformation::build_surface_embedding(model, reference->positions);
auto interpolation = mesh.interpolation();
REQUIRE(surface);
REQUIRE(interpolation);
auto extension = deformation::make_radial_extension(std::make_shared<const deformation::RadialInterpolation>(std::move(*interpolation)));
REQUIRE(extension);
auto result = deformation::compose_deformation(std::make_shared<const deformation::SurfaceEmbedding>(std::move(*surface)), std::make_shared<const deformation::RadialExtension>(std::move(*extension)));
REQUIRE(result);
return std::move(*result);
}
/** @brief Compare the assembled physical map and complete transpose with the original MeanField tolerances. */
void check_complete_actions(const DomainMap& map, const std::vector<double>& state, const std::vector<double>& direction, const double step, const double difference_tolerance, const double work_tolerance)
{
std::vector<double> plus(state), minus(state);
for (std::size_t parameter = 0; parameter < state.size(); ++parameter)
{
plus[parameter] += step * direction[parameter];
minus[parameter] -= step * direction[parameter];
}
const auto plus_value = deformation::evaluate_displacement(map, plus);
const auto minus_value = deformation::evaluate_displacement(map, minus);
REQUIRE(plus_value);
REQUIRE(minus_value);
auto workspace = deformation::make_deformation_workspace(map);
std::vector<double> action(map.output_size()), dual(map.output_size()), force(map.input_size()), curvature(map.input_size(), 7.0);
REQUIRE(deformation::apply_jacobian(map, state, direction, action, workspace));
long double error_squared = 0.0L;
long double reference_squared = 0.0L;
for (std::size_t row = 0; row < action.size(); ++row)
{
const double difference = ((*plus_value)[row] - (*minus_value)[row]) / (2.0 * step);
error_squared += static_cast<long double>(action[row] - difference) * (action[row] - difference);
reference_squared += static_cast<long double>(difference) * difference;
const double index = static_cast<double>(row + 1);
dual[row] = std::sin(0.07 * index) + 0.4 * std::cos(0.11 * index);
}
REQUIRE(reference_squared > 0.0L);
CHECK(std::sqrt(error_squared / reference_squared) < difference_tolerance);
REQUIRE(deformation::apply_jacobian_transpose(map, state, dual, force, workspace));
const double volume_work = std::inner_product(action.begin(), action.end(), dual.begin(), 0.0);
const double parameter_work = std::inner_product(direction.begin(), direction.end(), force.begin(), 0.0);
CHECK(std::abs(volume_work - parameter_work) <= work_tolerance * std::max({1.0, std::abs(volume_work), std::abs(parameter_work)}));
REQUIRE(deformation::apply_pullback_derivative(map, state, direction, dual, curvature, workspace));
CHECK(std::ranges::all_of(curvature, [](const double value)
{
return value == 0.0;
}));
std::vector<double> plus_force(map.input_size()), minus_force(map.input_size());
REQUIRE(deformation::apply_jacobian_transpose(map, plus, dual, plus_force, workspace));
REQUIRE(deformation::apply_jacobian_transpose(map, minus, dual, minus_force, workspace));
CHECK(plus_force == minus_force);
}
} // namespace
TEST_CASE("MeanField nodal domain directions preserve complete finite differences and virtual work", "[unit][deformation][meanfield]")
{
// Preserve the original cubic displacement space, including interpolation between refined surface rays.
for (const int refinement : {0, 1})
{
CAPTURE(refinement);
serif::tests::deformation::PairedMesh mesh(refinement, 0.0, 3, 4, 5.0);
const DomainMap map = make_domain_map(mesh, deformation::NodalRadialSurface{});
std::vector<double> state(map.input_size()), direction(map.input_size());
for (std::size_t parameter = 0; parameter < state.size(); ++parameter)
{
const double index = static_cast<double>(parameter + 1);
state[parameter] = 0.008 * std::sin(0.13 * index);
direction[parameter] = std::cos(0.17 * index) - 0.25 * std::sin(0.29 * index);
}
check_complete_actions(map, state, direction, 1.0e-6, 3.0e-10, 8.0e-13);
}
}
TEST_CASE("MeanField harmonic domain directions preserve complete finite differences and dual work", "[unit][deformation][meanfield]")
{
serif::tests::deformation::PairedMesh mesh(0, 0.0, 3, 4, 5.0);
const DomainMap map = make_domain_map(mesh, deformation::SphericalHarmonicSurface{.maximum_degree = 2});
REQUIRE(map.input_size() == 9);
std::vector<double> state(map.input_size()), direction(map.input_size());
for (std::size_t parameter = 0; parameter < state.size(); ++parameter)
{
const double value = std::sin(0.37 * static_cast<double>(parameter + 1));
state[parameter] = 0.001 * value;
direction[parameter] = 0.004 * value;
}
check_complete_actions(map, state, direction, 1.0e-5, 2.0e-9, 1.0e-11);
}

View File

@@ -0,0 +1,397 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numeric>
#include <tuple>
#include <variant>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
#include "serif/backend/mfem/model/volume_displacement.hpp"
#include "serif/backend/mfem/model/deformation.hpp"
#include "serif/tests/deformation/mesh.hpp"
#include "serif/tests/equations/fixture.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace deformation_backend = deformation::backend::mfem;
namespace equation_backend = serif::operators::backend::mfem;
namespace equation_fixture = serif::tests::equations;
namespace dependencies = serif::utils::dependencies;
namespace blocks = serif::discretization::blocks;
namespace stages = equation_backend::volume_keys;
using Status = dependencies::PreparationStatus;
using Closure = equation_fixture::Closure;
using Model = deformation::DomainDeformation<deformation::SphericalHarmonicSurface>;
using VolumeDiscretization = equation_backend::VolumeDiscretization<Closure::Test, equation_fixture::Schema>;
using Failure = std::variant<deformation::DeformationFailure, equation_backend::PreparationRejection>;
using DensityValue = serif::discretization::forms::Operand<blocks::density::mass>;
/** @brief Exercise displacement support independently of the stellar domain vocabulary. */
struct SupportedDisplacement : serif::discretization::backend::mfem::DofMap {
/** @brief A selected element set gives an independent gather/scatter reference. */
SupportedDisplacement(mfem::FiniteElementSpace& space, const mfem::Array<int>& support) : DofMap(space, support) {}
};
/** @brief Keep the collection and coefficient count unchanged while exposing incompatible local numbering. */
struct ReorderedDisplacementSpace : mfem::FiniteElementSpace
{
using mfem::FiniteElementSpace::FiniteElementSpace;
/** @brief Model an equation companion whose scalar coefficients have different local meanings. */
void GetElementDofs(int element, mfem::Array<int>& dofs, mfem::DofTransformation& transformation) const override
{
mfem::FiniteElementSpace::GetElementDofs(element, dofs, transformation);
std::swap(dofs[0], dofs[1]);
}
};
/** @brief Reuse the volume equation's existing field bindings and physical mapper. */
[[nodiscard]] VolumeDiscretization equation_discretization(const equation_fixture::Fixture& fixture)
{
return {.residual_binding = fixture.density_binding, .displacement_binding = fixture.displacement_binding, .compactification = fixture.compactification, .mapper = fixture.mapper};
}
/** @brief Supply only independent physical inputs; displacement is produced inside the combined graph. */
[[nodiscard]] auto combined_inputs(const Model& model, const deformation_backend::ReferenceDiscretization& reference, const std::vector<double>& shape, const equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const auto shape_inputs = deformation_backend::make_deformation_inputs(model, reference, shape);
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(equation_discretization(fixture), fixture.state(density, enthalpy));
return dependencies::make_dependency_context(
shape_inputs.template resource<deformation::keys::ReferenceDiscretization>(),
shape_inputs.template resource<deformation::keys::SurfaceDiscretization>(),
shape_inputs.template resource<deformation::keys::ReferenceCoordinates>(),
shape_inputs.template resource<deformation::keys::SurfaceModel>(),
shape_inputs.template resource<deformation::keys::InteriorModel>(),
shape_inputs.template resource<deformation::keys::VacuumModel>(),
shape_inputs.template resource<deformation::keys::ShapeCoefficients>(),
equation_inputs.template resource<equation_backend::MeshAndCoefficientBindings>(),
equation_inputs.template resource<equation_backend::CompactificationCoefficients>(),
equation_inputs.template resource<blocks::density::mass>(),
equation_inputs.template resource<::serif::tests::equations::Enthalpy>());
}
/** @brief Compare accepted residuals and both linear actions with the established standalone equation path. */
template <typename Cache>
void check_against_volume_cache(const Cache& combined, equation_fixture::Fixture& fixture, const serif::eos::models::Polytrope& eos, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const mfem::Vector displacement(combined.template get<equation_backend::DisplacementCoefficients>().values);
const auto discretization = equation_discretization(fixture);
const auto inputs = equation_backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto independent = equation_backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
REQUIRE(independent.try_prepare(inputs));
mfem::Vector actual, expected;
equation_backend::volume_residual(combined.template get<stages::Linearization>(), actual);
equation_backend::volume_residual(independent.template get<stages::Linearization>(), expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
const mfem::Vector direction = equation_fixture::packed(fixture.density(0.7), fixture.enthalpy(0.2), fixture.displacement(0.01));
equation_backend::apply_jacobian(combined.template get<stages::Linearization>(), direction, fixture.workspace, actual);
equation_backend::apply_jacobian(independent.template get<stages::Linearization>(), direction, fixture.workspace, expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
const mfem::Vector dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3);
equation_backend::apply_jacobian_transpose(combined.template get<stages::Linearization>(), dual, fixture.workspace, actual);
equation_backend::apply_jacobian_transpose(independent.template get<stages::Linearization>(), dual, fixture.workspace, expected);
CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14);
}
/** @brief Verify the complete harmonic-shape-to-equation tangent and its reverse virtual-work pairing. */
template <typename Cache>
void check_shape_equation_linearization(Cache& cache, const Model& model, const deformation_backend::ReferenceDiscretization& reference, std::vector<double>& shape, equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy)
{
const auto& accepted = cache.template get<deformation::keys::Linearization>();
auto shape_workspace = deformation::make_deformation_workspace(*accepted.map);
const std::array shape_direction{0.35, -0.17, 0.22, 0.09};
std::vector<double> volume_direction(accepted.map->output_size());
REQUIRE(deformation::apply_jacobian(accepted, shape_direction, volume_direction, shape_workspace));
// This fixture uses the same complete component-block ordering on both sides of the interface.
REQUIRE(fixture.displacement_space.GetOrdering() == mfem::Ordering::byNODES);
mfem::Vector displacement_direction(fixture.displacement_binding.size());
REQUIRE(volume_direction.size() == static_cast<std::size_t>(displacement_direction.Size()));
std::ranges::copy(volume_direction, displacement_direction.GetData());
const mfem::Vector zero_density = equation_fixture::constant(fixture.density_binding.size(), 0.0);
const mfem::Vector zero_enthalpy = equation_fixture::constant(fixture.enthalpy_binding.size(), 0.0);
const mfem::Vector direction = equation_fixture::packed(zero_density, zero_enthalpy, displacement_direction);
mfem::Vector analytic, equation_transpose;
equation_backend::apply_jacobian(cache.template get<stages::Linearization>(), direction, fixture.workspace, analytic);
const mfem::Vector equation_dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3);
equation_backend::apply_jacobian_transpose(cache.template get<stages::Linearization>(), equation_dual, fixture.workspace, equation_transpose);
const int displacement_offset = zero_density.Size() + zero_enthalpy.Size();
REQUIRE(equation_transpose.Size() == displacement_offset + displacement_direction.Size());
const std::span<const double> displacement_force(equation_transpose.GetData() + displacement_offset, displacement_direction.Size());
std::array<double, 4> shape_force{};
REQUIRE(deformation::apply_jacobian_transpose(accepted, displacement_force, shape_force, shape_workspace));
const double equation_work = analytic * equation_dual;
const double shape_work = std::inner_product(shape_direction.begin(), shape_direction.end(), shape_force.begin(), 0.0);
CHECK(std::abs(equation_work - shape_work) <= 2.0e-12 * std::max({1.0, std::abs(equation_work), std::abs(shape_work)}));
const std::vector<double> accepted_shape = shape;
constexpr double step = 1.0e-6;
for (std::size_t parameter = 0; parameter < shape.size(); ++parameter)
{
shape[parameter] = accepted_shape[parameter] + step * shape_direction[parameter];
}
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
mfem::Vector plus_residual, minus_residual;
equation_backend::volume_residual(cache.template get<stages::Linearization>(), plus_residual);
for (std::size_t parameter = 0; parameter < shape.size(); ++parameter)
{
shape[parameter] = accepted_shape[parameter] - step * shape_direction[parameter];
}
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
equation_backend::volume_residual(cache.template get<stages::Linearization>(), minus_residual);
plus_residual -= minus_residual;
plus_residual /= 2.0 * step;
CHECK(equation_fixture::relative_error(analytic, plus_residual) < 2.0e-8);
shape = accepted_shape;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
}
} // namespace
TEST_CASE("Deformation and volume equations share one dependency graph with atomic preparation", "[unit][deformation][equation_integration]")
{
auto generated = serif::tests::deformation::make_mesh();
equation_fixture::Fixture fixture(std::move(*generated.mesh));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, *generated.reference_mesh);
Model model{.surface = {.maximum_degree = 1}, .stellar_interior = {}, .vacuum = {}};
std::vector<double> shape{0.004, -0.002, 0.001, 0.003};
mfem::Vector density = fixture.density();
mfem::Vector enthalpy = fixture.enthalpy();
const serif::eos::models::Polytrope eos{1.5, 0.25};
const auto inputs = combined_inputs(model, reference, shape, fixture, density, enthalpy);
const auto calculations = std::tuple_cat(
deformation_backend::make_deformation_computations<deformation::SphericalHarmonicSurface>(),
deformation_backend::make_volume_displacement_computations(fixture.displacement_binding),
equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture)));
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<Failure>(inputs, std::move(calculation)...);
},
calculations);
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
check_shape_equation_linearization(cache, model, reference, shape, fixture, density, enthalpy);
const auto& backend_vector = cache.get<deformation_backend::volume_displacement_keys::Coefficients>();
CHECK(&cache.get<equation_backend::DisplacementCoefficients>().values == &backend_vector);
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().computed_count() == 0);
shape[1] += 0.001;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::SurfaceMap>() == Status::reused);
CHECK(cache.report().status<deformation::keys::RadialInterpolation>() == Status::reused);
CHECK(cache.report().status<deformation::keys::VolumeDisplacement>() == Status::computed);
CHECK(cache.report().status<deformation_backend::volume_displacement_keys::CheckedBinding>() == Status::reused);
CHECK(cache.report().status<stages::Basis<0>>() == Status::reused);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::computed);
CHECK(cache.report().status<stages::ReferenceValues<0, DensityValue>>() == Status::reused);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
density += 0.02;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::VolumeDisplacement>() == Status::reused);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::reused);
CHECK(cache.report().status<stages::ReferenceValues<0, DensityValue>>() == Status::computed);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
// A different interior response changes derivative information even when a spherical zero-displacement state has equal values.
std::ranges::fill(shape, 0.0);
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
model.stellar_interior.radial_power = 3.0;
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
CHECK(cache.report().status<deformation::keys::VolumeExtension>() == Status::computed);
CHECK(cache.report().status<deformation::keys::RadialInterpolation>() == Status::reused);
CHECK(cache.report().status<equation_backend::DisplacementCoefficients>() == Status::computed);
CHECK(cache.report().status<stages::Geometry<0>>() == Status::computed);
CHECK(cache.get<equation_backend::DisplacementCoefficients>().values.Norml2() == 0.0);
shape[0] = std::numeric_limits<double>::quiet_NaN();
const auto invalid_shape = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy));
REQUIRE_FALSE(invalid_shape);
CHECK(std::holds_alternative<deformation::DeformationFailure>(invalid_shape.error().error));
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<stages::Linearization>());
CHECK_FALSE(cache.contains<deformation::keys::Linearization>());
shape[0] = 0.003;
enthalpy = -1.0;
const auto invalid_equation = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy));
REQUIRE_FALSE(invalid_equation);
CHECK(std::holds_alternative<equation_backend::PreparationRejection>(invalid_equation.error().error));
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<stages::Linearization>());
CHECK_FALSE(cache.contains<deformation::keys::Linearization>());
enthalpy = fixture.enthalpy();
REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)));
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
}
TEST_CASE("The deformation displacement connection respects both global MFEM orderings", "[unit][deformation][equation_integration]")
{
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM})
{
equation_fixture::Fixture fixture(mfem::Element::HEXAHEDRON, ordering);
const mfem::Vector density = fixture.density();
const mfem::Vector enthalpy = fixture.enthalpy();
const mfem::Vector reference_displacement = fixture.displacement(0.03);
const int scalar_nodes = fixture.displacement_space.GetNDofs();
std::vector<double> displacement(fixture.displacement_binding.full_size());
for (int component = 0; component < 3; ++component)
{
for (int node = 0; node < scalar_nodes; ++node)
{
const int full = fixture.displacement_space.DofToVDof(node, component);
const int reduced = *fixture.displacement_binding.reduced_dof(full);
displacement[node + component * scalar_nodes] = reference_displacement[reduced];
}
}
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(equation_discretization(fixture), fixture.state(density, enthalpy));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh);
const auto inputs = dependencies::make_dependency_context(
dependencies::borrow<deformation::keys::VolumeDisplacement>(displacement, deformation_backend::DeformationValueTracking{}),
dependencies::own<deformation::keys::ReferenceDiscretization>(reference, deformation_backend::ReferenceDiscretizationTracking{}),
equation_inputs.resource<equation_backend::MeshAndCoefficientBindings>(),
equation_inputs.resource<equation_backend::CompactificationCoefficients>(),
equation_inputs.resource<blocks::density::mass>(),
equation_inputs.resource<::serif::tests::equations::Enthalpy>());
const serif::eos::models::Polytrope eos{1.5, 0.25};
const auto calculations = std::tuple_cat(deformation_backend::make_volume_displacement_computations(fixture.displacement_binding), equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture)));
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<Failure>(inputs, std::move(calculation)...);
},
calculations);
REQUIRE(cache.try_prepare(inputs));
const mfem::Vector& actual = cache.get<equation_backend::DisplacementCoefficients>().values;
CHECK(equation_fixture::relative_error(actual, reference_displacement) == 0.0);
check_against_volume_cache(cache, fixture, eos, density, enthalpy);
equation_fixture::Fixture other_fixture(mfem::Element::HEXAHEDRON, ordering);
const auto other_reference = deformation_backend::deformation_discretization(other_fixture.enthalpy_space, other_fixture.displacement_space, other_fixture.mesh);
const auto mismatched_inputs = dependencies::make_dependency_context(
inputs.resource<deformation::keys::VolumeDisplacement>(),
dependencies::own<deformation::keys::ReferenceDiscretization>(other_reference, deformation_backend::ReferenceDiscretizationTracking{}),
inputs.resource<equation_backend::MeshAndCoefficientBindings>(),
inputs.resource<equation_backend::CompactificationCoefficients>(),
inputs.resource<blocks::density::mass>(),
inputs.resource<::serif::tests::equations::Enthalpy>());
const auto mismatch = cache.try_prepare(mismatched_inputs);
REQUIRE_FALSE(mismatch);
CHECK(std::holds_alternative<deformation::DeformationFailure>(mismatch.error().error));
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
displacement.pop_back();
const auto invalid = cache.try_prepare(inputs);
REQUIRE_FALSE(invalid);
CHECK(std::holds_alternative<deformation::DeformationFailure>(invalid.error().error));
CHECK_FALSE(cache.ready());
}
}
TEST_CASE("Deformation rejects an equation displacement space with matching dimensions but different scalar numbering", "[unit][deformation][equation_integration]")
{
equation_fixture::Fixture fixture;
ReorderedDisplacementSpace reordered(&fixture.mesh, &fixture.enthalpy_collection, 3, mfem::Ordering::byNODES);
using Binding = serif::discretization::backend::mfem::TermBinding<blocks::displacement::vector, equation_fixture::Schema>;
const Binding binding(reordered);
REQUIRE(reordered.GetNDofs() == fixture.displacement_space.GetNDofs());
REQUIRE(reordered.FEColl() == fixture.displacement_space.FEColl());
REQUIRE(binding.size() == fixture.displacement_binding.size());
const VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper};
const mfem::Vector density = fixture.density();
const mfem::Vector enthalpy = fixture.enthalpy();
const auto equation_inputs = equation_backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy));
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh);
const std::vector<double> displacement(binding.full_size(), 0.0);
const auto inputs = dependencies::make_dependency_context(
dependencies::borrow<deformation::keys::VolumeDisplacement>(displacement, deformation_backend::DeformationValueTracking{}),
dependencies::own<deformation::keys::ReferenceDiscretization>(reference, deformation_backend::ReferenceDiscretizationTracking{}),
equation_inputs.resource<equation_backend::MeshAndCoefficientBindings>());
auto cache = std::apply([&](auto... calculation)
{
return dependencies::make_computation_cache<deformation::DeformationFailure>(inputs, std::move(calculation)...);
},
deformation_backend::make_volume_displacement_computations(binding));
const auto outcome = cache.try_prepare(inputs);
REQUIRE_FALSE(outcome);
CHECK(outcome.error().error.reason == deformation::DeformationFailureReason::incompatible_discretization);
CHECK(outcome.error().error.element == 0);
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<equation_backend::DisplacementCoefficients>());
}
TEST_CASE("Displacement values and sparse derivatives share supported MFEM coordinates", "[unit][deformation][displacement_mapping]") {
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
for (const bool restrict_support : {false, true}) {
mfem::Vector component_values, full_values, expected, dual, expected_transpose;
const auto projection = [&]() {
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON);
mfem::H1_FECollection collection(1, 3);
mfem::FiniteElementSpace space(&mesh, &collection, 3, ordering);
mfem::Array<int> support(mesh.GetNE());
support = 1;
if (restrict_support) {
support[1] = 0;
}
const SupportedDisplacement binding(space, support);
if (restrict_support) {
REQUIRE(binding.size() < binding.full_size());
}
component_values.SetSize(binding.full_size());
full_values.SetSize(binding.full_size());
for (int component = 0; component < space.GetVDim(); ++component) {
for (int node = 0; node < space.GetNDofs(); ++node) {
const int column = node + component * space.GetNDofs();
component_values[column] = 0.2 + 0.03 * column;
full_values[space.DofToVDof(node, component)] = component_values[column];
}
}
expected = binding.gather(full_values);
mfem::Vector converted(binding.size());
std::vector<int> visits(static_cast<std::size_t>(binding.size()), 0);
deformation_backend::for_each_displacement_coefficient(binding, [&](const int row, const int column) {
converted[row] = component_values[column];
++visits[static_cast<std::size_t>(row)];
});
CHECK(equation_fixture::relative_error(converted, expected) == 0.0);
CHECK(std::ranges::all_of(visits, [](const int count) { return count == 1; }));
dual = equation_fixture::deterministic(binding.size(), 0.4);
const mfem::Vector scattered = binding.scatter(dual);
expected_transpose.SetSize(binding.full_size());
for (int component = 0; component < space.GetVDim(); ++component) {
for (int node = 0; node < space.GetNDofs(); ++node) {
expected_transpose[node + component * space.GetNDofs()] = scattered[space.DofToVDof(node, component)];
}
}
return serif::model::backend::mfem::displacement_projection(binding);
}();
// The immutable projection must not depend on the departed mesh or binding.
REQUIRE(projection.has_assembly());
const auto sparse = projection.assemble();
REQUIRE(sparse->Finalized());
mfem::Vector actual(expected.Size()), transpose(expected_transpose.Size());
projection.action()->Mult(component_values, actual);
projection.action()->MultTranspose(dual, transpose);
CHECK(equation_fixture::relative_error(actual, expected) == 0.0);
CHECK(equation_fixture::relative_error(transpose, expected_transpose) == 0.0);
CHECK(std::abs(actual * dual - component_values * transpose) < 1.0e-12);
CHECK(sparse->NumNonZeroElems() == expected.Size());
}
}
}

View File

@@ -0,0 +1,381 @@
// Ported from MeanField tests/deformation/safe_newton_step.cpp, with serial input and sampling regressions.
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <utility>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "mfem/fem/coefficient.hpp"
#include "mfem/fem/fe_coll.hpp"
#include "mfem/fem/gridfunc.hpp"
#include "mfem/mesh/mesh.hpp"
#include "serif/backend/mfem/geometry/deformation/geometry_step.hpp"
#include "serif/geometry/deformation/detail/determinant_polynomial.hpp"
#include "serif/backend/mfem/mapping/domain_mapper.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
namespace mapping = serif::mapping;
using Catch::Matchers::WithinAbs;
constexpr int dimension = 3;
/** @brief Keep unit element widths so the mapped determinant has an independent analytic value. */
[[nodiscard]] mfem::Mesh make_mesh(const bool compactified, const bool nonconforming = false) {
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0);
if (nonconforming) {
mesh.EnsureNCMesh();
mfem::Array<int> selected{0};
mesh.GeneralRefinement(selected);
mesh.SetCurvature(6);
mesh.Transform([](const mfem::Vector& x, mfem::Vector& y) {
y = x;
y[0] += 0.03 * x[0] * x[1];
y[1] += 0.02 * x[1] * x[2];
y[2] += 0.01 * x[0] * x[2];
});
}
for (int element = 0; element < mesh.GetNE(); ++element) {
mesh.GetElement(element)->SetAttribute(compactified ? 2 : 1);
}
return mesh;
}
/** @brief Reuse the physical Kelvin implementation in both interior and vacuum tests. */
[[nodiscard]] std::unique_ptr<const mapping::compactification::ExteriorDomainMap> make_exterior_map() {
return std::make_unique<mapping::compactification::KelvinCompactification>(serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0});
}
struct GeometryFixture {
mfem::Mesh mesh;
mfem::H1_FECollection displacement_collection;
mfem::FiniteElementSpace displacement_space;
mfem::H1_FECollection compactification_collection;
mfem::FiniteElementSpace compactification_space;
mfem::GridFunction compactification_coordinate;
mapping::DomainMapper mapper;
/** @brief Vary global ordering and interpolation degree without changing the reference geometry. */
explicit GeometryFixture(const bool compactified = false, const mfem::Ordering::Type ordering = mfem::Ordering::byNODES, const int degree = 1, const bool nonconforming = false) :
mesh(make_mesh(compactified, nonconforming)),
displacement_collection(degree, dimension),
displacement_space(&mesh, &displacement_collection, dimension, ordering),
compactification_collection(1, dimension),
compactification_space(&mesh, &compactification_collection),
compactification_coordinate(&compactification_space),
mapper({.dimension = dimension, .vacuum_element_attribute_ID = 2}, make_exterior_map()) {
compactification_coordinate = 0.0;
}
/** @brief Provide an accepted identity map in true-DOF ordering. */
[[nodiscard]] mfem::Vector zero_displacement() const {
mfem::Vector result(displacement_space.GetTrueVSize());
result = 0.0;
return result;
}
/** @brief Construct directions independently of the geometry-step element extraction. */
template <typename Function>
[[nodiscard]] mfem::Vector project_direction(Function&& function) {
mfem::VectorFunctionCoefficient coefficient(dimension, std::forward<Function>(function));
mfem::GridFunction field(&displacement_space);
field.ProjectCoefficient(coefficient);
mfem::Vector result;
field.GetTrueDofs(result);
return result;
}
/** @brief Supply explicit sample rules for every element instead of choosing a hidden quadrature policy. */
[[nodiscard]] std::vector<backend::GeometrySampleRule> sample_rules() {
std::vector<backend::GeometrySampleRule> result;
for (int element = 0; element < mesh.GetNE(); ++element) {
result.push_back({.element = element, .integration_rule = &mfem::IntRules.Get(mesh.GetElementBaseGeometry(element), 2)});
}
return result;
}
};
/** @brief Give det(J(alpha)) = 1 - 2 alpha. */
void compress_x(const mfem::Vector& position, mfem::Vector& value) {
value.SetSize(dimension);
value = 0.0;
value(0) = -2.0 * position(0);
}
/** @brief Give a tangent singularity at one half while both endpoints remain positive. */
void compress_x_and_y(const mfem::Vector& position, mfem::Vector& value) {
compress_x(position, value);
value(1) = -2.0 * position(1);
}
/** @brief Provide a path with no geometry obstruction at positive steps. */
void expand_x(const mfem::Vector& position, mfem::Vector& value) {
value.SetSize(dimension);
value = 0.0;
value(0) = position(0);
}
class NonAffineExteriorMap final : public mapping::compactification::ExteriorDomainMap {
public:
/** @brief Declare that a cubic determinant model cannot certify this map. */
[[nodiscard]] bool is_affine_in_displacement() const noexcept override {
return false;
}
/** @brief Fail loudly if validation mistakenly reaches exterior evaluation. */
[[nodiscard]] mapping::MappingStatus evaluate(const mapping::compactification::ExteriorMapInput&, mapping::compactification::ExteriorMapResult&) const override {
FAIL("A non-affine exterior map must be rejected before evaluation.");
return mapping::MappingStatus::NON_FINITE_MAPPING_RESULT;
}
/** @brief Fail loudly if validation mistakenly reaches exterior differentiation. */
[[nodiscard]] mapping::MappingStatus evaluate_variation(const mapping::compactification::ExteriorMapInput&, const mapping::compactification::ExteriorMapResult&, const mapping::compactification::ExteriorMapDirection&, mapping::compactification::ExteriorMapVariation&) const override {
FAIL("A non-affine exterior map must be rejected before differentiation.");
return mapping::MappingStatus::NON_FINITE_MAPPING_RESULT;
}
/** @brief Identify the deliberately unsupported model in diagnostics. */
[[nodiscard]] std::string_view get_name() const noexcept override {
return "NonAffineExteriorMap";
}
};
}
TEST_CASE("Geometry step finds the first mapping boundary for both global DOF orderings", "[unit][deformation][geometry_step]") {
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
GeometryFixture fixture(false, ordering);
const mfem::Vector accepted = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction(compress_x);
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules, {.fraction_to_boundary = 0.8});
REQUIRE(result);
CHECK(result->limited_by_geometry);
CHECK_THAT(result->boundary_step, WithinAbs(0.5, 2.0e-13));
CHECK_THAT(result->step, WithinAbs(0.4, 2.0e-13));
CHECK_THAT(result->minimum_determinant_at_accepted, WithinAbs(1.0, 2.0e-13));
CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(-1.0, 2.0e-13));
CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.2, 2.0e-13));
CHECK(result->sampled_point_count > 0);
CHECK(result->limiting_element >= 0);
CHECK(result->limiting_rule >= 0);
CHECK(result->limiting_point >= 0);
}
}
TEST_CASE("Geometry step detects a tangent singularity before an admissible endpoint", "[unit][deformation][geometry_step]") {
GeometryFixture fixture(true);
const mfem::Vector accepted = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction(compress_x_and_y);
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules);
REQUIRE(result);
CHECK(result->limited_by_geometry);
CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(1.0, 3.0e-13));
CHECK_THAT(result->boundary_step, WithinAbs(0.5, 3.0e-13));
CHECK_THAT(result->step, WithinAbs(0.45, 3.0e-13));
CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.01, 3.0e-13));
}
TEST_CASE("Geometry step preserves constrained displacement and all samples on curved NC geometry", "[unit][deformation][geometry_step][geometry_reuse]") {
for (const bool compactified : {false, true}) {
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
GeometryFixture fixture(compactified, ordering, 3, true);
REQUIRE(fixture.displacement_space.GetTrueVSize() < fixture.displacement_space.GetVSize());
const auto accepted = fixture.zero_displacement();
const auto direction = fixture.project_direction(compress_x);
auto rules = fixture.sample_rules();
std::size_t expected_samples = 0;
for (int element = 0; element < fixture.mesh.GetNE(); ++element) {
rules.push_back({.element = element, .integration_rule = &mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(element), 4)});
}
for (const auto& rule : rules) { expected_samples += rule.integration_rule->GetNPoints(); }
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space,
fixture.compactification_coordinate, accepted, direction, rules, {.fraction_to_boundary = 0.8});
REQUIRE(result);
CHECK(result->sampled_point_count == expected_samples);
CHECK(result->limited_by_geometry);
CHECK_THAT(result->boundary_step, WithinAbs(0.5, 3.0e-12));
CHECK_THAT(result->step, WithinAbs(0.4, 3.0e-12));
CHECK_THAT(result->minimum_determinant_at_accepted, WithinAbs(1.0, 3.0e-12));
CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(-1.0, 3.0e-12));
CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.2, 3.0e-12));
REQUIRE(result->limiting_rule >= 0);
REQUIRE(static_cast<std::size_t>(result->limiting_rule) < rules.size());
CHECK(result->limiting_element == rules[result->limiting_rule].element);
CHECK(result->limiting_point >= 0);
CHECK(result->limiting_point < rules[result->limiting_rule].integration_rule->GetNPoints());
}
}
}
TEST_CASE("Geometry step honors a positive determinant floor", "[unit][deformation][geometry_step]") {
GeometryFixture fixture;
const mfem::Vector accepted = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction(compress_x);
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules, {.determinant_floor = 0.25, .fraction_to_boundary = 0.8});
REQUIRE(result);
CHECK(result->limited_by_geometry);
CHECK_THAT(result->boundary_step, WithinAbs(0.375, 2.0e-13));
CHECK_THAT(result->step, WithinAbs(0.3, 2.0e-13));
CHECK(result->limiting_point_determinant_at_step > 0.25);
}
TEST_CASE("Geometry step leaves an unconstrained step unchanged", "[unit][deformation][geometry_step]") {
GeometryFixture fixture;
const mfem::Vector accepted = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction(expand_x);
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules);
REQUIRE(result);
CHECK_FALSE(result->limited_by_geometry);
CHECK_THAT(result->boundary_step, WithinAbs(1.0, 2.0e-13));
CHECK_THAT(result->step, WithinAbs(1.0, 2.0e-13));
CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(2.0, 2.0e-13));
CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(2.0, 2.0e-13));
CHECK(result->limiting_element == -1);
CHECK(result->limiting_rule == -1);
CHECK(result->limiting_point == -1);
}
TEST_CASE("Geometry step rescales the polynomial to the requested maximum step", "[unit][deformation][geometry_step]") {
GeometryFixture fixture;
const mfem::Vector zero = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction(compress_x);
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules, {.maximum_step = 0.25});
REQUIRE(result);
CHECK_FALSE(result->limited_by_geometry);
CHECK(result->step == 0.25);
CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(0.5, 2.0e-13));
const auto endpoint = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules, {.maximum_step = 0.5});
REQUIRE(endpoint);
CHECK(endpoint->limited_by_geometry);
CHECK_THAT(endpoint->step, WithinAbs(0.45, 2.0e-13));
}
TEST_CASE("Geometry step rejects invalid inputs as values", "[unit][deformation][geometry_step]") {
GeometryFixture fixture;
mfem::Vector zero = fixture.zero_displacement();
auto rules = fixture.sample_rules();
const auto estimate = [&](const deformation::GeometryStepOptions options = {}) {
return backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, zero, rules, options);
};
SECTION("Invalid numerical controls") {
CHECK_FALSE(estimate({.maximum_step = 0.0}));
CHECK_FALSE(estimate({.maximum_step = std::numeric_limits<double>::infinity()}));
CHECK_FALSE(estimate({.determinant_floor = -1.0}));
CHECK_FALSE(estimate({.fraction_to_boundary = 0.0}));
CHECK_FALSE(estimate({.fraction_to_boundary = 1.0}));
CHECK_FALSE(estimate({.fraction_to_boundary = std::numeric_limits<double>::quiet_NaN()}));
}
SECTION("Empty or invalid rules") {
rules.clear();
CHECK_FALSE(estimate());
rules.push_back({.element = 0, .integration_rule = nullptr});
CHECK_FALSE(estimate());
rules = fixture.sample_rules();
rules[0].element = fixture.mesh.GetNE();
CHECK_FALSE(estimate());
}
SECTION("Invalid coefficients") {
zero.SetSize(1);
CHECK_FALSE(estimate());
zero = fixture.zero_displacement();
zero(0) = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(estimate());
zero = fixture.zero_displacement();
fixture.compactification_coordinate(0) = std::numeric_limits<double>::infinity();
CHECK_FALSE(estimate());
}
SECTION("Folded accepted state") {
const mfem::Vector invalid_accepted = fixture.project_direction(compress_x);
const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, invalid_accepted, zero, rules);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_geometry);
CHECK(result.error().element >= 0);
CHECK(result.error().point >= 0);
}
SECTION("Accepted state must lie strictly above the floor") {
const auto result = estimate({.determinant_floor = 1.0});
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_geometry);
}
}
TEST_CASE("Geometry step rejects a non-affine exterior model before evaluation", "[unit][deformation][geometry_step]") {
GeometryFixture fixture(true);
mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 2}, std::make_unique<NonAffineExteriorMap>());
const mfem::Vector zero = fixture.zero_displacement();
const auto rules = fixture.sample_rules();
const auto result = backend::estimate_geometry_step_limit(mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, zero, rules);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_configuration);
}
TEST_CASE("Geometry step retains the union of nonnested sampling rules", "[unit][deformation][geometry_step]") {
GeometryFixture fixture(false, mfem::Ordering::byVDIM, 3);
const mfem::Vector zero = fixture.zero_displacement();
const mfem::Vector direction = fixture.project_direction([](const mfem::Vector& position, mfem::Vector& value) {
value.SetSize(dimension);
value = 0.0;
const double centered_x = position(0) - 0.5;
value(0) = -2.0 * position(0) + 8.0 * centered_x * centered_x * centered_x;
});
const std::array rules{
backend::GeometrySampleRule{.element = 0, .integration_rule = &mfem::IntRules.Get(mfem::Geometry::CUBE, 1)},
backend::GeometrySampleRule{.element = 0, .integration_rule = &mfem::IntRules.Get(mfem::Geometry::CUBE, 3)}
};
// The cubic direction compresses at the central point, while its x derivative vanishes at the two-point Gauss abscissae.
const auto highest_only = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, std::span(rules).subspan(1));
const auto both = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules);
REQUIRE(highest_only);
REQUIRE(both);
CHECK_FALSE(highest_only->limited_by_geometry);
CHECK(both->limited_by_geometry);
CHECK_THAT(both->boundary_step, WithinAbs(0.5, 2.0e-13));
CHECK(both->limiting_rule == 0);
CHECK(both->sampled_point_count == static_cast<std::size_t>(rules[0].integration_rule->GetNPoints() + rules[1].integration_rule->GetNPoints()));
}
TEST_CASE("Determinant polynomial preserves first roots and dimensional degree", "[unit][deformation][geometry_step]") {
using deformation::detail::DeterminantPolynomial;
using deformation::detail::JacobianEntries;
using deformation::detail::determinant_polynomial;
using deformation::detail::evaluate_polynomial;
using deformation::detail::first_boundary_parameter;
JacobianEntries base{};
JacobianEntries direction{};
for (int component = 0; component < 3; ++component) {
base[component][component] = 1.0;
direction[component][component] = -2.0;
}
for (int spatial_dimension = 1; spatial_dimension <= 3; ++spatial_dimension) {
const DeterminantPolynomial coefficients = determinant_polynomial(base, direction, spatial_dimension, 0.75, 0.125);
for (const double parameter : {0.0, 0.2, 0.6, 1.0}) {
const double expected = std::pow(1.0 - 1.5 * parameter, spatial_dimension) - 0.125;
CHECK_THAT(evaluate_polynomial(coefficients, parameter), WithinAbs(expected, 2.0e-15));
}
}
CHECK(std::isinf(first_boundary_parameter({1.0, 0.0, 0.0, 0.0})));
CHECK(std::isinf(first_boundary_parameter({1.0, 2.0, 3.0, 4.0})));
CHECK_THAT(first_boundary_parameter({1.0, -2.0, 0.0, 0.0}), WithinAbs(0.5, 2.0e-13));
CHECK_THAT(first_boundary_parameter({1.0, -4.0, 4.0, 0.0}), WithinAbs(0.5, 2.0e-13));
CHECK_THAT(first_boundary_parameter({1.0, -5.5, 8.5, -3.0}), WithinAbs(1.0 / 3.0, 2.0e-13));
CHECK_THAT(first_boundary_parameter({1.0e300, -4.0e300, 4.0e300, 0.0}), WithinAbs(0.5, 2.0e-13));
CHECK_THAT(first_boundary_parameter({1.0e300, -5.5e300, 8.5e300, -3.0e300}), WithinAbs(1.0 / 3.0, 2.0e-13));
}

View File

@@ -0,0 +1,13 @@
deformation_test_sources = files(
'nonconforming.cpp',
'reference_geometry_meanfield.cpp',
'domain_actions_meanfield.cpp',
'surface_embedding.cpp',
'radial_extension.cpp',
'composition.cpp',
'computations.cpp',
'surface_metric.cpp',
'geometry_step.cpp',
'surface_geometry.cpp',
'equation_integration.cpp',
)

View File

@@ -0,0 +1,221 @@
#include <algorithm>
#include <bit>
#include <cmath>
#include <cstdint>
#include <memory>
#include <numeric>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/gridfunc.hpp"
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
#include "serif/backend/mfem/model/deformation.hpp"
#include "serif/equations/registry.hpp"
#include "serif/tests/nonconforming.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
namespace fixture = serif::tests::solver;
/** @brief Compare complete deformed coordinates on both sides of every hanging face. */
void check_hanging_geometry(mfem::FiniteElementSpace& space, const std::vector<double>& displacement) {
mfem::GridFunction field(&space);
REQUIRE(displacement.size() == static_cast<std::size_t>(field.Size()));
std::ranges::copy(displacement, field.GetData());
mfem::Mesh& mesh = *space.GetMesh();
mfem::Vector first(3), second(3), first_motion(3), second_motion(3);
int hanging_faces = 0;
for (int face = 0; face < mesh.GetNumFaces(); ++face) {
const mfem::Mesh::FaceInformation information = mesh.GetFaceInformation(face);
if (!information.IsNonconformingFine() || !information.IsLocal()) {
continue;
}
++hanging_faces;
mfem::FaceElementTransformations* transformation = mesh.GetFaceElementTransformations(face);
REQUIRE(transformation->Elem1 != nullptr);
REQUIRE(transformation->Elem2 != nullptr);
const mfem::IntegrationRule& rule = mfem::IntRules.Get(mfem::Geometry::SQUARE, 6);
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
transformation->SetAllIntPoints(&rule.IntPoint(sample));
const mfem::IntegrationPoint& first_point = transformation->Elem1->GetIntPoint();
const mfem::IntegrationPoint& second_point = transformation->Elem2->GetIntPoint();
transformation->Elem1->Transform(first_point, first);
transformation->Elem2->Transform(second_point, second);
field.GetVectorValue(transformation->Elem1No, first_point, first_motion);
field.GetVectorValue(transformation->Elem2No, second_point, second_motion);
first += first_motion;
second += second_motion;
first -= second;
CHECK(first.Norml2() < 2.0e-11);
}
}
REQUIRE(hanging_faces > 0);
}
/** @brief Check independent displacement extraction and its assembled transpose for either MFEM ordering. */
void check_displacement_projection(mfem::FiniteElementSpace& space, const std::vector<double>& component_values) {
serif::discretization::backend::mfem::TermBinding<fixture::Displacement, fixture::Schema> binding{space};
const auto projection = serif::model::backend::mfem::displacement_projection(binding);
const mfem::SparseMatrix* restriction = space.GetConformingRestriction();
REQUIRE(restriction != nullptr);
mfem::Vector local(space.GetVSize()), component(space.GetVSize()), expected(space.GetTrueVSize());
for (int node = 0; node < space.GetNDofs(); ++node) {
for (int axis = 0; axis < 3; ++axis) {
const int column = node + axis * space.GetNDofs();
component[column] = component_values[column];
local[space.DofToVDof(node, axis)] = component[column];
}
}
restriction->Mult(local, expected);
REQUIRE(binding.size() == expected.Size());
mfem::Vector actual(binding.size()), dual(binding.size()), transpose(space.GetVSize());
projection.action()->Mult(component, actual);
actual -= expected;
CHECK(actual.Norml2() < 2.0e-13);
for (int index = 0; index < dual.Size(); ++index) {
dual[index] = std::sin(0.13 * (index + 1));
}
projection.action()->MultTranspose(dual, transpose);
const double reduced_work = expected * dual;
CHECK(std::abs(component * transpose - reduced_work) < 2.0e-11 * std::max(1.0, std::abs(reduced_work)));
const auto sparse = projection.assemble();
REQUIRE(sparse);
mfem::Vector sparse_action(binding.size()), sparse_transpose(space.GetVSize());
sparse->Mult(component, sparse_action);
sparse->MultTranspose(dual, sparse_transpose);
sparse_action -= expected;
sparse_transpose -= transpose;
CHECK(sparse_action.Norml2() < 2.0e-13);
CHECK(sparse_transpose.Norml2() < 2.0e-13);
}
/** @brief Preserve convenience calls while leaving reusable scratch available for repeated actions. */
void check_extension_call_forms(const backend::ConstrainedRadialExtension& extension, const std::vector<double>& surface) {
auto workspace = extension.make_workspace();
std::vector<double> expected(extension.output_size()), actual(extension.output_size());
REQUIRE(extension.apply(surface, expected, workspace));
REQUIRE(extension.apply(surface, actual));
CHECK(actual == expected);
std::vector<double> force(extension.output_size());
for (std::size_t index = 0; index < force.size(); ++index) {
force[index] = std::sin(0.19 * (index + 1));
}
expected.resize(extension.input_size());
actual.resize(extension.input_size());
REQUIRE(extension.apply_transpose(force, expected, workspace));
REQUIRE(extension.apply_transpose(force, actual));
CHECK(actual == expected);
}
}
TEST_CASE("STROID nonconforming geometry retains continuity and complete shape derivatives", "[integration][deformation][nonconforming]") {
const auto resources = std::make_shared<fixture::StellarResources>(serif::tests::nonconforming::mesh(2, 0, 2));
const auto finite_elements = fixture::make_discretization(resources, 2);
std::vector<double> shape(9), direction(9);
for (std::size_t index = 0; index < shape.size(); ++index) {
shape[index] = 0.002 * std::sin(0.37 * (index + 1));
direction[index] = std::cos(0.23 * (index + 1));
}
const auto inputs = backend::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape);
auto cache = serif::utils::dependencies::make_computation_cache<deformation::DeformationFailure>(
backend::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto map = cache.get<deformation::keys::DomainMap>();
const auto extension = cache.get<deformation::keys::VolumeExtension>();
const std::vector<double>& volume = cache.get<deformation::keys::VolumeDisplacement>();
mfem::FiniteElementSpace& space = resources->reference_displacement;
REQUIRE(space.GetTrueVSize() < space.GetVSize());
REQUIRE(map->output_size() == static_cast<std::size_t>(space.GetVSize()));
const auto raw_extension = deformation::make_radial_extension(cache.get<deformation::keys::RadialInterpolation>());
REQUIRE(raw_extension);
const auto raw = deformation::evaluate_displacement(*raw_extension, cache.get<deformation::keys::SurfaceDisplacement>());
REQUIRE(raw);
mfem::Vector local(space.GetVSize()), independent(space.GetTrueVSize()), expected(space.GetVSize());
std::ranges::copy(*raw, local.GetData());
space.GetConformingRestriction()->Mult(local, independent);
space.GetConformingProlongation()->Mult(independent, expected);
mfem::Vector defect(local);
defect -= expected;
REQUIRE(defect.Norml2() > 1.0e-10);
for (int index = 0; index < expected.Size(); ++index) {
CHECK(std::abs(volume[index] - expected[index]) < 2.0e-13);
}
check_hanging_geometry(space, volume);
check_displacement_projection(resources->reference_displacement, volume);
check_displacement_projection(resources->displacement_space, volume);
const auto& reference = *cache.get<deformation::keys::ReferenceSurface>();
const std::vector<double>& surface = cache.get<deformation::keys::SurfaceDisplacement>();
check_extension_call_forms(*extension, surface);
for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) {
for (int component = 0; component < 3; ++component) {
CHECK(std::abs(volume[reference.scalar_dofs[node] + component * space.GetNDofs()] - surface[3 * node + component]) < 2.0e-13);
}
}
mfem::Array<int> boundary(space.GetMesh()->bdr_attributes.Max()), boundary_dofs;
boundary = 0;
boundary[fixture::Schema::boundary_id<serif::discretization::domain::InfinitySurfaceBoundary>() - 1] = 1;
space.GetEssentialVDofs(boundary, boundary_dofs);
for (int dof = 0; dof < boundary_dofs.Size(); ++dof) {
if (boundary_dofs[dof] != 0) {
CHECK(std::abs(volume[dof]) < 2.0e-13);
}
}
auto workspace = deformation::make_deformation_workspace(*map);
std::vector<double> tangent(volume.size()), force(volume.size()), pullback(shape.size());
REQUIRE(deformation::apply_jacobian(*map, shape, direction, tangent, workspace));
for (std::size_t index = 0; index < force.size(); ++index) {
force[index] = std::sin(0.17 * (index + 1));
}
REQUIRE(deformation::apply_jacobian_transpose(*map, shape, force, pullback, workspace));
const double volume_work = std::inner_product(tangent.begin(), tangent.end(), force.begin(), 0.0);
const double shape_work = std::inner_product(direction.begin(), direction.end(), pullback.begin(), 0.0);
CHECK(std::abs(volume_work - shape_work) < 2.0e-11 * std::max({1.0, std::abs(volume_work), std::abs(shape_work)}));
std::vector<double> plus(shape), minus(shape);
constexpr double step = 1.0e-6;
for (std::size_t index = 0; index < shape.size(); ++index) {
plus[index] += step * direction[index];
minus[index] -= step * direction[index];
}
const auto positive = deformation::evaluate_displacement(*map, plus);
const auto negative = deformation::evaluate_displacement(*map, minus);
REQUIRE(positive);
REQUIRE(negative);
double error_squared = 0.0, norm_squared = 0.0;
for (std::size_t index = 0; index < tangent.size(); ++index) {
const double error = ((*positive)[index] - (*negative)[index]) / (2.0 * step) - tangent[index];
error_squared += error * error;
norm_squared += tangent[index] * tangent[index];
}
REQUIRE(norm_squared > 0.0);
CHECK(std::sqrt(error_squared / norm_squared) < 2.0e-9);
backend::ConstrainedRadialExtension::Workspace wrong_workspace;
std::vector<double> output(volume.size());
CHECK_FALSE(extension->apply(surface, output, wrong_workspace));
}
TEST_CASE("Conforming deformation graph preserves the original radial action bit for bit", "[unit][deformation][nonconforming][parity]") {
const auto resources = fixture::make_resources(1.0, 0, 2);
const auto finite_elements = fixture::make_discretization(resources, 2);
const std::vector<double> shape{0.001, -0.002, 0.003, -0.001, 0.002, 0.001, -0.003, 0.002, 0.001};
const auto inputs = backend::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape);
auto cache = serif::utils::dependencies::make_computation_cache<deformation::DeformationFailure>(
backend::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
REQUIRE(cache.try_prepare(inputs));
REQUIRE(resources->reference_displacement.GetConformingProlongation() == nullptr);
check_extension_call_forms(*cache.get<deformation::keys::VolumeExtension>(), cache.get<deformation::keys::SurfaceDisplacement>());
const auto radial = deformation::make_radial_extension(cache.get<deformation::keys::RadialInterpolation>());
REQUIRE(radial);
const auto expected = deformation::evaluate_displacement(*radial, cache.get<deformation::keys::SurfaceDisplacement>());
REQUIRE(expected);
const std::vector<double>& actual = cache.get<deformation::keys::VolumeDisplacement>();
REQUIRE(actual.size() == expected->size());
for (std::size_t index = 0; index < actual.size(); ++index) {
CHECK(std::bit_cast<std::uint64_t>(actual[index]) == std::bit_cast<std::uint64_t>((*expected)[index]));
}
}

View File

@@ -0,0 +1,475 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <numeric>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include "mfem/fem/gridfunc.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
using serif::tests::deformation::PairedMesh;
/** @brief Expose a numbering mismatch without requiring unsupported variable-order nonconforming meshes. */
struct ReorderedCompanionSpace : ::mfem::FiniteElementSpace
{
using ::mfem::FiniteElementSpace::FiniteElementSpace;
/** @brief Preserve the basis, collection and total size while swapping two local scalar coefficients. */
void GetElementDofs(int element, ::mfem::Array<int>& dofs, ::mfem::DofTransformation& transformation) const override
{
::mfem::FiniteElementSpace::GetElementDofs(element, dofs, transformation);
std::swap(dofs[0], dofs[1]);
}
};
/** @brief Keep action tests concise while ensuring compilation failure is reported at its source. */
[[nodiscard]] std::shared_ptr<const deformation::RadialInterpolation> interpolation(PairedMesh& mesh)
{
auto result = mesh.interpolation();
if (!result)
{
INFO(result.error().message);
REQUIRE(result.has_value());
}
return std::make_shared<const deformation::RadialInterpolation>(std::move(*result));
}
/** @brief Preserve the MeanField finite-difference normalization, including its epsilon floor. */
[[nodiscard]] double relative_error(std::span<const double> actual, std::span<const double> expected)
{
double squared_error = 0.0, squared_norm = 0.0;
for (std::size_t entry = 0; entry < actual.size(); ++entry)
{
const double difference = actual[entry] - expected[entry];
squared_error += difference * difference;
squared_norm += expected[entry] * expected[entry];
}
return std::sqrt(squared_error) / std::max(std::sqrt(squared_norm), std::numeric_limits<double>::epsilon());
}
} // namespace
TEST_CASE("Radial interpolation preserves constants on paired logical rays and canonical physical surface nodes", "[unit][deformation]")
{
// Cubic nodes on a refined core project between surface nodes; the unrefined quadratic fixture is exactly aligned.
PairedMesh mesh(1, 0.0, 3);
const auto data = interpolation(mesh);
const auto logical_positions = backend::sample_reference_positions(mesh.scalar_space, *mesh.generated.reference_mesh);
REQUIRE(logical_positions.has_value());
const auto surface = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute);
REQUIRE(surface.has_value());
CHECK(surface->scalar_dofs == data->data().surface_scalar_dofs);
CHECK(std::is_sorted(surface->scalar_dofs.begin(), surface->scalar_dofs.end()));
CHECK(surface->positions.size() == data->surface_node_count());
CHECK(data->data().stellar_radius > 0.0);
CHECK(data->data().infinity_radius > data->data().stellar_radius);
CHECK(data->scalar_dof_count() == static_cast<std::size_t>(mesh.scalar_space.GetNDofs()));
int interpolated_rows = 0;
int centers = 0;
for (std::size_t node = 0; node < data->scalar_dof_count(); ++node)
{
const int begin = data->data().row_offsets[node];
const int end = data->data().row_offsets[node + 1];
if (data->data().logical_radii[node] < 1.0e-14)
{
CHECK(begin == end);
++centers;
continue;
}
const double sum = std::accumulate(data->data().interpolation_weights.begin() + begin, data->data().interpolation_weights.begin() + end, 0.0);
CHECK(sum == Catch::Approx(1.0).margin(2.0e-12));
for (std::size_t component = 0; component < 3; ++component)
{
double interpolated_position = 0.0;
for (int entry = begin; entry < end; ++entry)
{
const int surface_dof = data->data().surface_scalar_dofs[data->data().surface_columns[entry]];
interpolated_position += data->data().interpolation_weights[entry] * (*logical_positions)[surface_dof][component];
}
const double projected_position = data->data().stellar_radius * (*logical_positions)[node][component] / data->data().logical_radii[node];
CHECK(interpolated_position == Catch::Approx(projected_position).margin(2.0e-11));
}
if (end - begin > 1)
{
++interpolated_rows;
}
}
CHECK(centers > 0);
CHECK(interpolated_rows > 0);
}
TEST_CASE("Fused radial extension reproduces the surface fixes center and infinity and preserves original powers", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
const auto quadratic = deformation::make_radial_extension(data);
const auto cubic = deformation::make_radial_extension(data, {.radial_power = 3.0});
REQUIRE(quadratic.has_value());
REQUIRE(cubic.has_value());
CHECK(quadratic->interpolation() == cubic->interpolation());
std::vector<double> surface(quadratic->input_size());
for (std::size_t node = 0; node < data->surface_node_count(); ++node)
{
surface[3 * node] = 0.02;
surface[3 * node + 1] = -0.03;
surface[3 * node + 2] = 0.05;
}
std::vector<double> output(quadratic->output_size());
REQUIRE(quadratic->apply(surface, output));
int shared_nodes = 0, fixed_centers = 0, fixed_infinity = 0;
for (std::size_t node = 0; node < data->scalar_dof_count(); ++node)
{
const double radius = data->data().logical_radii[node];
double weight = 0.0;
if (data->data().stellar_support[node])
{
weight = std::pow(radius / data->data().stellar_radius, 2.0);
CHECK(cubic->radial_weights()[node] == Catch::Approx(std::pow(radius / data->data().stellar_radius, 3.0)).margin(2.0e-14));
}
else if (data->data().vacuum_support[node])
{
weight = (data->data().infinity_radius - radius) / (data->data().infinity_radius - data->data().stellar_radius);
CHECK(cubic->radial_weights()[node] == quadratic->radial_weights()[node]);
}
for (std::size_t component = 0; component < 3; ++component)
{
CHECK(output[node + component * data->scalar_dof_count()] == Catch::Approx(weight * surface[component]).margin(2.0e-13));
}
if (radius < 1.0e-14)
{
CHECK(quadratic->radial_weights()[node] == 0.0);
++fixed_centers;
}
if (std::abs(radius - data->data().infinity_radius) < 1.0e-14)
{
CHECK(quadratic->radial_weights()[node] == Catch::Approx(0.0).margin(1.0e-14));
++fixed_infinity;
}
if (data->data().stellar_support[node] && data->data().vacuum_support[node])
{
CHECK(quadratic->radial_weights()[node] == Catch::Approx(1.0).margin(2.0e-14));
++shared_nodes;
}
}
CHECK(fixed_centers > 0);
CHECK(fixed_infinity > 0);
CHECK(shared_nodes == static_cast<int>(data->surface_node_count()));
for (std::size_t entry = 0; entry < surface.size(); ++entry)
{
surface[entry] = std::sin(0.17 * (entry + 1));
}
REQUIRE(quadratic->apply(surface, output));
for (std::size_t node = 0; node < data->surface_node_count(); ++node)
{
for (std::size_t component = 0; component < 3; ++component)
{
CHECK(output[data->data().surface_scalar_dofs[node] + component * data->scalar_dof_count()] == Catch::Approx(surface[3 * node + component]).margin(2.0e-12));
}
}
}
TEST_CASE("Radial extension derivatives retain centered differences and exact transpose work after refinement", "[unit][deformation]")
{
for (const int refinement : {0, 1})
{
CAPTURE(refinement);
PairedMesh mesh(refinement, 0.1);
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
std::vector<double> direction(extension->input_size()), plus(direction.size()), minus(direction.size());
constexpr double step = 1.0e-6;
for (std::size_t entry = 0; entry < direction.size(); ++entry)
{
const double index = entry + 1;
direction[entry] = std::cos(0.13 * index) - 0.2 * std::sin(0.31 * index);
const double surface = 0.01 * std::sin(0.17 * index);
plus[entry] = surface + step * direction[entry];
minus[entry] = surface - step * direction[entry];
}
std::vector<double> plus_output(extension->output_size()), minus_output(plus_output.size()), derivative(plus_output.size()), force(plus_output.size()), surface_force(direction.size());
REQUIRE(extension->apply(plus, plus_output));
REQUIRE(extension->apply(minus, minus_output));
REQUIRE(extension->apply(direction, derivative));
for (std::size_t entry = 0; entry < derivative.size(); ++entry)
{
plus_output[entry] = (plus_output[entry] - minus_output[entry]) / (2.0 * step);
const double index = entry + 1;
force[entry] = std::sin(0.07 * index) + 0.3 * std::cos(0.11 * index);
}
CHECK(relative_error(derivative, plus_output) < 2.0e-10);
REQUIRE(extension->apply_transpose(force, surface_force));
const double volume_work = std::inner_product(derivative.begin(), derivative.end(), force.begin(), 0.0);
const double surface_work = std::inner_product(direction.begin(), direction.end(), surface_force.begin(), 0.0);
CHECK(std::abs(volume_work - surface_work) <= 5.0e-13 * std::max({1.0, std::abs(volume_work), std::abs(surface_work)}));
}
}
TEST_CASE("Logical radial interpolation is independent of physical flattening", "[unit][deformation]")
{
PairedMesh spherical;
PairedMesh oblate(0, 0.15);
const auto spherical_data = interpolation(spherical);
const auto oblate_data = interpolation(oblate);
CHECK(spherical_data->data().row_offsets == oblate_data->data().row_offsets);
CHECK(spherical_data->data().surface_columns == oblate_data->data().surface_columns);
CHECK(spherical_data->data().interpolation_weights == oblate_data->data().interpolation_weights);
CHECK(spherical_data->data().logical_radii == oblate_data->data().logical_radii);
const auto spherical_surface = backend::sample_reference_surface(spherical.scalar_space, PairedMesh::surface_attribute);
const auto oblate_surface = backend::sample_reference_surface(oblate.scalar_space, PairedMesh::surface_attribute);
REQUIRE(spherical_surface.has_value());
REQUIRE(oblate_surface.has_value());
CHECK(spherical_surface->positions != oblate_surface->positions);
}
TEST_CASE("Radial compilation rejects unsupported spaces broken mesh pairing and invalid foliation", "[unit][deformation]")
{
PairedMesh mesh;
SECTION("Wrong vector storage")
{
::mfem::FiniteElementSpace interleaved(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byVDIM);
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, interleaved, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Different companion collection")
{
::mfem::H1_FECollection different_collection(2, 3);
::mfem::FiniteElementSpace different_vector(mesh.generated.mesh.get(), &different_collection, 3);
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, different_vector, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Matching sizes and collection with different scalar coefficient numbering")
{
ReorderedCompanionSpace reordered(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byNODES);
REQUIRE(reordered.GetVSize() == mesh.vector_space.GetVSize());
REQUIRE(reordered.FEColl() == mesh.scalar_space.FEColl());
const auto result = backend::build_radial_interpolation(mesh.scalar_space, reordered, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization);
CHECK(result.error().element == 0);
}
SECTION("Discontinuous scalar basis")
{
::mfem::L2_FECollection discontinuous(2, 3);
::mfem::FiniteElementSpace scalar(mesh.generated.mesh.get(), &discontinuous);
CHECK_FALSE(backend::sample_reference_surface(scalar, 1));
}
SECTION("Missing boundary")
{
CHECK_FALSE(backend::sample_reference_surface(mesh.scalar_space, 77));
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 77));
}
SECTION("Malformed element support")
{
mesh.stellar_elements.pop_back();
CHECK_FALSE(mesh.interpolation());
}
SECTION("Non-binary element support")
{
mesh.stellar_elements[0] = -1;
CHECK_FALSE(mesh.interpolation());
}
SECTION("Reversed logical shells")
{
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 2, 1));
}
SECTION("Inconsistent paired boundary attributes")
{
mesh.generated.reference_mesh->GetBdrElement(0)->SetAttribute(77);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Inconsistent paired element attributes")
{
mesh.generated.reference_mesh->GetElement(0)->SetAttribute(77);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Inconsistent paired element orientation")
{
int* vertices = mesh.generated.reference_mesh->GetElement(0)->GetVertices();
std::swap(vertices[0], vertices[1]);
CHECK_FALSE(mesh.interpolation());
}
SECTION("Physical geometry is not a logical cubical foliation")
{
CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2));
}
SECTION("Vacuum support incorrectly includes the center")
{
std::fill(mesh.vacuum_elements.begin(), mesh.vacuum_elements.end(), 1);
CHECK_FALSE(mesh.interpolation());
}
}
TEST_CASE("Radial actions reject invalid values lengths and overlapping buffers", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
const auto extension = deformation::make_radial_extension(data);
REQUIRE(extension.has_value());
CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = 0.5}));
CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = std::numeric_limits<double>::infinity()}));
CHECK_FALSE(deformation::make_radial_extension(nullptr));
std::vector<double> surface(extension->input_size(), 1.0), volume(extension->output_size(), 7.0);
CHECK_FALSE(extension->apply(std::span(surface).first(surface.size() - 1), volume));
CHECK(std::all_of(volume.begin(), volume.end(), [](double value)
{
return value == 7.0;
}));
surface[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(extension->apply(surface, volume));
surface[0] = 1.0;
volume[0] = std::numeric_limits<double>::infinity();
CHECK_FALSE(extension->apply_transpose(volume, surface));
std::vector<double> overlap(extension->input_size() + extension->output_size(), 1.0);
CHECK_FALSE(extension->apply(std::span<const double>(overlap).first(extension->input_size()), std::span<double>(overlap).first(extension->output_size())));
CHECK_FALSE(extension->apply_transpose(std::span<const double>(overlap).first(extension->output_size()), std::span<double>(overlap).first(extension->input_size())));
volume[0] = 7.0;
REQUIRE(extension->apply(surface, volume));
REQUIRE(extension->apply_transpose(volume, surface));
}
TEST_CASE("Imported radial interpolation is checked once and owns its validated data", "[unit][deformation]")
{
PairedMesh mesh;
const auto data = interpolation(mesh);
deformation::RadialInterpolationData malformed = data->data();
malformed.surface_columns.front() = static_cast<int>(data->surface_node_count());
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
malformed.row_offsets.back() += 1;
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
const int surface_dof = data->data().surface_scalar_dofs.front();
for (int entry = malformed.row_offsets[surface_dof]; entry < malformed.row_offsets[surface_dof + 1]; ++entry)
{
malformed.interpolation_weights[entry] *= 0.5;
}
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
malformed = data->data();
const int first_entry = malformed.row_offsets[surface_dof];
const int next_entry = malformed.row_offsets[surface_dof + 1];
const int removed_entries = next_entry - first_entry;
REQUIRE(removed_entries > 0);
malformed.surface_columns.erase(malformed.surface_columns.begin() + first_entry, malformed.surface_columns.begin() + next_entry);
malformed.interpolation_weights.erase(malformed.interpolation_weights.begin() + first_entry, malformed.interpolation_weights.begin() + next_entry);
for (std::size_t row = surface_dof + 1; row < malformed.row_offsets.size(); ++row)
{
malformed.row_offsets[row] -= removed_entries;
}
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
SECTION("Nonfinite interpolation weight")
{
malformed = data->data();
malformed.interpolation_weights.front() = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Invalid material support")
{
malformed = data->data();
malformed.stellar_support.front() = 2;
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Nonfinite logical radius")
{
malformed = data->data();
malformed.logical_radii.front() = std::numeric_limits<double>::infinity();
CHECK_FALSE(deformation::make_radial_interpolation(malformed));
}
SECTION("Imported storage cannot mutate a compiled table")
{
deformation::RadialInterpolationData imported = data->data();
const auto checked = deformation::make_radial_interpolation(imported);
REQUIRE(checked);
imported.interpolation_weights.front() = std::numeric_limits<double>::quiet_NaN();
CHECK(checked->data().interpolation_weights == data->data().interpolation_weights);
const auto shared = std::make_shared<const deformation::RadialInterpolation>(*checked);
const auto first = deformation::make_radial_extension(shared);
const auto second = deformation::make_radial_extension(shared, {.radial_power = 3.0});
REQUIRE(first);
REQUIRE(second);
CHECK(first->interpolation() == shared);
CHECK(second->interpolation() == shared);
CHECK_FALSE(std::ranges::equal(first->radial_weights(), second->radial_weights()));
}
}
TEST_CASE("Extended constant vectors retain scalar finite element interpolation between volume nodes", "[unit][deformation]")
{
PairedMesh mesh;
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
constexpr std::array<double, 3> components{1.25, -0.75, 2.5};
std::vector<double> surface(extension->input_size()), volume(extension->output_size());
for (std::size_t node = 0; node < surface.size() / 3; ++node)
{
for (std::size_t component = 0; component < 3; ++component)
{
surface[3 * node + component] = components[component];
}
}
REQUIRE(extension->apply(surface, volume));
::mfem::GridFunction displacement(&mesh.vector_space);
::mfem::GridFunction radial_weight(&mesh.scalar_space);
std::copy(volume.begin(), volume.end(), displacement.GetData());
std::copy(extension->radial_weights().begin(), extension->radial_weights().end(), radial_weight.GetData());
::mfem::Vector value(3);
for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element)
{
const ::mfem::IntegrationPoint& point = ::mfem::Geometries.GetCenter(mesh.generated.mesh->GetElementGeometry(element));
const double weight = radial_weight.GetValue(element, point);
displacement.GetVectorValue(element, point, value);
for (int component = 0; component < 3; ++component)
{
CHECK(value[component] == Catch::Approx(components[component] * weight).margin(2.0e-11));
}
}
}
TEST_CASE("Small radial surface motion preserves orientation before and after uniform refinement", "[unit][deformation]")
{
for (const int refinement : {0, 1})
{
CAPTURE(refinement);
PairedMesh mesh(refinement);
const auto surface_geometry = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute);
REQUIRE(surface_geometry.has_value());
const auto extension = deformation::make_radial_extension(interpolation(mesh));
REQUIRE(extension.has_value());
std::vector<double> surface(extension->input_size()), volume(extension->output_size());
for (std::size_t node = 0; node < surface_geometry->positions.size(); ++node)
{
const std::array<double, 3>& position = surface_geometry->positions[node];
const double radius = std::sqrt(position[0] * position[0] + position[1] * position[1] + position[2] * position[2]);
const double polar_direction = position[2] / radius;
const double amplitude = 0.006 - 0.001 * 0.5 * (3.0 * polar_direction * polar_direction - 1.0);
for (std::size_t component = 0; component < 3; ++component)
{
surface[3 * node + component] = amplitude * position[component] / radius;
}
}
REQUIRE(extension->apply(surface, volume));
::mfem::GridFunction displacement(&mesh.vector_space);
std::copy(volume.begin(), volume.end(), displacement.GetData());
double minimum_determinant = std::numeric_limits<double>::infinity();
::mfem::DenseMatrix gradient;
for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element)
{
::mfem::ElementTransformation& transformation = *mesh.generated.mesh->GetElementTransformation(element);
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), mesh.vector_space.GetFE(element)->GetOrder() + 2);
for (int point = 0; point < rule.GetNPoints(); ++point)
{
transformation.SetIntPoint(&rule.IntPoint(point));
displacement.GetVectorGradient(transformation, gradient);
for (int component = 0; component < 3; ++component)
{
gradient(component, component) += 1.0;
}
minimum_determinant = std::min(minimum_determinant, gradient.Det());
}
}
CHECK(minimum_determinant > 0.0);
}
}

View File

@@ -0,0 +1,210 @@
#include <algorithm>
#include <cmath>
#include <memory>
#include <numbers>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "mfem/fem/coefficient.hpp"
#include "mfem/fem/gridfunc.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/backend/mfem/discretization/boundary_trace.hpp"
#include "serif/discretization/blocks/fields.hpp"
#include "serif/backend/mfem/geometry/deformation/inputs.hpp"
#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp"
#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
namespace finite_elements = serif::discretization::backend::mfem;
namespace domain = serif::discretization::domain;
using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
/** @brief Retain the original eight-coordinate indexing example without imposing a higher-order physical field. */
struct LinearEnthalpy : serif::discretization::blocks::term<::serif::tests::equations::enthalpy>
{
using FunctionalSpace = serif::discretization::functional_space::DiscreteFunctionalSpace<serif::discretization::functional_space::H1, 1, 0>;
using Support = domain::StellarDomains;
using Quantity = serif::dimensions::SpecificEnthalpy;
struct value : serif::discretization::blocks::value_block_base
{
};
struct residual : serif::discretization::blocks::residual_block_base
{
};
};
using AlternateBoundarySchema = domain::schema::DomainSchema<typename Schema::domain_ids_type, domain::ids::lists::BoundaryIDList<domain::ids::BoundaryID<domain::StellarSurfaceBoundary, 2>, domain::ids::BoundaryID<domain::InfinitySurfaceBoundary, 1>>, domain::relation::lists::RelationList<>>;
/** @brief Resolve boundary coordinates independently using MFEM's serial essential true-DOF list. */
[[nodiscard]] std::vector<int> essential_dofs(const mfem::FiniteElementSpace& space, const int attribute)
{
mfem::Array<int> marker(space.GetMesh()->bdr_attributes.Max()), selected;
marker = 0;
marker[attribute - 1] = 1;
space.GetEssentialTrueDofs(marker, selected);
return std::vector<int>(selected.begin(), selected.end());
}
} // namespace
TEST_CASE("MeanField canonical scalar coordinates retain exact gather and transpose scatter indexing", "[unit][deformation][meanfield]")
{
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON);
mfem::H1_FECollection collection(1, 3);
mfem::FiniteElementSpace space(&mesh, &collection);
finite_elements::TermBinding<LinearEnthalpy, Schema> binding(space);
REQUIRE(binding.size() == 8);
const mfem::Array<int> selected{1, 3, 6};
const auto rows = std::make_shared<const finite_elements::DofSelection>(binding, selected);
const auto trace = finite_elements::make_boundary_trace(rows, binding);
mfem::Vector full(binding.size()), sampled(rows->size()), scattered(binding.size());
for (int dof = 0; dof < full.Size(); ++dof)
{
full[dof] = 10.0 + dof;
}
trace.interpolation().Mult(full, sampled);
CHECK(sampled[0] == 11.0);
CHECK(sampled[1] == 13.0);
CHECK(sampled[2] == 16.0);
trace.interpolation().MultTranspose(sampled, scattered);
for (int dof = 0; dof < scattered.Size(); ++dof)
{
CHECK(scattered[dof] == (dof == 1 || dof == 3 || dof == 6 ? full[dof] : 0.0));
}
CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array<int>{3, 1}}), std::invalid_argument);
CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array<int>{1, 1}}), std::invalid_argument);
CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array<int>{-1}}), std::invalid_argument);
CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array<int>{8}}), std::invalid_argument);
}
TEST_CASE("MeanField surface coordinates resolve semantic boundary IDs and scalar companion nodes", "[unit][deformation][meanfield]")
{
serif::tests::deformation::PairedMesh fixture(0, 0.1, 3, 4);
const auto primary = backend::deformation_discretization<Schema>(fixture.scalar_space, fixture.vector_space, *fixture.generated.reference_mesh);
const auto alternate = backend::deformation_discretization<AlternateBoundarySchema>(fixture.scalar_space, fixture.vector_space, *fixture.generated.reference_mesh);
const auto stellar = backend::sample_reference_surface(fixture.scalar_space, primary.surface_attribute);
const auto infinity = backend::select_surface_scalar_dofs(fixture.scalar_space, alternate.surface_attribute);
REQUIRE(stellar);
REQUIRE(infinity);
CHECK(stellar->scalar_dofs == essential_dofs(fixture.scalar_space, 1));
CHECK(*infinity == essential_dofs(fixture.scalar_space, 2));
CHECK(stellar->scalar_dofs != *infinity);
CHECK(std::is_sorted(stellar->scalar_dofs.begin(), stellar->scalar_dofs.end()));
REQUIRE(stellar->scalar_dofs.size() > 0);
CHECK(stellar->scalar_dofs.size() < static_cast<std::size_t>(fixture.scalar_space.GetTrueVSize()));
const auto repeated = backend::select_surface_scalar_dofs(fixture.scalar_space, primary.surface_attribute);
REQUIRE(repeated);
CHECK(*repeated == stellar->scalar_dofs);
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM})
{
mfem::FiniteElementSpace vector_space(fixture.generated.mesh.get(), &fixture.collection, 3, ordering);
const std::vector<int> vector_dofs = essential_dofs(vector_space, primary.surface_attribute);
CHECK(vector_dofs.size() == 3 * stellar->scalar_dofs.size());
mfem::VectorFunctionCoefficient coordinates(3, [](const mfem::Vector& position, mfem::Vector& value)
{
value = position;
});
mfem::GridFunction projected(&vector_space);
projected.ProjectCoefficient(coordinates);
for (std::size_t node = 0; node < stellar->positions.size(); ++node)
{
for (int component = 0; component < 3; ++component)
{
CHECK_THAT(stellar->positions[node][component], WithinAbs(projected[vector_space.DofToVDof(stellar->scalar_dofs[node], component)], 2.0e-13));
}
}
}
}
TEST_CASE("MeanField stellar surface metrics include tagged internal interfaces", "[unit][deformation][meanfield]")
{
serif::tests::deformation::PairedMesh fixture;
int selected_faces = 0;
for (int boundary = 0; boundary < fixture.generated.mesh->GetNBE(); ++boundary)
{
if (fixture.generated.mesh->GetBdrAttribute(boundary) == fixture.surface_attribute)
{
const int face = fixture.generated.mesh->GetBdrElementFaceIndex(boundary);
REQUIRE(face >= 0);
CHECK(fixture.generated.mesh->GetFaceInformation(face).IsOfFaceType(mfem::FaceType::Interior));
++selected_faces;
}
}
REQUIRE(selected_faces > 0);
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(embedding);
const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding);
REQUIRE(metric);
REQUIRE(metric->size() == reference->scalar_dofs.size());
for (const double diagonal : *metric)
{
CHECK(std::isfinite(diagonal));
CHECK(diagonal > 0.0);
}
}
TEST_CASE("MeanField STROID spherical volume retains its original mesh accuracy", "[integration][deformation][geometry][meanfield]")
{
// Match sandbox.smesh: two refinements, quartic geometry, stellar radius 1 and reference infinity 5.
auto generated = serif::tests::deformation::make_mesh(2, 0.0, 4, 5.0);
long double volume = 0.0L;
int stellar_elements = 0;
for (int element = 0; element < generated.mesh->GetNE(); ++element)
{
if (!Schema::domain_id_belongs_to<domain::StellarDomains>(generated.mesh->GetAttribute(element)))
{
continue;
}
mfem::ElementTransformation& transformation = *generated.mesh->GetElementTransformation(element);
const mfem::IntegrationRule& rule = mfem::IntRules.Get(transformation.GetGeometryType(), transformation.OrderW());
for (int point = 0; point < rule.GetNPoints(); ++point)
{
const mfem::IntegrationPoint& sample = rule.IntPoint(point);
transformation.SetIntPoint(&sample);
volume += sample.weight * transformation.Weight();
}
++stellar_elements;
}
REQUIRE(stellar_elements > 0);
CHECK_THAT(static_cast<double>(volume), WithinRel(4.0 * std::numbers::pi / 3.0, 1.0e-6));
}
TEST_CASE("Shared MFEM reference tracking distinguishes coordinate and label dependencies", "[unit][deformation][reference_tracking]") {
namespace shared = serif::backend::mfem::shared;
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON);
mfem::H1_FECollection collection(1, 3);
mfem::FiniteElementSpace space(&mesh, &collection);
const auto topology = shared::mesh_snapshot(&mesh, false, false);
const auto materials = shared::mesh_snapshot(&mesh, false, true);
const auto coordinates = shared::mesh_snapshot(&mesh, true, true);
const auto original_space = shared::space_snapshot(space);
REQUIRE(shared::mesh_matches(topology, &mesh));
REQUIRE(shared::mesh_matches(coordinates, &mesh));
mesh.Transform([](const mfem::Vector& position, mfem::Vector& transformed) {
transformed = position;
transformed[0] *= 1.2;
});
CHECK(shared::mesh_matches(topology, &mesh));
CHECK(shared::mesh_matches(materials, &mesh));
CHECK_FALSE(shared::mesh_matches(coordinates, &mesh));
CHECK(shared::space_snapshot(space) == original_space);
mesh.GetElement(0)->SetAttribute(9);
CHECK(shared::mesh_matches(topology, &mesh));
CHECK_FALSE(shared::mesh_matches(materials, &mesh));
mesh.GetBdrElement(0)->SetAttribute(9);
CHECK_FALSE(shared::mesh_matches(topology, &mesh));
mfem::FiniteElementSpace other(&mesh, &collection);
CHECK_FALSE(shared::space_snapshot(other) == original_space);
CHECK_FALSE(shared::mesh_matches(topology, nullptr));
CHECK(shared::mesh_matches(shared::mesh_snapshot(nullptr, true, true), nullptr));
}

View File

@@ -0,0 +1,369 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <limits>
#include <numbers>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/geometry/deformation/actions.hpp"
#include "serif/geometry/deformation/surface_embedding.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
/** @brief Resolve low harmonic degrees without relying on a particular mesh backend. */
[[nodiscard]] std::vector<std::array<double, 3>> surface_positions(const std::array<double, 3>& center = {}) {
std::vector<std::array<double, 3>> positions;
constexpr std::size_t node_count = 96;
const double golden_angle = std::numbers::pi * (3.0 - std::sqrt(5.0));
for (std::size_t node = 0; node < node_count; ++node) {
const double cosine = 1.0 - 2.0 * (static_cast<double>(node) + 0.5) / node_count;
const double sine = std::sqrt(1.0 - cosine * cosine);
const double azimuth = golden_angle * static_cast<double>(node);
const double radius = 2.0 * (1.0 + 0.1 * cosine * cosine);
positions.push_back({center[0] + radius * sine * std::cos(azimuth), center[1] + radius * sine * std::sin(azimuth), center[2] + radius * cosine});
}
positions.push_back({center[0], center[1], center[2] + 2.0});
positions.push_back({center[0], center[1], center[2] - 2.0});
return positions;
}
/** @brief Select an analytic mode independently of its expected position in the ordered basis. */
[[nodiscard]] std::size_t mode_index(const deformation::SurfaceEmbedding& surface, int degree, int order) {
const auto selected = std::ranges::find(surface.modes(), deformation::SphericalHarmonicMode{.degree = degree, .order = order});
REQUIRE(selected != surface.modes().end());
return static_cast<std::size_t>(selected - surface.modes().begin());
}
/** @brief Use deterministic nonconstant data to expose ordering errors in displacement and force actions. */
[[nodiscard]] std::vector<double> varying_values(std::size_t size, double scale = 1.0) {
std::vector<double> result(size);
for (std::size_t index = 0; index < size; ++index) {
result[index] = scale * std::sin(0.37 * static_cast<double>(index + 1));
}
return result;
}
/** @brief Evaluate virtual work without depending on the tested transpose implementation. */
[[nodiscard]] double work(std::span<const double> first, std::span<const double> second) {
REQUIRE(first.size() == second.size());
long double result = 0.0L;
for (std::size_t index = 0; index < first.size(); ++index) {
result += static_cast<long double>(first[index]) * second[index];
}
return static_cast<double>(result);
}
/** @brief Compare against a centered finite difference and independent dual work at MeanField tolerances. */
void check_linear_actions(const deformation::SurfaceEmbedding& surface) {
const std::vector<double> parameters = varying_values(surface.input_size(), 0.03);
const std::vector<double> direction = varying_values(surface.input_size(), 0.07);
const std::vector<double> force = varying_values(surface.output_size());
std::vector<double> action(surface.output_size());
std::vector<double> transferred(surface.input_size());
REQUIRE(surface.apply(direction, action));
REQUIRE(surface.apply_transpose(force, transferred));
const double scale = std::max({1.0, std::sqrt(work(action, action) * work(force, force)), std::sqrt(work(direction, direction) * work(transferred, transferred))});
CHECK(std::abs(work(action, force) - work(direction, transferred)) < 2e-13 * scale);
constexpr double epsilon = 1e-5;
std::vector<double> plus(parameters);
std::vector<double> minus(parameters);
for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) {
plus[parameter] += epsilon * direction[parameter];
minus[parameter] -= epsilon * direction[parameter];
}
std::vector<double> plus_value(surface.output_size());
std::vector<double> minus_value(surface.output_size());
REQUIRE(surface.apply(plus, plus_value));
REQUIRE(surface.apply(minus, minus_value));
for (std::size_t index = 0; index < action.size(); ++index) {
plus_value[index] = (plus_value[index] - minus_value[index]) / (2.0 * epsilon) - action[index];
}
CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-10);
}
}
TEST_CASE("Nodal surface coordinates remain physical radial displacement lengths", "[unit][deformation]") {
const std::array<double, 3> center{0.3, -0.2, 0.4};
const auto positions = surface_positions(center);
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, positions);
REQUIRE(surface);
CHECK(surface->has_nodal_parameters());
CHECK(surface->modes().empty());
CHECK(surface->nodal_basis().empty());
REQUIRE(surface->input_size() == positions.size());
REQUIRE(surface->output_size() == 3 * positions.size());
std::vector<double> parameters(surface->input_size(), 0.125);
std::vector<double> displacement(surface->output_size(), 8.0);
REQUIRE(surface->apply(parameters, displacement));
for (std::size_t node = 0; node < positions.size(); ++node) {
double moved_radius_squared = 0.0;
for (std::size_t component = 0; component < 3; ++component) {
const double radial_coordinate = positions[node][component] - center[component];
CHECK(std::abs(surface->radial_directions()[node][component] * surface->reference_radii()[node] - radial_coordinate) < 2e-15);
CHECK(displacement[3 * node + component] == 0.125 * surface->radial_directions()[node][component]);
moved_radius_squared += std::pow(radial_coordinate + displacement[3 * node + component], 2);
}
CHECK(std::abs(std::sqrt(moved_radius_squared) - surface->reference_radii()[node] - 0.125) < 2e-15);
}
}
TEST_CASE("Nodal surface actions preserve exact derivatives and virtual work", "[unit][deformation]") {
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions());
REQUIRE(surface);
check_linear_actions(*surface);
}
TEST_CASE("Nodal radial derivatives retain MeanField nonproportional directions and original work tolerances", "[unit][deformation]") {
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions());
REQUIRE(surface);
std::vector<double> parameters(surface->input_size());
std::vector<double> direction(surface->input_size());
std::vector<double> plus(surface->input_size());
std::vector<double> minus(surface->input_size());
constexpr double step = 1e-6;
for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) {
const double index = static_cast<double>(parameter + 1);
parameters[parameter] = 0.013 * std::sin(0.37 * index);
direction[parameter] = std::cos(0.19 * index) - 0.21 * std::sin(0.43 * index);
plus[parameter] = parameters[parameter] + step * direction[parameter];
minus[parameter] = parameters[parameter] - step * direction[parameter];
}
std::vector<double> plus_value(surface->output_size());
std::vector<double> minus_value(surface->output_size());
std::vector<double> action(surface->output_size());
auto workspace = deformation::make_deformation_workspace(*surface);
REQUIRE(surface->apply(plus, plus_value));
REQUIRE(surface->apply(minus, minus_value));
REQUIRE(deformation::apply_jacobian(*surface, parameters, direction, action, workspace));
for (std::size_t entry = 0; entry < action.size(); ++entry) {
plus_value[entry] = (plus_value[entry] - minus_value[entry]) / (2.0 * step) - action[entry];
}
CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-11);
std::vector<double> surface_force(surface->output_size());
for (std::size_t entry = 0; entry < surface_force.size(); ++entry) {
const double index = static_cast<double>(entry + 1);
surface_force[entry] = std::sin(0.23 * index) + 0.17 * std::cos(0.31 * index);
}
std::vector<double> parameter_force(surface->input_size());
REQUIRE(deformation::apply_jacobian_transpose(*surface, parameters, surface_force, parameter_force, workspace));
const double surface_work = work(action, surface_force);
const double parameter_work = work(direction, parameter_force);
CHECK(std::abs(surface_work - parameter_work) <= 3e-14 * std::max({1.0, std::abs(surface_work), std::abs(parameter_work)}));
std::ranges::fill(parameter_force, 1.0);
REQUIRE(deformation::apply_pullback_derivative(*surface, parameters, direction, surface_force, parameter_force, workspace));
CHECK(work(parameter_force, parameter_force) == 0.0);
}
TEST_CASE("Reference radial geometry rejects empty nonfinite and centered nodes", "[unit][deformation]") {
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, {}));
const std::array<std::array<double, 3>, 1> center_node{{{0.0, 0.0, 0.0}}};
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, center_node));
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
for (std::size_t component = 0; component < 3; ++component) {
std::array<double, 3> center{};
center[component] = invalid;
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, surface_positions()));
auto positions = surface_positions();
positions[4][component] = invalid;
CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions));
CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions));
}
}
}
TEST_CASE("Reference radius calculation does not overflow a representable length", "[unit][deformation]") {
const std::array<std::array<double, 3>, 2> positions{{{1e200, 0.0, 0.0}, {0.0, 1e-200, 0.0}}};
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
REQUIRE(surface);
CHECK(surface->reference_radii()[0] == 1e200);
CHECK(surface->reference_radii()[1] == 1e-200);
CHECK(surface->radial_directions()[0][0] == 1.0);
CHECK(surface->radial_directions()[1][1] == 1.0);
}
TEST_CASE("Harmonic surfaces retain physical mode counts and canonical ordering", "[unit][deformation]") {
const auto positions = surface_positions();
const std::array<int, 5> expected_counts{5, 13, 19, 23, 25};
for (int cutoff = 0; cutoff <= 4; ++cutoff) {
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = cutoff}, positions);
REQUIRE(surface);
CHECK_FALSE(surface->has_nodal_parameters());
CHECK(surface->input_size() == static_cast<std::size_t>(expected_counts[static_cast<std::size_t>(cutoff)]));
std::size_t index = 0;
for (int degree = 0; degree <= 4; ++degree) {
for (int order = -std::min(degree, cutoff); order <= std::min(degree, cutoff); ++order) {
CHECK((surface->modes()[index++] == deformation::SphericalHarmonicMode{.degree = degree, .order = order}));
}
}
}
const auto monopole = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 0}, positions);
REQUIRE(monopole);
CHECK(monopole->input_size() == 1);
}
TEST_CASE("Harmonic surfaces reject invalid or unresolved mode selections", "[unit][deformation]") {
for (const deformation::SphericalHarmonicSurface model : {
deformation::SphericalHarmonicSurface{.maximum_degree = -1},
deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = -1},
deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = 5},
deformation::SphericalHarmonicSurface{.maximum_degree = std::numeric_limits<int>::max()}}) {
const auto surface = deformation::build_surface_embedding(model, surface_positions());
REQUIRE_FALSE(surface);
CHECK(surface.error().reason == deformation::DeformationFailureReason::invalid_configuration);
}
const auto unresolved = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 10}, surface_positions());
REQUIRE_FALSE(unresolved);
CHECK(unresolved.error().reason == deformation::DeformationFailureReason::unresolved_surface);
CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, {}));
}
TEST_CASE("Real harmonics preserve analytic normalization phase and signed-order convention", "[unit][deformation]") {
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
REQUIRE(surface);
constexpr double pi = std::numbers::pi;
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
const double x = surface->radial_directions()[node][0];
const double y = surface->radial_directions()[node][1];
const double z = surface->radial_directions()[node][2];
/// Analytic low-degree values protect physical coefficient conventions independently of the recurrence.
const auto check_mode = [&](int degree, int order, double exact) {
CHECK(std::abs(surface->nodal_basis()[node * surface->input_size() + mode_index(*surface, degree, order)] - exact) < 2e-14);
};
check_mode(0, 0, 1.0 / std::sqrt(4.0 * pi));
check_mode(1, 0, std::sqrt(3.0 / (4.0 * pi)) * z);
check_mode(1, 1, -std::sqrt(3.0 / (4.0 * pi)) * x);
check_mode(1, -1, -std::sqrt(3.0 / (4.0 * pi)) * y);
check_mode(2, 0, std::sqrt(5.0 / (16.0 * pi)) * (3.0 * z * z - 1.0));
check_mode(2, -2, std::sqrt(15.0 / (4.0 * pi)) * x * y);
check_mode(2, 2, std::sqrt(15.0 / (16.0 * pi)) * (x * x - y * y));
}
std::vector<double> parameters(surface->input_size(), 0.0);
parameters[0] = 0.125 * std::sqrt(4.0 * pi);
std::vector<double> displacement(surface->output_size());
REQUIRE(surface->apply(parameters, displacement));
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
for (std::size_t component = 0; component < 3; ++component) {
CHECK(std::abs(displacement[3 * node + component] - 0.125 * surface->radial_directions()[node][component]) < 1e-15);
}
}
}
TEST_CASE("Restricting harmonic orders does not renormalize surviving coefficients", "[unit][deformation]") {
const auto positions = surface_positions();
const auto full = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions);
REQUIRE(full);
for (int cutoff : {0, 1, 2}) {
const auto restricted = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_azimuthal_order = cutoff}, positions);
REQUIRE(restricted);
for (std::size_t mode = 0; mode < restricted->input_size(); ++mode) {
const deformation::SphericalHarmonicMode selected = restricted->modes()[mode];
const std::size_t full_mode = mode_index(*full, selected.degree, selected.order);
for (std::size_t node = 0; node < full->surface_node_count(); ++node) {
CHECK(restricted->nodal_basis()[node * restricted->input_size() + mode] == full->nodal_basis()[node * full->input_size() + full_mode]);
}
}
}
}
TEST_CASE("Harmonic surface actions preserve exact derivatives virtual work and the reference basis", "[unit][deformation]") {
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
REQUIRE(surface);
const std::vector<double> original_basis(surface->nodal_basis().begin(), surface->nodal_basis().end());
check_linear_actions(*surface);
CHECK(std::ranges::equal(original_basis, surface->nodal_basis()));
}
TEST_CASE("Harmonic surfaces reject aliased modes even when enough nodes are present", "[unit][deformation]") {
std::vector<std::array<double, 3>> corners;
for (double x : {-0.5, 0.5}) {
for (double y : {-0.5, 0.5}) {
for (double z : {-0.5, 0.5}) {
corners.push_back({x, y, z});
}
}
}
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2, .maximum_azimuthal_order = 0}, corners);
REQUIRE_FALSE(surface);
CHECK(surface.error().reason == deformation::DeformationFailureReason::unresolved_surface);
}
TEST_CASE("Nodal and harmonic representations produce identical nodal surface motion", "[unit][deformation]") {
const auto positions = surface_positions();
const auto harmonic = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions);
const auto nodal = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
REQUIRE(harmonic);
REQUIRE(nodal);
const std::vector<double> parameters = varying_values(harmonic->input_size(), 0.03);
std::vector<double> nodal_parameters(nodal->input_size());
for (std::size_t node = 0; node < nodal_parameters.size(); ++node) {
long double amplitude = 0.0L;
for (std::size_t mode = 0; mode < harmonic->input_size(); ++mode) {
amplitude += static_cast<long double>(harmonic->nodal_basis()[node * harmonic->input_size() + mode]) * parameters[mode];
}
nodal_parameters[node] = static_cast<double>(amplitude);
}
std::vector<double> harmonic_motion(harmonic->output_size());
std::vector<double> nodal_motion(nodal->output_size());
REQUIRE(harmonic->apply(parameters, harmonic_motion));
REQUIRE(nodal->apply(nodal_parameters, nodal_motion));
// Independent reductions must agree to roundoff, including optimized builds.
const double scale = std::sqrt(std::max(work(harmonic_motion, harmonic_motion), work(nodal_motion, nodal_motion)));
for (std::size_t index = 0; index < harmonic_motion.size(); ++index) {
harmonic_motion[index] -= nodal_motion[index];
}
CHECK(std::sqrt(work(harmonic_motion, harmonic_motion)) <= 8.0 * std::numeric_limits<double>::epsilon() * scale);
}
TEST_CASE("Surface actions reject wrong sizes nonfinite inputs and overlapping storage", "[unit][deformation]") {
for (bool harmonic : {false, true}) {
const auto positions = surface_positions();
const auto surface = harmonic ? deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions) : deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
REQUIRE(surface);
std::vector<double> parameters(surface->input_size(), 0.0);
std::vector<double> displacement(surface->output_size(), 0.0);
const std::vector<double> wrong(surface->input_size() + 1, 0.0);
CHECK_FALSE(surface->apply(wrong, displacement));
CHECK_FALSE(surface->apply(parameters, parameters));
CHECK_FALSE(surface->apply_transpose(parameters, parameters));
CHECK_FALSE(surface->apply_transpose(displacement, displacement));
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
parameters.back() = invalid;
CHECK_FALSE(surface->apply(parameters, displacement));
parameters.back() = 0.0;
displacement.back() = invalid;
CHECK_FALSE(surface->apply_transpose(displacement, parameters));
displacement.back() = 0.0;
}
const std::span<double> buffer(displacement);
CHECK_FALSE(surface->apply(buffer.first(surface->input_size()), buffer));
CHECK_FALSE(surface->apply_transpose(buffer, buffer.first(surface->input_size())));
}
}
TEST_CASE("Finite harmonic inputs cannot silently overflow surface motion or modal forces", "[unit][deformation]") {
const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions());
REQUIRE(surface);
const double maximum = std::numeric_limits<double>::max();
std::vector<double> parameters(surface->input_size());
std::vector<double> displacement(surface->output_size());
for (std::size_t mode = 0; mode < surface->input_size(); ++mode) {
parameters[mode] = std::copysign(maximum, surface->nodal_basis()[mode]);
}
CHECK_FALSE(surface->apply(parameters, displacement));
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
for (std::size_t component = 0; component < 3; ++component) {
displacement[3 * node + component] = maximum * surface->radial_directions()[node][component];
REQUIRE(std::isfinite(displacement[3 * node + component]));
}
}
const auto transpose = surface->apply_transpose(displacement, parameters);
REQUIRE_FALSE(transpose);
CHECK(transpose.error().reason == deformation::DeformationFailureReason::non_finite);
}

View File

@@ -0,0 +1,174 @@
#include <algorithm>
#include <cmath>
#include <memory>
#include <numeric>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/geometry/deformation/radial_extension.hpp"
#include "serif/geometry/deformation/surface_embedding.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
using serif::tests::deformation::PairedMesh;
/** @brief Preserve the old physical Roche equipotential and its asymmetric companion potential. */
struct BinaryRochePotential {
double primary_mass;
double companion_mass;
double separation;
/** @brief Evaluate gravitational and centrifugal contributions in units with G equal to one. */
[[nodiscard]] double operator()(double x, double y, double z) const {
const double primary_distance = std::sqrt(x * x + y * y + z * z);
const double companion_offset = x - separation;
const double companion_distance = std::sqrt(companion_offset * companion_offset + y * y + z * z);
const double total_mass = primary_mass + companion_mass;
const double center_of_mass = separation * companion_mass / total_mass;
const double angular_speed_squared = total_mass / (separation * separation * separation);
const double rotation_radius_squared = (x - center_of_mass) * (x - center_of_mass) + y * y;
return -primary_mass / primary_distance - companion_mass / companion_distance - 0.5 * angular_speed_squared * rotation_radius_squared;
}
/** @brief Preserve the first closed-envelope radial crossing and the original root tolerances. */
[[nodiscard]] double radial_intersection(const std::array<double, 3>& direction, double target_potential, double reference_scale) const {
double lower_radius = 1.0e-8 * reference_scale;
const double lower_value = (*this)(lower_radius * direction[0], lower_radius * direction[1], lower_radius * direction[2]) - target_potential;
REQUIRE(lower_value < 0.0);
double upper_radius = lower_radius;
double upper_value = lower_value;
constexpr int bracket_samples = 512;
for (int sample = 1; sample <= bracket_samples && upper_value <= 0.0; ++sample) {
upper_radius = 0.45 * separation * static_cast<double>(sample) / bracket_samples;
upper_value = (*this)(upper_radius * direction[0], upper_radius * direction[1], upper_radius * direction[2]) - target_potential;
}
REQUIRE(upper_value > 0.0);
for (int iteration = 0; iteration < 80; ++iteration) {
const double middle_radius = 0.5 * (lower_radius + upper_radius);
const double middle_value = (*this)(middle_radius * direction[0], middle_radius * direction[1], middle_radius * direction[2]) - target_potential;
if (middle_value > 0.0) {
upper_radius = middle_radius;
} else {
lower_radius = middle_radius;
}
}
return 0.5 * (lower_radius + upper_radius);
}
};
/**
* @brief Sample the actual finite-element trace using its moved tangents for physical area weights.
* Only trace basis functions affect this restriction; no ideal continuous radial surface is substituted.
* The independent boundary calculation avoids requiring an interior volume extension to test a surface representation.
*/
template <typename PointError>
[[nodiscard]] std::array<double, 2> sample_surface_error(PairedMesh& mesh, const deformation::ReferenceSurface& reference, std::span<const double> displacement, const PointError& point_error) {
std::vector<int> surface_indices(mesh.scalar_space.GetNDofs(), -1);
for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) {
surface_indices[reference.scalar_dofs[node]] = static_cast<int>(node);
}
double maximum_error = 0.0, squared_error = 0.0, surface_area = 0.0;
int samples = 0;
::mfem::Array<int> boundary_dofs;
::mfem::Vector shape, position(3);
::mfem::DenseMatrix derivatives;
for (int boundary = 0; boundary < mesh.generated.mesh->GetNBE(); ++boundary) {
if (mesh.generated.mesh->GetBdrAttribute(boundary) != PairedMesh::surface_attribute) {
continue;
}
::mfem::ElementTransformation& transformation = *mesh.generated.mesh->GetBdrElementTransformation(boundary);
const ::mfem::FiniteElement& element = *mesh.scalar_space.GetBE(boundary);
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), 2 * element.GetOrder());
mesh.scalar_space.GetBdrElementDofs(boundary, boundary_dofs);
shape.SetSize(boundary_dofs.Size());
derivatives.SetSize(boundary_dofs.Size(), 2);
for (int point = 0; point < rule.GetNPoints(); ++point) {
const ::mfem::IntegrationPoint& integration_point = rule.IntPoint(point);
transformation.Transform(integration_point, position);
transformation.SetIntPoint(&integration_point);
::mfem::DenseMatrix tangents(transformation.Jacobian());
element.CalcShape(integration_point, shape);
element.CalcDShape(integration_point, derivatives);
for (int node = 0; node < boundary_dofs.Size(); ++node) {
const int surface_node = surface_indices[::mfem::FiniteElementSpace::DecodeDof(boundary_dofs[node])];
REQUIRE(surface_node >= 0);
for (int component = 0; component < 3; ++component) {
const double value = displacement[3 * surface_node + component];
position[component] += shape[node] * value;
for (int tangent = 0; tangent < 2; ++tangent) {
tangents(component, tangent) += derivatives(node, tangent) * value;
}
}
}
double area_squared = 0.0;
for (int component = 0; component < 3; ++component) {
const int second = (component + 1) % 3;
const int third = (component + 2) % 3;
const double normal = tangents(second, 0) * tangents(third, 1) - tangents(third, 0) * tangents(second, 1);
area_squared += normal * normal;
}
const double weight = integration_point.weight * std::sqrt(area_squared);
const double error = point_error(position);
REQUIRE(std::isfinite(error));
REQUIRE(weight > 0.0);
maximum_error = std::max(maximum_error, error);
squared_error += error * error * weight;
surface_area += weight;
++samples;
}
}
REQUIRE(samples > 0);
REQUIRE(surface_area > 0.0);
return {maximum_error, std::sqrt(squared_error / surface_area)};
}
}
TEST_CASE("Nodal radial displacement resolves oblate and binary Roche envelopes between surface nodes", "[unit][deformation]") {
// MeanField's baseline sandbox mesh uses two refinements, order-four geometry and order-three displacement.
PairedMesh mesh(2, 0.0, 3, 4, 5.0);
const auto reference = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute);
REQUIRE(reference.has_value());
const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(surface.has_value());
const double reference_scale = std::accumulate(surface->reference_radii().begin(), surface->reference_radii().end(), 0.0) / surface->surface_node_count();
const double equatorial_radius = 1.08 * reference_scale;
const double polar_radius = 0.94 * reference_scale;
std::vector<double> oblate_parameters(surface->input_size()), roche_parameters(surface->input_size());
const BinaryRochePotential potential{.primary_mass = 1.0, .companion_mass = 0.7, .separation = 4.0 * reference_scale};
const double target_potential = potential(0.0, 0.0, reference_scale);
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
const std::array<double, 3>& direction = surface->radial_directions()[node];
const double oblate_radius = 1.0 / std::sqrt((direction[0] * direction[0] + direction[1] * direction[1]) / (equatorial_radius * equatorial_radius) + direction[2] * direction[2] / (polar_radius * polar_radius));
oblate_parameters[node] = oblate_radius - surface->reference_radii()[node];
roche_parameters[node] = potential.radial_intersection(direction, target_potential, reference_scale) - surface->reference_radii()[node];
}
std::vector<double> oblate_displacement(surface->output_size()), roche_displacement(surface->output_size());
REQUIRE(surface->apply(oblate_parameters, oblate_displacement));
REQUIRE(surface->apply(roche_parameters, roche_displacement));
for (std::size_t node = 0; node < surface->surface_node_count(); ++node) {
std::array<double, 3> oblate_position{}, roche_position{};
for (std::size_t component = 0; component < 3; ++component) {
oblate_position[component] = reference->positions[node][component] + oblate_displacement[3 * node + component];
roche_position[component] = reference->positions[node][component] + roche_displacement[3 * node + component];
}
const double level = (oblate_position[0] * oblate_position[0] + oblate_position[1] * oblate_position[1]) / (equatorial_radius * equatorial_radius) + oblate_position[2] * oblate_position[2] / (polar_radius * polar_radius);
CHECK(std::abs(level - 1.0) <= 3.0e-12);
CHECK(std::abs(potential(roche_position[0], roche_position[1], roche_position[2]) - target_potential) <= 2.0e-11 * std::max(1.0, std::abs(target_potential)));
}
const auto oblate_error = sample_surface_error(mesh, *reference, oblate_displacement, [equatorial_radius, polar_radius](const ::mfem::Vector& position) {
return std::abs((position[0] * position[0] + position[1] * position[1]) / (equatorial_radius * equatorial_radius) + position[2] * position[2] / (polar_radius * polar_radius) - 1.0);
});
const auto roche_error = sample_surface_error(mesh, *reference, roche_displacement, [&potential, target_potential](const ::mfem::Vector& position) {
return std::abs(potential(position[0], position[1], position[2]) - target_potential) / std::max(1.0, std::abs(target_potential));
});
CAPTURE(oblate_error, roche_error);
CHECK(oblate_error[0] < 5.0e-3);
CHECK(oblate_error[1] < 1.0e-3);
CHECK(roche_error[0] < 2.0e-2);
CHECK(roche_error[1] < 5.0e-3);
const double companion_facing = potential.radial_intersection({1.0, 0.0, 0.0}, target_potential, reference_scale);
const double companion_opposing = potential.radial_intersection({-1.0, 0.0, 0.0}, target_potential, reference_scale);
CHECK(std::abs(companion_facing - companion_opposing) > 1.0e-3 * reference_scale);
}

View File

@@ -0,0 +1,295 @@
#include <algorithm>
#include <cmath>
#include <memory>
#include <numbers>
#include <utility>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/bilinearform.hpp"
#include "mfem/fem/bilininteg.hpp"
#include "mfem/fem/coefficient.hpp"
#include "mfem/fem/gridfunc.hpp"
#include "serif/backend/mfem/shared/boundary_elements.hpp"
#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp"
#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp"
#include "serif/geometry/deformation/composition.hpp"
#include "serif/geometry/deformation/linearization.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = deformation::backend::mfem;
using PairedMesh = serif::tests::deformation::PairedMesh;
/** @brief Independently integrate an analytic monopole or axial-dipole vector trace using MeanField's declared mass quadrature. */
[[nodiscard]] double analytic_trace_norm(PairedMesh& fixture, int degree) {
/// Project analytic Cartesian data at FE nodes; evaluating it directly at quadrature points would change the realized trace.
::mfem::VectorFunctionCoefficient coefficient(3, [degree](const ::mfem::Vector& position, ::mfem::Vector& value) {
const double radius = position.Norml2();
value.SetSize(3);
value = 0.0;
if (radius <= 0.0) {
return;
}
const double harmonic = degree == 0 ? 1.0 / std::sqrt(4.0 * std::numbers::pi) : std::sqrt(3.0 / (4.0 * std::numbers::pi)) * position[2] / radius;
for (int component = 0; component < 3; ++component) {
value[component] = harmonic * position[component] / radius;
}
});
::mfem::GridFunction tangent(&fixture.vector_space);
tangent.ProjectCoefficient(coefficient);
::mfem::Array<int> dofs;
::mfem::Vector local;
::mfem::Vector shape;
long double work = 0.0L;
for (int boundary = 0; boundary < fixture.generated.mesh->GetNBE(); ++boundary) {
if (fixture.generated.mesh->GetBdrAttribute(boundary) != fixture.surface_attribute) {
continue;
}
const ::mfem::FiniteElement& element = *fixture.vector_space.GetBE(boundary);
::mfem::ElementTransformation& transformation = *fixture.vector_space.GetBdrElementTransformation(boundary);
REQUIRE(fixture.vector_space.GetBdrElementVDofs(boundary, dofs) == nullptr);
tangent.GetSubVector(dofs, local);
REQUIRE(local.Size() == 3 * element.GetDof());
shape.SetSize(element.GetDof());
const ::mfem::IntegrationRule& rule = ::mfem::MassIntegrator::GetRule(element, element, transformation);
for (int point_index = 0; point_index < rule.GetNPoints(); ++point_index) {
const ::mfem::IntegrationPoint& point = rule.IntPoint(point_index);
transformation.SetIntPoint(&point);
element.CalcShape(point, shape);
long double norm_squared = 0.0L;
for (int component = 0; component < 3; ++component) {
long double value = 0.0L;
for (int node = 0; node < element.GetDof(); ++node) {
value += static_cast<long double>(shape[node]) * local[node + component * element.GetDof()];
}
norm_squared += value * value;
}
work += point.weight * transformation.Weight() * norm_squared;
}
}
REQUIRE(std::isfinite(work));
REQUIRE(work > 0.0L);
return static_cast<double>(work);
}
}
TEST_CASE("Harmonic reference metrics measure the actual vector finite-element surface trace", "[integration][deformation]") {
for (double flattening : {0.0, 0.2}) {
// MeanField uses cubic displacement traces; refine the surface to resolve all 25 degree-four modes.
PairedMesh fixture(1, flattening, 3);
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
for (int cutoff : {0, 4}) {
const auto embedding = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = cutoff}, reference->positions);
REQUIRE(embedding);
const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding);
REQUIRE(metric);
REQUIRE(metric->size() == static_cast<std::size_t>(cutoff == 0 ? 5 : 25));
CHECK((*metric)[0] == Catch::Approx(analytic_trace_norm(fixture, 0)).epsilon(5e-13));
CHECK((*metric)[cutoff == 0 ? 1 : 2] == Catch::Approx(analytic_trace_norm(fixture, 1)).epsilon(5e-13));
for (double entry : *metric) {
CHECK(std::isfinite(entry));
CHECK(entry > 0.0);
}
}
}
}
TEST_CASE("Nodal radial metrics match the independent assembled boundary mass diagonal", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(embedding);
const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding);
REQUIRE(metric);
::mfem::Array<int> marker(fixture.generated.mesh->bdr_attributes.Max());
marker = 0;
marker[fixture.surface_attribute - 1] = 1;
::mfem::BilinearForm mass(&fixture.scalar_space);
mass.AddBoundaryIntegrator(new ::mfem::MassIntegrator(), marker);
mass.Assemble();
mass.Finalize();
::mfem::Vector diagonal;
mass.SpMat().GetDiag(diagonal);
REQUIRE(metric->size() == reference->scalar_dofs.size());
for (std::size_t node = 0; node < metric->size(); ++node) {
CHECK((*metric)[node] == Catch::Approx(diagonal[reference->scalar_dofs[node]]).epsilon(5e-14));
}
}
TEST_CASE("Reference metric compilation rejects incompatible trace ordering and geometry", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2}, reference->positions);
REQUIRE(embedding);
deformation::ReferenceSurface changed = *reference;
SECTION("Canonical scalar node ordering changed") {
REQUIRE(changed.scalar_dofs.size() > 1);
std::swap(changed.scalar_dofs[0], changed.scalar_dofs[1]);
}
SECTION("Reference positions changed at fixed dimensions") {
changed.positions[0][0] += 0.01;
}
SECTION("A reference node is missing") {
changed.positions.pop_back();
changed.scalar_dofs.pop_back();
}
const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, changed, *embedding);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization);
}
TEST_CASE("Reference metrics reject an embedding from different points despite equal dimensions", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
auto positions = reference->positions;
positions[0][0] += 0.01;
const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions);
REQUIRE(embedding);
const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization);
}
TEST_CASE("Reference metrics reject a changed mesh before assembling parameter norms", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(embedding);
/// Moving the reference coordinates must invalidate the old trace even if all vector dimensions stay equal.
::mfem::VectorFunctionCoefficient translation(3, [](const ::mfem::Vector& old_position, ::mfem::Vector& new_position) {
new_position = old_position;
new_position[0] += 0.01;
});
fixture.generated.mesh->Transform(translation);
const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding);
REQUIRE_FALSE(result);
CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization);
}
TEST_CASE("Reference metrics require the selected scalar surface trace", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(embedding);
CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.infinity_attribute, *reference, *embedding));
CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.scalar_space, 999, *reference, *embedding));
CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.vector_space, fixture.surface_attribute, *reference, *embedding));
const deformation::ReferenceSurface copied = *reference;
CHECK(backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, copied, *embedding));
}
TEST_CASE("Harmonic and nodal surfaces couple to identical stellar and vacuum volume fields", "[integration][deformation]") {
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
auto harmonic = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2}, reference->positions);
auto nodal = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
REQUIRE(harmonic);
REQUIRE(nodal);
auto interpolation = fixture.interpolation();
REQUIRE(interpolation);
auto extension = deformation::make_radial_extension(std::make_shared<const deformation::RadialInterpolation>(std::move(*interpolation)));
REQUIRE(extension);
const auto harmonic_map = std::make_shared<const deformation::SurfaceEmbedding>(std::move(*harmonic));
const auto nodal_map = std::make_shared<const deformation::SurfaceEmbedding>(std::move(*nodal));
const auto volume_extension = std::make_shared<const deformation::RadialExtension>(std::move(*extension));
const auto harmonic_domain = deformation::compose_deformation(harmonic_map, volume_extension);
const auto nodal_domain = deformation::compose_deformation(nodal_map, volume_extension);
REQUIRE(harmonic_domain);
REQUIRE(nodal_domain);
std::vector<double> harmonic_parameters(harmonic_map->input_size());
for (std::size_t parameter = 0; parameter < harmonic_parameters.size(); ++parameter) {
harmonic_parameters[parameter] = 0.001 * std::sin(0.37 * static_cast<double>(parameter + 1));
}
std::vector<double> nodal_parameters(nodal_map->input_size());
for (std::size_t node = 0; node < nodal_parameters.size(); ++node) {
long double value = 0.0L;
for (std::size_t parameter = 0; parameter < harmonic_parameters.size(); ++parameter) {
value += static_cast<long double>(harmonic_map->nodal_basis()[node * harmonic_parameters.size() + parameter]) * harmonic_parameters[parameter];
}
nodal_parameters[node] = static_cast<double>(value);
}
const auto harmonic_volume = deformation::evaluate_displacement(*harmonic_domain, harmonic_parameters);
const auto nodal_volume = deformation::evaluate_displacement(*nodal_domain, nodal_parameters);
REQUIRE(harmonic_volume);
REQUIRE(nodal_volume);
REQUIRE(harmonic_volume->size() == nodal_volume->size());
long double difference_norm = 0.0L;
long double reference_norm = 0.0L;
for (std::size_t entry = 0; entry < harmonic_volume->size(); ++entry) {
const long double difference = (*harmonic_volume)[entry] - (*nodal_volume)[entry];
difference_norm += difference * difference;
reference_norm += static_cast<long double>((*nodal_volume)[entry]) * (*nodal_volume)[entry];
}
REQUIRE(reference_norm > 0.0L);
CHECK(std::sqrt(difference_norm / reference_norm) < 3e-13);
const auto infinity_nodes = backend::select_surface_scalar_dofs(fixture.scalar_space, fixture.infinity_attribute);
REQUIRE(infinity_nodes);
for (int node : *infinity_nodes) {
for (int component = 0; component < 3; ++component) {
CHECK(std::abs((*harmonic_volume)[node + component * fixture.scalar_space.GetNDofs()]) < 1e-15);
}
}
}
TEST_CASE("Shared boundary traversal rejects malformed coordinates before numerical assembly", "[integration][deformation][backend]") {
namespace shared = serif::backend::mfem::shared;
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
std::vector<int> selected = reference->scalar_dofs;
REQUIRE_FALSE(selected.empty());
SECTION("Repeated scalar nodes") {
selected.push_back(selected.front());
}
SECTION("An out-of-range scalar node") {
selected.front() = fixture.scalar_space.GetNDofs();
}
SECTION("A negative scalar node") {
selected.front() = -1;
}
SECTION("No selected coordinates for an existing boundary") {
selected.clear();
}
bool assembled = false;
const shared::BoundaryStatus status = shared::for_each_boundary_element(fixture.scalar_space, fixture.surface_attribute, selected,
[&](const shared::BoundaryElement&) -> shared::BoundaryStatus {
assembled = true;
return {};
});
REQUIRE_FALSE(status);
CHECK(status.error().incompatible_coordinates);
CHECK_FALSE(assembled);
}
TEST_CASE("Shared boundary traversal stops and identifies a rejected element assembly", "[integration][deformation][backend]") {
namespace shared = serif::backend::mfem::shared;
PairedMesh fixture;
const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute);
REQUIRE(reference);
int visited = 0;
const shared::BoundaryStatus status = shared::for_each_boundary_element(fixture.scalar_space, fixture.surface_attribute, reference->scalar_dofs,
[&](const shared::BoundaryElement&) -> shared::BoundaryStatus {
++visited;
return std::unexpected(shared::BoundaryFailure{.message = "Rejected local numerical contribution.", .incompatible_coordinates = false});
});
REQUIRE_FALSE(status);
CHECK(visited == 1);
CHECK_FALSE(status.error().incompatible_coordinates);
CHECK(status.error().message == "Rejected local numerical contribution.");
REQUIRE(status.error().element >= 0);
CHECK(fixture.scalar_space.GetMesh()->GetBdrAttribute(status.error().element) == fixture.surface_attribute);
}