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,8 @@
# GENERATIVE AI DISCLOSURE
- Generative AI has been used to develop the test suite of this code base. Specifically ChatGPT 6 astra max.
- There are two primary ways this was used
- Porting tests from a previous version of this code base to the current version. Specific instructions were given and code was manually reviewed to ensure correctness. This porting was required as significant parts of the interface have changed; however, the goal is to maintain the same numeric behavior.
- Developing new tests based on preexisting tests (e.g a test ensuring volume is consistent under an affine transformation was written and then we asked GenAI to write a number of other tests with a number of different affine transformations)
- The backend consolidation added AI-assisted regressions comparing element coordinate extraction and assembly against native MFEM operations, and exercising the shared Newton algorithm with standard-library vectors without an MFEM dependency.
- The TOP-export regressions were developed with AI assistance: analytic polytrope and manufactured geometry checks, physical-unit invariance, deformed/nonconforming FE sampling, axis handling, state preservation, publication failures, and an optional check with the actual TOP reader.

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

Binary file not shown.

View File

@@ -0,0 +1,59 @@
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/discretization/domain/mesh/topology.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/discretization/domain/schema/validation/all.hpp"
namespace {
namespace domain = serif::discretization::domain;
/** @brief Represent the core, envelope, vacuum and their interfaces without a finite-element backend. */
[[nodiscard]] domain::mesh::MeshTopologyData layered_topology() {
return {
.elementDomainIDs = {1, 2, 3},
.faceElements = {{.firstElementID = 0, .secondElementID = 1}, {.firstElementID = 1, .secondElementID = 2}, {.firstElementID = 2}},
.boundaryElementBoundaryIDs = {1, 2},
.boundaryElementFaceIDs = {1, 2}
};
}
}
TEST_CASE("Physical schema validation uses backend-independent mesh connectivity", "[unit][discretization][domain]") {
const domain::mesh::MeshTopology topology(layered_topology());
const auto result = domain::schema::validation::validate_schema<domain::schema::CoreEnvelopeVacuumDomainSchema>(topology);
REQUIRE(result);
CHECK(result.relation_count() == 7);
CHECK(topology.element_neighbors(0) == std::vector<int>{1});
CHECK((topology.element_neighbors(1) == std::vector<int>{0, 2}));
CHECK(topology.boundary_elements_on_face(1) == std::vector<int>{0});
CHECK(topology.boundary_element_face(1) == 2);
CHECK(topology.face_elements(2).secondElementID == -1);
}
TEST_CASE("Neutral topology rejects invalid indexing before deriving adjacency", "[unit][discretization][domain]") {
domain::mesh::MeshTopologyData data = layered_topology();
SECTION("A face names an absent volume element") {
data.faceElements.front().firstElementID = 3;
}
SECTION("Boundary labels and faces have different counts") {
data.boundaryElementFaceIDs.pop_back();
}
SECTION("A boundary names an absent face") {
data.boundaryElementFaceIDs.front() = 3;
}
CHECK_THROWS(domain::mesh::MeshTopology(data));
}
TEST_CASE("Neutral topology retains unresolved and multiply labelled boundary faces", "[unit][discretization][domain]") {
domain::mesh::MeshTopologyData data = layered_topology();
data.boundaryElementBoundaryIDs.insert(data.boundaryElementBoundaryIDs.end(), {7, 8});
data.boundaryElementFaceIDs.insert(data.boundaryElementFaceIDs.end(), {1, -1});
const domain::mesh::MeshTopology topology(data);
CHECK((topology.boundary_elements_on_face(1) == std::vector<int>{0, 2}));
CHECK(topology.boundary_element_face(3) == -1);
CHECK(topology.boundary_element_boundary_id(3) == 8);
CHECK(topology.element_neighbors(-1).empty());
CHECK(topology.boundary_elements_on_face(100).empty());
CHECK(topology.element_domain_id(100) == 0);
}

Binary file not shown.

View File

@@ -1,42 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include "serif/discretization/domain/physical_domains.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/discretization/domain/schema/utils.hpp"
#include "serif/discretization/domain/types.hpp"
#include "serif/tests/test_tags.hpp"
#include "serif/tests/discritization/domain/domain_test_utils.hpp"
namespace domain = serif::discretization::domain;
namespace schema = domain::schema;
TEST_CASE(
"Domain Schema Builds Exact MFEM Attribute Markers",
tags::domain &tags::utils &tags::unit
) {
using Schema = schema::CoreEnvelopeVacuumDomainSchema;
const mfem::Mesh mesh = domain_test_utils::make_layered_mesh();
const mfem::Array<int> stellarMarker = schema::make_attribute_marker<domain::StellarDomains, Schema>(mesh);
const mfem::Array<int> vacuumMarker = schema::make_attribute_marker<domain::VacuumDomain, Schema>(mesh);
const mfem::Array<int> allMarker = schema::make_attribute_marker<domain::AllDomains, Schema>(mesh);
REQUIRE(stellarMarker.Size() == 3);
REQUIRE(vacuumMarker.Size() == 3);
REQUIRE(allMarker.Size() == 3);
CHECK(stellarMarker[0] == 1);
CHECK(stellarMarker[1] == 1);
CHECK(stellarMarker[2] == 0);
CHECK(vacuumMarker[0] == 0);
CHECK(vacuumMarker[1] == 0);
CHECK(vacuumMarker[2] == 1);
CHECK(allMarker[0] == 1);
CHECK(allMarker[1] == 1);
CHECK(allMarker[2] == 1);
}

View File

@@ -3,11 +3,11 @@
#include <string_view> #include <string_view>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <mfem.hpp> #include "mfem/mesh/mesh.hpp"
#include <stroid/stroid.h> #include <stroid/stroid.h>
#include "serif/discretization/domain/schema/schemas.hpp" #include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/discretization/domain/schema/validation/all.hpp" #include "serif/backend/mfem/discretization/schema.hpp"
#include "serif/tests/test_tags.hpp" #include "serif/tests/test_tags.hpp"
#include "serif/tests/discritization/domain/domain_test_utils.hpp" #include "serif/tests/discritization/domain/domain_test_utils.hpp"
@@ -23,7 +23,7 @@ TEST_CASE(
const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); const mfem::Mesh mesh = domain_test_utils::make_layered_mesh();
const auto validation = const auto validation =
schema_validation::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh); serif::backend::mfem::discretization::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh);
REQUIRE(validation.valid()); REQUIRE(validation.valid());
REQUIRE(validation.relationResults.size() == 7); REQUIRE(validation.relationResults.size() == 7);
@@ -53,7 +53,7 @@ TEST_CASE(
*/ */
const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(false, true); const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(false, true);
const auto validation = const auto validation =
schema_validation::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh); serif::backend::mfem::discretization::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh);
CHECK_FALSE(validation.valid()); CHECK_FALSE(validation.valid());
REQUIRE(validation.relationResults.size() == 7); REQUIRE(validation.relationResults.size() == 7);
CHECK(validation.failed_relation_count() == 1); CHECK(validation.failed_relation_count() == 1);
@@ -145,7 +145,7 @@ TEST_CASE(
* accept a mesh generated under another numbering convention. * accept a mesh generated under another numbering convention.
*/ */
const auto productionValidation = const auto productionValidation =
schema_validation::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(*stroidMesh.mesh); serif::backend::mfem::discretization::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(*stroidMesh.mesh);
CHECK_FALSE(productionValidation.valid()); CHECK_FALSE(productionValidation.valid());
CHECK(productionValidation.failed_relation_count() > 0); CHECK(productionValidation.failed_relation_count() > 0);
@@ -157,7 +157,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 3, {2, 2, 2, 2, 1, 2, 2, 2, 2}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 3, {2, 2, 2, 2, 1, 2, 2, 2, 2}, {});
const auto validation = schema_validation::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh); const auto validation = serif::backend::mfem::discretization::validate_schema<schema::CoreEnvelopeVacuumDomainSchema>(mesh);
CHECK_FALSE(validation.valid()); CHECK_FALSE(validation.valid());
REQUIRE(validation.relationResults.size() == 7); REQUIRE(validation.relationResults.size() == 7);

View File

@@ -1,9 +1,9 @@
#include <vector> #include <vector>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <mfem.hpp> #include "mfem/mesh/mesh.hpp"
#include "serif/discretization/domain/mesh/topology.hpp" #include "serif/backend/mfem/discretization/topology.hpp"
#include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/physical_domains.hpp"
#include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/relations.hpp"
#include "serif/discretization/domain/relation/validation/runtime.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp"
@@ -20,6 +20,7 @@ namespace schema = domain::schema;
namespace validation = schema::validation; namespace validation = schema::validation;
using domain::mesh::MeshTopology; using domain::mesh::MeshTopology;
using serif::backend::mfem::discretization::make_mesh_topology;
TEST_CASE( TEST_CASE(
"Domain Boundary Accepts A Complete Internal Stellar Vacuum Interface", "Domain Boundary Accepts A Complete Internal Stellar Vacuum Interface",
@@ -28,7 +29,7 @@ TEST_CASE(
std::vector<domain_test_utils::BoundaryEdge> boundaries{{.firstVertexId = 1, .secondVertexId = 4, .attribute = 1}}; std::vector<domain_test_utils::BoundaryEdge> boundaries{{.firstVertexId = 1, .secondVertexId = 4, .attribute = 1}};
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 3}, boundaries); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 3}, boundaries);
const MeshTopology topology{mesh}; const MeshTopology topology = make_mesh_topology(mesh);
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::StellarSurfaceBoundary, domain::StellarDomains, domain::StellarSurfaceBoundary, domain::StellarDomains,
@@ -63,7 +64,7 @@ TEST_CASE(
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(1, 1, attributes, boundaries); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(1, 1, attributes, boundaries);
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::InfinitySurfaceBoundary, domain::VacuumDomain>>:: domain::InfinitySurfaceBoundary, domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK(result); CHECK(result);
} }
@@ -76,7 +77,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::InfinitySurfaceBoundary, domain::VacuumDomain>>:: domain::InfinitySurfaceBoundary, domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK( CHECK(
@@ -92,7 +93,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::InfinitySurfaceBoundary, domain::VacuumDomain>>:: domain::InfinitySurfaceBoundary, domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK( CHECK(
@@ -112,7 +113,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::StellarSurfaceBoundary, domain::StellarDomains, domain::StellarSurfaceBoundary, domain::StellarDomains,
domain::VacuumDomain>>:: domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
@@ -131,7 +132,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::StellarSurfaceBoundary, domain::StellarDomains, domain::StellarSurfaceBoundary, domain::StellarDomains,
domain::VacuumDomain>>:: domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryExpectedFaceIsUntagged); CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryExpectedFaceIsUntagged);
@@ -150,7 +151,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::StellarSurfaceBoundary, domain::StellarDomains, domain::StellarSurfaceBoundary, domain::StellarDomains,
domain::VacuumDomain>>:: domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK( CHECK(
@@ -172,7 +173,7 @@ TEST_CASE(
const auto result = validation::RelationValidator<relation::DomainBoundary< const auto result = validation::RelationValidator<relation::DomainBoundary<
domain::StellarSurfaceBoundary, domain::StellarDomains, domain::StellarSurfaceBoundary, domain::StellarDomains,
domain::VacuumDomain>>:: domain::VacuumDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryAbsent); CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryAbsent);

View File

@@ -1,7 +1,7 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <mfem.hpp> #include "mfem/mesh/mesh.hpp"
#include "serif/discretization/domain/mesh/topology.hpp" #include "serif/backend/mfem/discretization/topology.hpp"
#include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/physical_domains.hpp"
#include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/relations.hpp"
#include "serif/discretization/domain/relation/validation/runtime.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp"
@@ -18,13 +18,14 @@ namespace schema = domain::schema;
namespace validation = schema::validation; namespace validation = schema::validation;
using domain::mesh::MeshTopology; using domain::mesh::MeshTopology;
using serif::backend::mfem::discretization::make_mesh_topology;
TEST_CASE( TEST_CASE(
"Connected Accepts Face Connected Atomic And Composite Domains", "Connected Accepts Face Connected Atomic And Composite Domains",
tags::unit &tags::mesh &tags::utils &tags::domain tags::unit &tags::mesh &tags::utils &tags::domain
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); const mfem::Mesh mesh = domain_test_utils::make_layered_mesh();
const MeshTopology topology{mesh}; const MeshTopology topology = make_mesh_topology(mesh);
const auto coreResult = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(topology); const auto coreResult = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(topology);
@@ -47,7 +48,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 2}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 2}, {});
const auto result = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); const auto result = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::DomainAbsent); CHECK(result.failure == validation::RelationValidationFailure::DomainAbsent);
@@ -62,7 +63,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 1, {1, 2, 1}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 1, {1, 2, 1}, {});
const auto result = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); const auto result = validation::RelationValidator<relation::FullyConnected<domain::CoreDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::DomainDisconnected); CHECK(result.failure == validation::RelationValidationFailure::DomainDisconnected);

View File

@@ -1,9 +1,9 @@
#include <vector> #include <vector>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <mfem.hpp> #include "mfem/mesh/mesh.hpp"
#include "serif/discretization/domain/mesh/topology.hpp" #include "serif/backend/mfem/discretization/topology.hpp"
#include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/physical_domains.hpp"
#include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/relations.hpp"
#include "serif/discretization/domain/relation/validation/runtime.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp"
@@ -20,13 +20,14 @@ namespace schema = domain::schema;
namespace validation = schema::validation; namespace validation = schema::validation;
using domain::mesh::MeshTopology; using domain::mesh::MeshTopology;
using serif::backend::mfem::discretization::make_mesh_topology;
TEST_CASE( TEST_CASE(
"Inscribed Accepts Nested Atomic And Composite Domains", "Inscribed Accepts Nested Atomic And Composite Domains",
tags::unit &tags::mesh &tags::utils &tags::domain tags::unit &tags::mesh &tags::utils &tags::domain
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); const mfem::Mesh mesh = domain_test_utils::make_layered_mesh();
const MeshTopology topology{mesh}; const MeshTopology topology = make_mesh_topology(mesh);
const auto coreResult = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(topology); const auto coreResult = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(topology);
@@ -43,7 +44,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {2, 2, 2, 2}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {2, 2, 2, 2}, {});
const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::InnerDomainAbsent); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainAbsent);
@@ -55,7 +56,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(1, 1, {1}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(1, 1, {1}, {});
const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::OuterDomainAbsent); CHECK(result.failure == validation::RelationValidationFailure::OuterDomainAbsent);
@@ -67,7 +68,7 @@ TEST_CASE(
) { ) {
const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {1, 2, 2, 2}, {}); const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {1, 2, 2, 2}, {});
const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); const auto result = validation::RelationValidator<relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesMeshBoundary); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesMeshBoundary);
@@ -87,7 +88,7 @@ TEST_CASE(
const auto result = validation::RelationValidator< const auto result = validation::RelationValidator<
relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>:: relation::Inscribed<domain::CoreDomain, domain::EnvelopeDomain>>::
template validate<schema::CoreEnvelopeVacuumDomainSchema>(MeshTopology{mesh}); template validate<schema::CoreEnvelopeVacuumDomainSchema>(make_mesh_topology(mesh));
CHECK_FALSE(result); CHECK_FALSE(result);
CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesUnexpectedDomain); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesUnexpectedDomain);

View File

@@ -0,0 +1,279 @@
// MeanField field_dof_map.cpp and field_mfem.cpp invariants expressed through serial TermBinding.
#include <algorithm>
#include <array>
#include <limits>
#include <memory>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/gridfunc.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/backend/mfem/discretization/dof_selection.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace
{
namespace discretization = serif::discretization;
namespace backend = discretization::backend::mfem;
namespace blocks = discretization::blocks;
namespace domain = discretization::domain;
using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema;
using AlternateSchema = domain::schema::DomainSchema<domain::ids::lists::DomainIDList<domain::ids::DomainID<domain::CoreDomain, 11>, domain::ids::DomainID<domain::EnvelopeDomain, 17>, domain::ids::DomainID<domain::VacuumDomain, 29>>, domain::ids::lists::BoundaryIDList<>, domain::relation::lists::RelationList<>>;
/** @brief A stellar velocity represented by independent discontinuous scalar components. */
struct DiscontinuousVelocity final : blocks::field
{
struct vector final : blocks::term<DiscontinuousVelocity>
{
using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace<discretization::functional_space::L2, 2, 1>;
using Support = domain::StellarDomains;
};
};
/** @brief Two adjacent cells expose both inactive vacuum DOFs and shared continuous trace DOFs. */
[[nodiscard]] mfem::Mesh split_mesh(const int stellar_attribute = 2, const int vacuum_attribute = 3)
{
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0);
mesh.GetElement(0)->SetAttribute(stellar_attribute);
mesh.GetElement(1)->SetAttribute(vacuum_attribute);
mesh.SetAttributes();
return mesh;
}
/** @brief Independently mark the native element DOFs selected by semantic support. */
template <typename Term, typename DomainSchema>
[[nodiscard]] std::vector<bool> expected_support(const mfem::FiniteElementSpace& space)
{
std::vector<bool> active(space.GetVSize(), false);
mfem::Array<int> dofs;
for (int element = 0; element < space.GetNE(); ++element)
{
if (!DomainSchema::template domain_id_belongs_to<typename Term::Support>(space.GetMesh()->GetAttribute(element)))
{
continue;
}
space.GetElementVDofs(element, dofs);
for (const int encoded : dofs)
{
active[encoded < 0 ? -1 - encoded : encoded] = true;
}
}
return active;
}
/** @brief Compare canonical indexing, projection, and GridFunction transfer with the independent marker. */
template <typename Term, typename DomainSchema = Schema>
void check_supported_projection(mfem::FiniteElementSpace& space)
{
const backend::TermBinding<Term, DomainSchema> binding{space};
const std::vector<bool> active = expected_support<Term, DomainSchema>(space);
REQUIRE(binding.full_size() == space.GetVSize());
CHECK(binding.size() == std::ranges::count(active, true));
mfem::Vector full(binding.full_size());
for (int dof = 0; dof < full.Size(); ++dof)
{
full[dof] = 10.0 + dof;
}
const mfem::Vector reduced = binding.gather(full);
const mfem::Vector projected = binding.scatter(reduced);
const mfem::Vector recovered = binding.gather(projected);
int next_reduced = 0;
for (int dof = 0; dof < full.Size(); ++dof)
{
CAPTURE(dof);
const std::optional<int> index = binding.reduced_dof(dof);
CHECK(index.has_value() == active[dof]);
if (active[dof])
{
REQUIRE(index);
CHECK(*index == next_reduced++);
CHECK(reduced[*index] == full[dof]);
CHECK(recovered[*index] == reduced[*index]);
CHECK(projected[dof] == full[dof]);
}
else
{
CHECK(projected[dof] == 0.0);
}
}
// MFEM owns the GridFunction conversion; no additional field-adapter lifecycle is needed.
mfem::GridFunction grid(&space);
grid.SetFromTrueDofs(projected);
mfem::Vector true_values;
grid.GetTrueDofs(true_values);
const mfem::Vector grid_values = binding.gather(true_values);
for (int dof = 0; dof < reduced.Size(); ++dof)
{
CHECK(grid_values[dof] == reduced[dof]);
}
std::vector<double> storage(full.Size() + 2, -77.0);
std::copy_n(full.GetData(), full.Size(), storage.data() + 1);
const mfem::Vector full_view(storage.data() + 1, full.Size());
const mfem::Vector from_view = binding.gather(full_view);
for (int dof = 0; dof < reduced.Size(); ++dof)
{
CHECK(from_view[dof] == reduced[dof]);
}
CHECK(storage.front() == -77.0);
CHECK(storage.back() == -77.0);
CHECK_THROWS_AS(binding.reduced_dof(-1), std::out_of_range);
CHECK_THROWS_AS(binding.reduced_dof(binding.full_size()), std::out_of_range);
CHECK_THROWS_AS(binding.supports(-1), std::out_of_range);
CHECK_THROWS_AS(binding.supports(space.GetNE()), std::out_of_range);
CHECK_THROWS_AS(binding.gather(mfem::Vector(binding.full_size() + 1)), std::invalid_argument);
CHECK_THROWS_AS(binding.scatter(mfem::Vector(binding.size() + 1)), std::invalid_argument);
}
}
TEST_CASE("MeanField supported field projections preserve canonical indexing and native GridFunctions", "[unit][discretization][meanfield]")
{
mfem::Mesh mesh = split_mesh();
mfem::H1_FECollection density_collection(blocks::density::mass::FunctionalSpace::order, 3);
mfem::L2_FECollection velocity_collection(2, 3);
mfem::H1_FECollection enthalpy_collection(3, 3);
mfem::FiniteElementSpace density(&mesh, &density_collection);
mfem::FiniteElementSpace enthalpy(&mesh, &enthalpy_collection);
mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3);
mfem::FiniteElementSpace potential(&mesh, &potential_collection);
check_supported_projection<blocks::density::mass>(density);
check_supported_projection<::serif::tests::equations::Enthalpy>(enthalpy);
check_supported_projection<blocks::gravity::potential>(potential);
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM})
{
mfem::FiniteElementSpace displacement(&mesh, &enthalpy_collection, 3, ordering);
check_supported_projection<blocks::displacement::vector>(displacement);
mfem::FiniteElementSpace velocity(&mesh, &velocity_collection, 3, ordering);
CHECK(velocity.GetFE(0)->GetOrder() == 2);
CHECK(velocity.GetVDim() == 3);
CHECK(velocity.GetOrdering() == ordering);
check_supported_projection<DiscontinuousVelocity::vector>(velocity);
}
CHECK_THROWS_AS((backend::TermBinding<DiscontinuousVelocity::vector, Schema>{density}), std::invalid_argument);
}
TEST_CASE("MeanField stellar H1 fields retain interface values and exclude vacuum-only coefficients", "[unit][discretization][meanfield]")
{
mfem::Mesh mesh = split_mesh();
mfem::H1_FECollection h1(3, 3);
mfem::FiniteElementSpace enthalpy_space(&mesh, &h1), density_space(&mesh, &h1);
const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{enthalpy_space};
const backend::TermBinding<blocks::density::mass, Schema> density{density_space};
mfem::Array<int> stellar, vacuum;
enthalpy_space.GetElementDofs(0, stellar);
enthalpy_space.GetElementDofs(1, vacuum);
int shared = 0;
int excluded = 0;
for (const int dof : vacuum)
{
const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end();
CHECK(enthalpy.reduced_dof(dof).has_value() == on_interface);
shared += on_interface;
excluded += !on_interface;
}
CHECK(shared > 0);
CHECK(excluded > 0);
density_space.GetElementDofs(0, stellar);
density_space.GetElementDofs(1, vacuum);
shared = 0;
excluded = 0;
for (const int dof : vacuum)
{
const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end();
CHECK(density.reduced_dof(dof).has_value() == on_interface);
shared += on_interface;
excluded += !on_interface;
}
CHECK(shared > 0);
CHECK(excluded > 0);
CHECK(enthalpy.supports(0));
CHECK_FALSE(enthalpy.supports(1));
CHECK(density.supports(0));
CHECK_FALSE(density.supports(1));
}
TEST_CASE("MeanField field support follows alternate semantic material IDs", "[unit][discretization][meanfield]")
{
mfem::Mesh mesh = split_mesh(17, 29);
mfem::H1_FECollection h1(3, 3);
mfem::FiniteElementSpace density(&mesh, &h1), enthalpy(&mesh, &h1);
check_supported_projection<blocks::density::mass, AlternateSchema>(density);
check_supported_projection<::serif::tests::equations::Enthalpy, AlternateSchema>(enthalpy);
}
TEST_CASE("MeanField supported maps reject stale bindings and malformed selections", "[unit][discretization][meanfield]")
{
mfem::Mesh mesh = split_mesh();
mfem::H1_FECollection collection(3, 3);
mfem::FiniteElementSpace space(&mesh, &collection, 3);
const backend::TermBinding<blocks::displacement::vector, Schema> binding{space};
for (const std::vector<int>& entries : {std::vector<int>{-1, 2}, {1, binding.size()}, {1, 1, 3}, {1, 3, 2}})
{
mfem::Array<int> selected(static_cast<int>(entries.size()));
std::copy(entries.begin(), entries.end(), selected.begin());
CHECK_THROWS_AS(backend::DofSelection(binding, selected), std::invalid_argument);
}
mfem::Vector values(binding.size());
values = 0.0;
CHECK_NOTHROW(binding.validate_values(values));
values[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(binding.scatter(values), std::domain_error);
mesh.GetElement(0)->SetAttribute(3);
CHECK_THROWS(binding.validate());
}
TEST_CASE("MeanField registered spaces retain family order vector dimension and global scalar storage", "[unit][discretization][meanfield]")
{
mfem::Mesh mesh = split_mesh();
mfem::H1_FECollection h1(3, 3);
mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3);
mfem::FiniteElementSpace potential(&mesh, &potential_collection), displacement(&mesh, &h1, 3);
const backend::TermBinding<blocks::gravity::potential, Schema> potential_binding{potential};
const backend::TermBinding<blocks::displacement::vector, Schema> displacement_binding{displacement};
CHECK(potential.GetFE(0)->GetOrder() == blocks::gravity::potential::FunctionalSpace::order);
CHECK(displacement.GetFE(0)->GetOrder() == 3);
CHECK(potential.GetVDim() == 1);
CHECK(displacement.GetVDim() == 3);
CHECK(potential_binding.size() == potential.GetVSize());
CHECK(displacement_binding.size() == displacement.GetVSize());
const backend::TermBinding<blocks::barotropic_constant::mass_normalization, Schema> global{mesh};
CHECK(global.size() == 1);
CHECK(global.full_size() == 1);
CHECK_THROWS_AS(global.space(), std::logic_error);
mfem::Vector scalar(1);
scalar[0] = 2.7;
CHECK(global.gather(global.scatter(scalar))[0] == 2.7);
mfem::Vector local;
global.element_values(0, scalar, local);
CHECK(local[0] == 2.7);
global.element_values(1, scalar, local);
CHECK(local[0] == 0.0);
CHECK_THROWS_AS((backend::TermBinding<blocks::density::mass, Schema>{displacement}), std::invalid_argument);
}
TEST_CASE("MeanField stellar boundary selections preserve exact reduced field ordering", "[unit][discretization][meanfield]")
{
serif::tests::deformation::PairedMesh fixture(0, 0.0, 3, 4);
const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{fixture.scalar_space};
const backend::DofSelection selected = backend::select_boundary_dofs<domain::StellarSurfaceBoundary>(enthalpy);
REQUIRE(selected.size() > 0);
CHECK(selected.size() < enthalpy.size());
CHECK(&selected.binding() == &enthalpy);
mfem::Array<int> marker{1, 0};
mfem::Array<int> full_boundary;
fixture.scalar_space.GetEssentialTrueDofs(marker, full_boundary);
REQUIRE(selected.size() == full_boundary.Size());
for (int index = 0; index < full_boundary.Size(); ++index)
{
const std::optional<int> reduced = enthalpy.reduced_dof(full_boundary[index]);
REQUIRE(reduced);
CHECK(selected.reduced_dofs()[index] == *reduced);
if (index > 0)
{
CHECK(selected.reduced_dofs()[index] > selected.reduced_dofs()[index - 1]);
}
}
CHECK_NOTHROW(selected.validate());
}

View File

@@ -1,12 +1,15 @@
discretization_test_sources = files( discretization_test_sources = files(
'domain/mesh_topology.cpp',
'field_support_meanfield.cpp',
'nonconforming.cpp',
'reconstruction.cpp',
'domain/concepts.cpp', 'domain/concepts.cpp',
# 'domain/ids/lists/lists.cpp', 'domain/ids/lists/lists.cpp',
# 'domain/relation/relations.cpp', 'domain/relation/relations.cpp',
# 'domain/schema/domain_schema.cpp', 'domain/schema/domain_schema.cpp',
# 'domain/schema/schemas.cpp', 'domain/schema/schemas.cpp',
# 'domain/schema/utils.cpp', 'domain/schema/validation/all.cpp',
# 'domain/schema/validation/all.cpp', 'domain/schema/validation/boundary.cpp',
# 'domain/schema/validation/boundary.cpp', 'domain/schema/validation/connected.cpp',
# 'domain/schema/validation/connected.cpp', 'domain/schema/validation/inscribed.cpp',
# 'domain/schema/validation/inscribed.cpp',
) )

View File

@@ -0,0 +1,219 @@
#include <algorithm>
#include <chrono>
#include <concepts>
#include <cmath>
#include <filesystem>
#include <memory>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/bilinearform.hpp"
#include "mfem/fem/bilininteg.hpp"
#include "serif/backend/mfem/discretization/element_coordinates.hpp"
#include "serif/backend/mfem/discretization/mass_diagonal.hpp"
#include "serif/backend/mfem/discretization/schema.hpp"
#include "serif/tests/nonconforming.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
namespace {
namespace backend = serif::discretization::backend::mfem;
namespace blocks = serif::discretization::blocks;
using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
/** @brief Keep each saved STROID fixture private to this invocation and remove it after any assertion. */
struct TemporaryMesh {
std::filesystem::path path = std::filesystem::temp_directory_path() /
("serif-nc-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".smesh");
/** @brief Release only this test's generated file. */
~TemporaryMesh() {
std::error_code error;
std::filesystem::remove(path, error);
}
};
/** @brief Use nonconstant signed data so primal restriction cannot masquerade as dual accumulation. */
[[nodiscard]] ::mfem::Vector sequence(const int size) {
::mfem::Vector values(size);
for (int index = 0; index < size; ++index) {
values[index] = std::sin(0.17 * (index + 1)) + 0.3 * std::cos(0.23 * index);
}
return values;
}
/** @brief Compare complete vectors with a norm-scaled roundoff tolerance. */
void check_values(const ::mfem::Vector& actual, const ::mfem::Vector& expected) {
REQUIRE(actual.Size() == expected.Size());
::mfem::Vector difference(actual);
difference -= expected;
CHECK(difference.Norml2() <= 2.0e-12 * std::max(1.0, expected.Norml2()));
}
/** @brief Independently close element support through native MFEM prolongation rows. */
[[nodiscard]] std::vector<int> supported_true_dofs(const backend::DofMap& binding) {
const ::mfem::SparseMatrix& prolongation = *binding.space().GetConformingProlongation();
std::vector<bool> supported(prolongation.Width(), false);
::mfem::Array<int> dofs;
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (!binding.supports(element)) {
continue;
}
binding.space().GetElementVDofs(element, dofs);
for (const int encoded : dofs) {
const int local = ::mfem::FiniteElementSpace::DecodeDof(encoded);
for (int entry = prolongation.GetI()[local]; entry < prolongation.GetI()[local + 1]; ++entry) {
if (prolongation.GetData()[entry] != 0.0) {
supported[prolongation.GetJ()[entry]] = true;
}
}
}
}
std::vector<int> result;
for (int index = 0; index < prolongation.Width(); ++index) {
if (supported[index]) {
result.push_back(index);
}
}
return result;
}
/** @brief Compare primal, dual, and oriented element coordinates with independent native matrices. */
void check_transfers(const backend::DofMap& binding) {
REQUIRE(binding.constrained());
const auto supported = supported_true_dofs(binding);
REQUIRE(static_cast<int>(supported.size()) == binding.size());
const ::mfem::Vector reduced = sequence(binding.size());
::mfem::Vector independent(binding.true_size());
independent = 0.0;
for (int index = 0; index < binding.size(); ++index) {
independent[supported[index]] = reduced[index];
}
::mfem::Vector expected(binding.full_size());
binding.space().GetConformingProlongation()->Mult(independent, expected);
check_values(binding.scatter(reduced), expected);
check_values(binding.scatter_true(reduced), independent);
check_values(binding.gather(expected), reduced);
const ::mfem::Vector dual = sequence(binding.full_size());
::mfem::Vector true_dual(binding.true_size()), expected_dual(binding.size());
binding.space().GetConformingProlongation()->MultTranspose(dual, true_dual);
for (int index = 0; index < binding.size(); ++index) {
expected_dual[index] = true_dual[supported[index]];
}
check_values(binding.accumulate_dual(dual), expected_dual);
const double full_work = expected * dual;
CHECK(std::abs(reduced * expected_dual - full_work) <= 2.0e-11 * std::max(1.0, std::abs(full_work)));
::mfem::Vector arbitrary_true(binding.true_size()), expected_primal(binding.size());
binding.space().GetConformingRestriction()->Mult(dual, arbitrary_true);
for (int index = 0; index < binding.size(); ++index) {
expected_primal[index] = arbitrary_true[supported[index]];
}
check_values(binding.gather(dual), expected_primal);
const auto elements = binding.validated_elements();
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (!binding.supports(element)) {
continue;
}
::mfem::Array<int> dofs;
const auto* transformation = binding.space().GetElementVDofs(element, dofs);
::mfem::Vector native, actual;
expected.GetSubVector(dofs, native);
if (transformation != nullptr) {
transformation->InvTransformPrimal(native);
}
elements.element_values(element, reduced, actual);
check_values(actual, native);
}
}
/** @brief Retain every local mass coupling before reducing; local diagonal restriction is not an oracle. */
template <typename Term>
void check_mass(const backend::TermBinding<Term, Schema>& binding) {
::mfem::Array<int> marker(binding.mesh().attributes.Max());
marker = 0;
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (binding.supports(element)) {
marker[binding.mesh().GetAttribute(element) - 1] = 1;
}
}
::mfem::BilinearForm native(&binding.space());
if constexpr (std::same_as<Term, blocks::displacement::vector>) {
native.AddDomainIntegrator(new ::mfem::VectorMassIntegrator(), marker);
} else {
native.AddDomainIntegrator(new ::mfem::MassIntegrator(), marker);
}
native.Assemble();
native.Finalize();
const ::mfem::SparseMatrix& prolongation = *binding.space().GetConformingProlongation();
const std::unique_ptr<::mfem::SparseMatrix> constrained(::mfem::RAP(prolongation, native.SpMat(), prolongation));
::mfem::Vector diagonal;
constrained->GetDiag(diagonal);
const auto supported = supported_true_dofs(binding);
const auto actual = backend::reference_mass_diagonal(binding);
REQUIRE(actual);
REQUIRE(actual->size() == supported.size());
for (int index = 0; index < binding.size(); ++index) {
const double expected = diagonal[supported[index]];
CHECK(std::abs((*actual)[index] - expected) <= 2.0e-11 * expected);
}
}
}
TEST_CASE("STROID nonconforming meshes preserve native coefficient and dual transfers", "[integration][nonconforming]") {
auto generated = serif::tests::nonconforming::mesh(0, 3);
REQUIRE(generated.mesh->Nonconforming());
CHECK(serif::backend::mfem::discretization::validate_schema<Schema>(*generated.mesh).valid());
CHECK(serif::backend::mfem::discretization::validate_schema<Schema>(*generated.reference_mesh).valid());
::mfem::H1_FECollection scalar_collection(blocks::density::mass::FunctionalSpace::order, 3);
::mfem::FiniteElementSpace scalar(generated.mesh.get(), &scalar_collection);
REQUIRE(scalar.GetTrueVSize() < scalar.GetVSize());
backend::TermBinding<blocks::density::mass, Schema> density(scalar);
::mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3);
::mfem::FiniteElementSpace potential_space(generated.mesh.get(), &potential_collection);
REQUIRE(potential_space.GetTrueVSize() < potential_space.GetVSize());
backend::TermBinding<blocks::gravity::potential, Schema> potential(potential_space);
CHECK(density.size() < potential.size());
REQUIRE(potential.size() == potential_space.GetTrueVSize());
const ::mfem::SparseMatrix& density_prolongation = *scalar.GetConformingProlongation();
int material_hanging_coefficients = 0;
::mfem::Array<int> material_dofs;
for (int element = 0; element < scalar.GetNE(); ++element) {
if (!density.supports(element)) {
continue;
}
scalar.GetElementDofs(element, material_dofs);
for (const int local : material_dofs) {
const int begin = density_prolongation.GetI()[local];
if (density_prolongation.GetI()[local + 1] != begin + 1 || density_prolongation.GetData()[begin] != 1.0) {
++material_hanging_coefficients;
}
}
}
REQUIRE(material_hanging_coefficients > 0);
check_transfers(density);
check_transfers(potential);
check_mass(density);
check_mass(potential);
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
::mfem::FiniteElementSpace vector(generated.mesh.get(), &scalar_collection, 3, ordering);
backend::TermBinding<blocks::displacement::vector, Schema> displacement(vector);
check_transfers(displacement);
check_mass(displacement);
}
}
TEST_CASE("STROID saved meshes retain genuine hanging faces when loaded directly", "[integration][nonconforming]") {
auto generated = serif::tests::nonconforming::mesh();
const TemporaryMesh file;
stroid::IO::SaveStroidMesh(generated, file.path.string());
auto restored = stroid::IO::LoadStroidMesh(file.path.string());
INFO((restored ? "restored" : restored.error()));
REQUIRE(restored);
REQUIRE(restored->mesh->Nonconforming());
REQUIRE(restored->reference_mesh->Nonconforming());
CHECK(restored->mesh->GetNE() == generated.mesh->GetNE());
::mfem::H1_FECollection collection(blocks::gravity::potential::FunctionalSpace::order, 3);
::mfem::FiniteElementSpace space(restored->mesh.get(), &collection);
REQUIRE(space.GetTrueVSize() < space.GetVSize());
backend::TermBinding<blocks::gravity::potential, Schema> potential(space);
check_transfers(potential);
CHECK(serif::backend::mfem::discretization::validate_schema<Schema>(*restored->mesh).valid());
}

View File

@@ -0,0 +1,77 @@
#include <array>
#include <cmath>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe/fe_h1.hpp"
#include "serif/backend/mfem/discretization/basis_evaluation.hpp"
TEST_CASE("Surface power retains nodal density and its analytic surface limit", "[unit][reconstruction]") {
namespace reconstruction = serif::discretization::reconstruction;
const std::array nodes{0.0, 0.2, 0.7, 1.0};
std::array<double, 4> values, slopes;
for (const double exponent : {1.0, 4.0 / 3.0, 5.0 / 3.0, 2.0, 2.8}) {
const reconstruction::SurfacePowerProfile profile(exponent);
for (std::size_t node = 0; node < nodes.size(); ++node) {
profile.evaluate(nodes, nodes[node], values, slopes);
for (std::size_t basis = 0; basis < nodes.size(); ++basis) {
CHECK(values[basis] == Catch::Approx(node == basis ? 1.0 : 0.0).margin(2e-14));
CHECK(std::isfinite(slopes[basis]));
}
}
for (const double point : {0.0, 1e-12, 0.01, 0.35, 0.9, 1.0}) {
profile.evaluate(nodes, point, values, slopes);
double density = 0.0, derivative = 0.0, constant = 0.0, constant_derivative = 0.0;
for (std::size_t basis = 0; basis < nodes.size(); ++basis) {
density += values[basis] * std::pow(nodes[basis], exponent);
derivative += slopes[basis] * std::pow(nodes[basis], exponent);
constant += values[basis];
constant_derivative += slopes[basis];
}
CHECK(density == Catch::Approx(std::pow(point, exponent)).margin(3e-14));
CHECK(derivative == Catch::Approx(exponent * std::pow(point, exponent - 1.0)).margin(3e-13));
CHECK(constant == Catch::Approx(1.0).margin(3e-14));
CHECK(constant_derivative == Catch::Approx(0.0).margin(3e-13));
}
}
CHECK_THROWS(reconstruction::SurfacePowerProfile(0.8));
CHECK_THROWS(reconstruction::SurfacePowerProfile(NAN));
}
TEST_CASE("Reconstructed tensor values gradients and transposes agree in every surface orientation", "[unit][reconstruction]") {
namespace backend = serif::discretization::backend::mfem;
namespace reconstruction = serif::discretization::reconstruction;
::mfem::H1_HexahedronElement element(3);
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 12);
backend::TensorBasisCache cache;
backend::BasisWorkspace workspace;
for (const double exponent : {1.0, 4.0 / 3.0, 2.8}) {
const reconstruction::SurfacePowerProfile profile(exponent);
for (int axis = 0; axis < 3; ++axis) {
for (const bool high : {false, true}) {
const backend::ElementReconstruction reconstruction{&profile, axis, high};
for (const auto operation : {backend::ReferenceOperation::value, backend::ReferenceOperation::gradient, backend::ReferenceOperation::value_and_gradient}) {
backend::BasisEvaluation dense, tensor;
dense.sample(element, rule, operation, 1, cache, backend::ContractionMode::dense, &reconstruction);
tensor.sample(element, rule, operation, 1, cache, backend::ContractionMode::automatic, &reconstruction);
REQUIRE(tensor.uses_tensor());
::mfem::Vector coefficients(element.GetDof()), dense_values, tensor_values;
for (int i = 0; i < coefficients.Size(); ++i) { coefficients[i] = std::sin(0.3 * i); }
dense.evaluate(coefficients, dense_values, workspace);
tensor.evaluate(coefficients, tensor_values, workspace);
tensor_values -= dense_values;
CHECK(tensor_values.Normlinf() < 2e-12);
::mfem::Vector dual(dense.height()), dense_transpose, tensor_transpose;
for (int i = 0; i < dual.Size(); ++i) { dual[i] = std::cos(0.13 * i); }
dense.integrate(dual, dense_transpose, workspace);
tensor.integrate(dual, tensor_transpose, workspace);
CHECK((coefficients * tensor_transpose) == Catch::Approx(dense_values * dual).margin(1e-9));
tensor_transpose -= dense_transpose;
CHECK(tensor_transpose.Normlinf() < 2e-10);
}
}
}
// The cache borrows the immutable profile, just as it borrows finite elements.
cache = backend::TensorBasisCache{};
}
}

View File

@@ -0,0 +1,135 @@
// Preserve the independent identities, finite differences, and surface conventions from MeanField's barotrope tests.
#include <algorithm>
#include <array>
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/eos/evaluation.hpp"
#include "serif/eos/models/polytropic.hpp"
namespace
{
namespace eos = serif::eos;
namespace quantity = serif::dimensions;
using Catch::Matchers::WithinRel;
/** @brief Evaluate a centered difference independently of the EOS derivative implementation. */
template <typename Function>
[[nodiscard]] double centered_derivative(Function&& function, const double value, const double step)
{
return (function(value + step) - function(value - step)) / (2.0 * step);
}
}
TEST_CASE("MeanField polytropic round trips preserve the original strict identities", "[unit][eos][meanfield]")
{
const eos::models::Polytrope model{3.0, 1.5};
for (const double density : {1.0e-6, 1.0e-3, 0.1, 0.7, 2.0})
{
CAPTURE(density);
const double pressure = eos::evaluate<quantity::Pressure>(model, quantity::DensityValue{density}).value();
const double enthalpy = eos::evaluate<quantity::SpecificEnthalpy>(model, quantity::DensityValue{density}).value();
CHECK_THAT(eos::evaluate<quantity::MassDensity>(model, quantity::SpecificEnthalpyValue{enthalpy}).value(), WithinRel(density, 2.0e-14));
CHECK_THAT(eos::evaluate<quantity::Pressure>(model, quantity::SpecificEnthalpyValue{enthalpy}).value(), WithinRel(pressure, 2.0e-14));
CHECK_THAT(eos::evaluate<quantity::SpecificEnthalpy>(model, quantity::PressureValue{pressure}).value(), WithinRel(enthalpy, 2.0e-14));
CHECK_THAT(pressure, WithinRel(density * enthalpy / 4.0, 2.0e-14));
CHECK_THAT((eos::partial_derivative<quantity::Pressure, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), WithinRel(density, 2.0e-14));
CHECK_THAT((eos::partial_derivative<quantity::Pressure, quantity::MassDensity>(model, quantity::DensityValue{density}).value()), WithinRel(enthalpy / 3.0, 2.0e-14));
}
}
TEST_CASE("MeanField polytropic thermodynamic identities span integer and fractional indices", "[unit][eos][meanfield]")
{
for (const double index : {1.0, 1.5, 3.0})
{
const eos::models::Polytrope model{index, 0.73};
CHECK(model.polytropic_index() == index);
CHECK(model.polytropic_constant() == 0.73);
CHECK(model.enthalpy_scale() == (index + 1.0) * 0.73);
for (const double density : {1.0e-4, 0.02, 0.37, 2.4})
{
CAPTURE(index, density);
const double pressure = eos::evaluate<quantity::Pressure>(model, quantity::DensityValue{density}).value();
const double enthalpy = eos::evaluate<quantity::SpecificEnthalpy>(model, quantity::DensityValue{density}).value();
const double recovered_density = eos::evaluate<quantity::MassDensity>(model, quantity::SpecificEnthalpyValue{enthalpy}).value();
const double recovered_pressure = eos::evaluate<quantity::Pressure>(model, quantity::SpecificEnthalpyValue{enthalpy}).value();
CHECK_THAT(pressure, WithinRel(0.73 * std::pow(density, 1.0 + 1.0 / index), 2.0e-13));
CHECK_THAT(enthalpy, WithinRel((index + 1.0) * 0.73 * std::pow(density, 1.0 / index), 2.0e-13));
CHECK_THAT(recovered_density, WithinRel(density, 5.0e-13));
CHECK_THAT(recovered_pressure, WithinRel(pressure, 5.0e-13));
CHECK_THAT(recovered_pressure, WithinRel(density * enthalpy / (index + 1.0), 5.0e-13));
CHECK((eos::partial_derivative<quantity::Pressure, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == recovered_density);
CHECK_THAT((eos::partial_derivative<quantity::Pressure, quantity::MassDensity>(model, quantity::DensityValue{density}).value()), WithinRel(enthalpy / index, 5.0e-13));
}
}
}
TEST_CASE("MeanField cubic polytrope derivatives retain the strict centered difference tolerance", "[unit][eos][meanfield]")
{
const eos::models::Polytrope model{3.0, 1.5};
for (const double enthalpy : {0.05, 0.2, 0.7, 1.4})
{
CAPTURE(enthalpy);
const double step = 1.0e-6 * std::max(1.0, enthalpy);
const double density_derivative = centered_derivative([&](const double value)
{
return eos::evaluate<quantity::MassDensity>(model, quantity::SpecificEnthalpyValue{value}).value();
}, enthalpy, step);
const double pressure_derivative = centered_derivative([&](const double value)
{
return eos::evaluate<quantity::Pressure>(model, quantity::SpecificEnthalpyValue{value}).value();
}, enthalpy, step);
CHECK_THAT(density_derivative, WithinRel((eos::partial_derivative<quantity::MassDensity, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), 5.0e-10));
CHECK_THAT(pressure_derivative, WithinRel((eos::partial_derivative<quantity::Pressure, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), 5.0e-10));
}
}
TEST_CASE("MeanField pressure and density derivatives match centered differences for every original index", "[unit][eos][meanfield]")
{
for (const double index : {1.0, 1.5, 3.0})
{
const eos::models::Polytrope model{index, 0.61};
for (const double value : {0.2, 0.73, 1.8})
{
CAPTURE(index, value);
const double step = 2.0e-6 * std::max(1.0, std::abs(value));
const double pressure_enthalpy = centered_derivative([&](const double enthalpy)
{
return eos::evaluate<quantity::Pressure>(model, quantity::SpecificEnthalpyValue{enthalpy}).value();
}, value, step);
const double pressure_density = centered_derivative([&](const double density)
{
return eos::evaluate<quantity::Pressure>(model, quantity::DensityValue{density}).value();
}, value, step);
const double density_enthalpy = centered_derivative([&](const double enthalpy)
{
return eos::evaluate<quantity::MassDensity>(model, quantity::SpecificEnthalpyValue{enthalpy}).value();
}, value, step);
CHECK_THAT(pressure_enthalpy, WithinRel((eos::partial_derivative<quantity::Pressure, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{value}).value()), 5.0e-8));
CHECK_THAT(pressure_density, WithinRel((eos::partial_derivative<quantity::Pressure, quantity::MassDensity>(model, quantity::DensityValue{value}).value()), 5.0e-8));
CHECK_THAT(density_enthalpy, WithinRel((eos::partial_derivative<quantity::MassDensity, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{value}).value()), 5.0e-8));
}
}
}
TEST_CASE("MeanField polytropic surface keeps the n one right derivative and zero exterior", "[unit][eos][meanfield]")
{
for (const double index : {1.0, 1.5, 3.0})
{
const eos::models::Polytrope model{index, 0.47};
for (const double enthalpy : {0.0, -0.3, -1.0})
{
CAPTURE(index, enthalpy);
CHECK(eos::evaluate<quantity::MassDensity>(model, quantity::SpecificEnthalpyValue{enthalpy}).value() == 0.0);
CHECK(eos::evaluate<quantity::Pressure>(model, quantity::SpecificEnthalpyValue{enthalpy}).value() == 0.0);
CHECK((eos::partial_derivative<quantity::Pressure, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == 0.0);
const double density_derivative = index == 1.0 && enthalpy == 0.0 ? 1.0 / model.enthalpy_scale() : 0.0;
CHECK((eos::partial_derivative<quantity::MassDensity, quantity::SpecificEnthalpy>(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == density_derivative);
}
CHECK(eos::evaluate<quantity::Pressure>(model, quantity::DensityValue{0.0}).value() == 0.0);
CHECK(eos::evaluate<quantity::SpecificEnthalpy>(model, quantity::DensityValue{0.0}).value() == 0.0);
CHECK((eos::partial_derivative<quantity::Pressure, quantity::MassDensity>(model, quantity::DensityValue{0.0}).value()) == 0.0);
}
}

View File

@@ -0,0 +1,237 @@
// Ported from MeanField tests/physics/equation_of_state_type_system.cpp.
#include <concepts>
#include <type_traits>
#include <catch2/catch_test_macros.hpp>
#include "serif/dimensions/type_alias.hpp"
#include "serif/eos/evaluation.hpp"
#include "serif/tests/test_tags.hpp"
namespace eos = serif::eos;
namespace dimensions = serif::dimensions;
namespace {
struct Entropy final : dimensions::ThermodynamicQuantity {};
struct ElectronFraction final : dimensions::ThermodynamicQuantity {};
using SpecificEnthalpyFromPressureAndEntropy =
eos::relations::Relation<dimensions::SpecificEnthalpy, dimensions::Pressure, Entropy>;
class CompleteEquationOfState final {
public:
using Relations = eos::relations::RelationCatalog<
eos::relations::PressureFromDensity,
eos::relations::SpecificEnthalpyFromPressure,
SpecificEnthalpyFromPressureAndEntropy>;
[[nodiscard]] constexpr dimensions::PressureValue evaluate(
eos::relations::PressureFromDensity,
const dimensions::DensityValue density
) const noexcept {
return dimensions::PressureValue{2.0 * density.value()};
}
[[nodiscard]] constexpr dimensions::SpecificEnthalpyValue evaluate(
eos::relations::SpecificEnthalpyFromPressure,
const dimensions::PressureValue pressure
) const noexcept {
return dimensions::SpecificEnthalpyValue{3.0 * pressure.value()};
}
[[nodiscard]] constexpr dimensions::SpecificEnthalpyValue evaluate(
SpecificEnthalpyFromPressureAndEntropy,
const dimensions::PressureValue pressure,
const dimensions::QuantityValue<Entropy> entropy
) const noexcept {
return dimensions::SpecificEnthalpyValue{3.0 * pressure.value() + 5.0 * entropy.value()};
}
[[nodiscard]] constexpr dimensions::PartialDerivative<dimensions::SpecificEnthalpy, Entropy> partial_derivative(
SpecificEnthalpyFromPressureAndEntropy,
dimensions::WithRespectTo<Entropy>,
dimensions::PressureValue,
dimensions::QuantityValue<Entropy>
) const noexcept {
return dimensions::PartialDerivative<dimensions::SpecificEnthalpy, Entropy>{5.0};
}
};
class MissingRelationImplementation final {
public:
using Relations = eos::relations::
RelationCatalog<eos::relations::PressureFromDensity, eos::relations::SpecificEnthalpyFromPressure>;
[[nodiscard]] dimensions::PressureValue evaluate(
eos::relations::PressureFromDensity,
dimensions::DensityValue density
) const {
return dimensions::PressureValue{density.value()};
}
};
class IncorrectRelationOutput final {
public:
using Relations = eos::relations::RelationCatalog<eos::relations::PressureFromDensity>;
[[nodiscard]] dimensions::DensityValue evaluate(
eos::relations::PressureFromDensity,
dimensions::DensityValue density
) const {
return density;
}
};
class InvalidRelationCatalog final {
public:
using Relations = eos::relations::RelationCatalog<eos::relations::Relation<double, dimensions::MassDensity>>;
};
template <typename EquationOfState>
concept CanEvaluateDensityFromSpecificEnthalpy = requires(const EquationOfState& equation_of_state) {
eos::evaluate<dimensions::MassDensity>(equation_of_state, dimensions::SpecificEnthalpyValue{1.0});
};
} // namespace
TEST_CASE(
"Thermodynamic Values Preserve Physical Quantity Types",
tags::unit & tags::eos
) {
STATIC_CHECK(dimensions::ThermodynamicQuantityType<dimensions::MassDensity>);
STATIC_CHECK(dimensions::ThermodynamicQuantityType<dimensions::Pressure>);
STATIC_CHECK(dimensions::ThermodynamicQuantityType<dimensions::SpecificEnthalpy>);
STATIC_CHECK_FALSE(dimensions::ThermodynamicQuantityType<const dimensions::Pressure>);
STATIC_CHECK_FALSE(std::same_as<dimensions::DensityValue, dimensions::PressureValue>);
STATIC_CHECK_FALSE(std::same_as<dimensions::PressureValue, dimensions::SpecificEnthalpyValue>);
STATIC_CHECK_FALSE(std::is_convertible_v<double, dimensions::PressureValue>);
STATIC_CHECK_FALSE(std::is_constructible_v<dimensions::PressureValue, dimensions::DensityValue>);
STATIC_CHECK(std::is_trivially_copyable_v<dimensions::DensityValue>);
STATIC_CHECK(std::is_standard_layout_v<dimensions::DensityValue>);
STATIC_CHECK(sizeof(dimensions::DensityValue) == sizeof(double));
STATIC_CHECK(sizeof(dimensions::PressureValue) == sizeof(double));
STATIC_CHECK(sizeof(dimensions::SpecificEnthalpyValue) == sizeof(double));
STATIC_CHECK(std::is_empty_v<eos::relations::PressureFromDensity>);
constexpr dimensions::DensityValue density{-0.25};
STATIC_CHECK(density.value() == -0.25);
}
TEST_CASE(
"Thermodynamic Derivatives Preserve Numerator And Denominator Types",
tags::unit & tags::eos
) {
using PressureByDensity = dimensions::PartialDerivative<dimensions::Pressure, dimensions::MassDensity>;
using PressureBySpecificEnthalpy =
dimensions::PartialDerivative<dimensions::Pressure, dimensions::SpecificEnthalpy>;
STATIC_CHECK_FALSE(std::same_as<PressureByDensity, PressureBySpecificEnthalpy>);
STATIC_CHECK_FALSE(std::is_convertible_v<PressureByDensity, PressureBySpecificEnthalpy>);
STATIC_CHECK(std::is_trivially_copyable_v<PressureByDensity>);
STATIC_CHECK(std::is_standard_layout_v<PressureByDensity>);
STATIC_CHECK(sizeof(PressureByDensity) == sizeof(double));
constexpr PressureByDensity derivative{1.75};
STATIC_CHECK(derivative.value() == 1.75);
}
TEST_CASE(
"EOS Relation Catalog Rejects Invalid And Duplicate Relations",
tags::unit & tags::eos
) {
using ValidCatalog = eos::relations::RelationCatalog<
eos::relations::PressureFromDensity, eos::relations::SpecificEnthalpyFromPressure>;
using DuplicateCatalog =
eos::relations::RelationCatalog<eos::relations::PressureFromDensity, eos::relations::PressureFromDensity>;
using InvalidRelation = eos::relations::Relation<double, dimensions::MassDensity>;
using InvalidCatalog = eos::relations::RelationCatalog<InvalidRelation>;
using RepeatedInputRelation =
eos::relations::Relation<dimensions::Pressure, dimensions::MassDensity, dimensions::MassDensity>;
using RepeatedInputCatalog = eos::relations::RelationCatalog<RepeatedInputRelation>;
STATIC_CHECK(eos::relations::ValidRelationCatalog<ValidCatalog>);
STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog<DuplicateCatalog>);
STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog<InvalidCatalog>);
STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog<RepeatedInputCatalog>);
STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog<eos::relations::RelationCatalog<>>);
STATIC_CHECK(
eos::relations::relation_catalog_contains_relation_v<ValidCatalog, eos::relations::PressureFromDensity>
);
STATIC_CHECK_FALSE(
eos::relations::relation_catalog_contains_relation_v<ValidCatalog, eos::relations::DensityFromSpecificEnthalpy>
);
STATIC_CHECK(
eos::relations::relation_contains_input_v<eos::relations::PressureFromDensity, dimensions::MassDensity>
);
STATIC_CHECK_FALSE(
eos::relations::relation_contains_input_v<eos::relations::PressureFromDensity, dimensions::Pressure>
);
STATIC_CHECK(std::same_as<eos::relations::PressureFromDensity::OutputQuantity, dimensions::Pressure>);
STATIC_CHECK(
std::same_as<
dimensions::QuantityAt<0, eos::relations::PressureFromDensity::InputQuantities>::Type,
dimensions::MassDensity>
);
}
TEST_CASE(
"EOS Model Contract Requires Every Declared Relation",
tags::unit & tags::eos
) {
STATIC_CHECK(eos::models::EOSModel<CompleteEquationOfState>);
STATIC_CHECK_FALSE(eos::models::EOSModel<MissingRelationImplementation>);
STATIC_CHECK_FALSE(eos::models::EOSModel<IncorrectRelationOutput>);
STATIC_CHECK_FALSE(eos::models::EOSModel<InvalidRelationCatalog>);
STATIC_CHECK(eos::models::EOSSupportingRelation<CompleteEquationOfState, eos::relations::PressureFromDensity>);
STATIC_CHECK_FALSE(
eos::models::EOSSupportingRelation<CompleteEquationOfState, eos::relations::DensityFromSpecificEnthalpy>
);
STATIC_CHECK_FALSE(CanEvaluateDensityFromSpecificEnthalpy<CompleteEquationOfState>);
STATIC_CHECK(
eos::models::EOSSupportingPartialDerivative<
CompleteEquationOfState, SpecificEnthalpyFromPressureAndEntropy, Entropy>
);
STATIC_CHECK_FALSE(
eos::models::EOSSupportingPartialDerivative<
CompleteEquationOfState, SpecificEnthalpyFromPressureAndEntropy, ElectronFraction>
);
}
TEST_CASE(
"EOS Evaluation Selects Relations From Typed Inputs",
tags::unit & tags::eos
) {
constexpr CompleteEquationOfState equation_of_state;
constexpr dimensions::PressureValue pressure =
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{1.25});
constexpr dimensions::SpecificEnthalpyValue specific_enthalpy = eos::evaluate<dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue<Entropy>{0.2}
);
constexpr auto entropy_derivative = eos::partial_derivative<dimensions::SpecificEnthalpy, Entropy>(
equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue<Entropy>{0.2}
);
STATIC_CHECK(noexcept(eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{1.25})));
STATIC_CHECK(
noexcept(eos::partial_derivative<dimensions::SpecificEnthalpy, Entropy>(
equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue<Entropy>{0.2}
))
);
STATIC_CHECK(pressure.value() == 2.5);
STATIC_CHECK(specific_enthalpy.value() == 2.5);
STATIC_CHECK(entropy_derivative.value() == 5.0);
}

7
tests/eos/meson.build Normal file
View File

@@ -0,0 +1,7 @@
eos_test_sources = files(
'barotrope_meanfield.cpp',
'equation_of_state_type_system.cpp',
'polytropic_eos_characterization.cpp',
'polytropic_eos_relations.cpp',
'pressure_potential.cpp',
)

View File

@@ -0,0 +1,166 @@
// Ported from MeanField tests/physics/polytropic_eos_characterization.cpp.
#include <array>
#include <cmath>
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/eos/evaluation.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/eos/models/polytropic.hpp"
#include "serif/tests/test_tags.hpp"
namespace eos = serif::eos;
namespace dimensions = serif::dimensions;
TEST_CASE(
"Polytropic EOS Pressure To Specific Enthalpy Relation Is Characterized",
tags::unit & tags::eos
) {
constexpr std::array<double, 3> polytropic_indices{1.0, 1.5, 3.0};
constexpr std::array<double, 3> polytropic_constants{0.25, 0.73, 2.0};
constexpr std::array<double, 5> pressures{0.0, 1.0e-12, 1.0e-4, 0.3, 5.0};
for (const double polytropic_index : polytropic_indices) {
for (const double polytropic_constant : polytropic_constants) {
const eos::models::Polytrope equation_of_state(polytropic_index, polytropic_constant);
for (const double pressure : pressures) {
CAPTURE(polytropic_index, polytropic_constant, pressure);
const double index_plus_one = polytropic_index + 1.0;
const double expected_enthalpy = index_plus_one *
std::pow(polytropic_constant, polytropic_index / index_plus_one) *
std::pow(pressure, 1.0 / index_plus_one);
const double enthalpy =
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::PressureValue{pressure})
.value();
if (pressure == 0.0) {
CHECK(enthalpy == 0.0);
} else {
CHECK_THAT(enthalpy, Catch::Matchers::WithinRel(expected_enthalpy, 5.0e-14));
const double recovered_pressure = eos::evaluate<dimensions::Pressure>(
equation_of_state, dimensions::SpecificEnthalpyValue{enthalpy}
)
.value();
CHECK_THAT(recovered_pressure, Catch::Matchers::WithinRel(pressure, 5.0e-13));
}
}
}
}
}
TEST_CASE(
"Polytropic EOS Domain Contract Covers Every Relation",
tags::unit & tags::eos
) {
constexpr double infinity = std::numeric_limits<double>::infinity();
constexpr double quiet_nan = std::numeric_limits<double>::quiet_NaN();
for (const double invalid_index : std::array<double, 4>{0.999, infinity, -infinity, quiet_nan}) {
CAPTURE(invalid_index);
CHECK_THROWS_AS(eos::models::Polytrope(invalid_index, 1.0), eos::EOSEvaluationError);
}
for (const double invalid_constant : std::array<double, 5>{0.0, -0.1, infinity, -infinity, quiet_nan}) {
CAPTURE(invalid_constant);
CHECK_THROWS_AS(eos::models::Polytrope(3.0, invalid_constant), eos::EOSEvaluationError);
}
const eos::models::Polytrope equation_of_state(3.0, 0.75);
constexpr double negative_density = -0.1;
CHECK_THROWS_AS(
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{negative_density}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::DensityValue{negative_density}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
(eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(
equation_of_state, dimensions::DensityValue{negative_density}
)),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::PressureValue{-0.1}),
eos::EOSEvaluationError
);
constexpr double exterior_enthalpy = -0.1;
CHECK(
eos::evaluate<dimensions::MassDensity>(equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy})
.value() == 0.0
);
CHECK(
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy})
.value() == 0.0
);
CHECK(
(eos::partial_derivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy}
)
.value() == 0.0)
);
CHECK(
(eos::partial_derivative<dimensions::Pressure, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy}
)
.value() == 0.0)
);
for (const double nonfinite_value : std::array<double, 3>{infinity, -infinity, quiet_nan}) {
CAPTURE(nonfinite_value);
CHECK_THROWS_AS(
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{nonfinite_value}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::DensityValue{nonfinite_value}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
(eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(
equation_of_state, dimensions::DensityValue{nonfinite_value}
)),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::PressureValue{nonfinite_value}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::MassDensity>(
equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value}
),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value}),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
(eos::partial_derivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value}
)),
eos::EOSEvaluationError
);
CHECK_THROWS_AS(
(eos::partial_derivative<dimensions::Pressure, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value}
)),
eos::EOSEvaluationError
);
}
}

View File

@@ -0,0 +1,196 @@
// Ported from MeanField tests/physics/polytropic_eos_relations.cpp.
#include <array>
#include <cmath>
#include <limits>
#include <string_view>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/eos/evaluation.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/eos/models/polytropic.hpp"
#include "serif/tests/test_tags.hpp"
namespace eos = serif::eos;
namespace dimensions = serif::dimensions;
TEST_CASE(
"Polytropic EOS Typed Relations Preserve Analytic Values",
tags::unit & tags::eos
) {
constexpr std::array<double, 3> polytropic_indices{1.0, 1.5, 3.0};
constexpr std::array<double, 2> polytropic_constants{0.25, 0.73};
constexpr std::array<double, 4> densities{0.0, 1.0e-6, 0.2, 2.0};
constexpr std::array<double, 4> specific_enthalpies{-0.3, 0.0, 0.2, 1.7};
constexpr std::array<double, 4> pressures{0.0, 1.0e-8, 0.3, 4.0};
for (const double polytropic_index : polytropic_indices) {
for (const double polytropic_constant : polytropic_constants) {
const eos::models::Polytrope equation_of_state(polytropic_index, polytropic_constant);
for (const double density : densities) {
CAPTURE(polytropic_index, polytropic_constant, density);
const double expected_pressure = polytropic_constant * std::pow(density, 1.0 + 1.0 / polytropic_index);
const double expected_specific_enthalpy =
(polytropic_index + 1.0) * polytropic_constant * std::pow(density, 1.0 / polytropic_index);
CHECK(
eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{density}).value() ==
expected_pressure
);
CHECK(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::DensityValue{density})
.value() == expected_specific_enthalpy
);
CHECK_THAT(
(eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(
equation_of_state, dimensions::DensityValue{density}
)
.value()),
Catch::Matchers::WithinRel(
density == 0.0 ? 0.0 : expected_specific_enthalpy / polytropic_index, 2.0e-15
)
);
}
for (const double specific_enthalpy : specific_enthalpies) {
CAPTURE(polytropic_index, polytropic_constant, specific_enthalpy);
const double expected_density =
specific_enthalpy <= 0.0
? 0.0
: std::pow(
specific_enthalpy / ((polytropic_index + 1.0) * polytropic_constant), polytropic_index
);
CHECK(
eos::evaluate<dimensions::MassDensity>(
equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy}
)
.value() == expected_density
);
CHECK(
eos::evaluate<dimensions::Pressure>(
equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy}
)
.value() ==
(specific_enthalpy <= 0.0 ? 0.0 : expected_density * specific_enthalpy / (polytropic_index + 1.0))
);
CHECK(
(eos::partial_derivative<dimensions::Pressure, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy}
)
.value() == expected_density)
);
}
for (const double pressure : pressures) {
CAPTURE(polytropic_index, polytropic_constant, pressure);
const double index_plus_one = polytropic_index + 1.0;
const double expected_specific_enthalpy =
index_plus_one * std::pow(polytropic_constant, polytropic_index / index_plus_one) *
std::pow(pressure, 1.0 / index_plus_one);
CHECK(
eos::evaluate<dimensions::SpecificEnthalpy>(equation_of_state, dimensions::PressureValue{pressure})
.value() == expected_specific_enthalpy
);
}
}
}
}
TEST_CASE(
"Typed Polytropic EOS Preserves Domain And Exterior Semantics",
tags::unit & tags::eos
) {
const eos::models::Polytrope equation_of_state(3.0, 0.75);
try {
static_cast<void>(eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{-0.1}));
FAIL("A negative density must be rejected.");
} catch (const eos::EOSEvaluationError& error) {
CHECK(std::string_view{error.what()}.find("EOS_INVALID_THERMODYNAMIC_INPUT") != std::string_view::npos);
}
try {
static_cast<void>(eos::evaluate<dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::PressureValue{std::numeric_limits<double>::quiet_NaN()}
));
FAIL("A nonfinite pressure must be rejected.");
} catch (const eos::EOSEvaluationError& error) {
CHECK(std::string_view{error.what()}.find("EOS_INVALID_THERMODYNAMIC_INPUT") != std::string_view::npos);
}
constexpr double exterior_specific_enthalpy = -0.3;
CHECK(
eos::evaluate<dimensions::MassDensity>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy}
)
.value() == 0.0
);
CHECK(
eos::evaluate<dimensions::Pressure>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy}
)
.value() == 0.0
);
CHECK(
(eos::partial_derivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy}
)
.value() == 0.0)
);
CHECK(
(eos::partial_derivative<dimensions::Pressure, dimensions::SpecificEnthalpy>(
equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy}
)
.value() == 0.0)
);
}
TEST_CASE("Density based polytropic EOS supplies surface density and bulk Newton curvature", tags::unit & tags::eos) {
for (const double index : {1.0, 1.5, 2.0, 2.5, 3.0}) {
const eos::models::Polytrope model(index, 0.73);
for (const double density : {1.0e-6, 0.2, 2.0}) {
CAPTURE(index, density);
const dimensions::DensityValue state{density};
const auto pressure = eos::evaluate<dimensions::Pressure>(model, state);
const double recovered = eos::evaluate<dimensions::MassDensity>(model, pressure).value();
CHECK_THAT(recovered, Catch::Matchers::WithinRel(density, 3.0e-15));
const double step = density * 1.0e-4;
const double plus = eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(
model, dimensions::DensityValue{density + step}).value();
const double minus = eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(
model, dimensions::DensityValue{density - step}).value();
const double derivative = eos::second_partial_derivative<dimensions::Pressure, dimensions::MassDensity, dimensions::MassDensity>(model, state).value();
CHECK_THAT(derivative, Catch::Matchers::WithinRel((plus - minus) / (2.0 * step), 3.0e-9));
}
CHECK(eos::evaluate<dimensions::MassDensity>(model, dimensions::PressureValue{0.0}).value() == 0.0);
if (index == 1.0) {
CHECK((eos::second_partial_derivative<dimensions::Pressure, dimensions::MassDensity, dimensions::MassDensity>(
model, dimensions::DensityValue{0.0}).value() == 1.46));
} else {
CHECK_THROWS_AS((eos::second_partial_derivative<dimensions::Pressure, dimensions::MassDensity, dimensions::MassDensity>(
model, dimensions::DensityValue{0.0})), eos::EOSEvaluationError);
}
for (const double invalid : {-0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS(eos::evaluate<dimensions::MassDensity>(model, dimensions::PressureValue{invalid}), eos::EOSEvaluationError);
CHECK_THROWS_AS((eos::second_partial_derivative<dimensions::Pressure, dimensions::MassDensity, dimensions::MassDensity>(
model, dimensions::DensityValue{invalid})), eos::EOSEvaluationError);
}
}
}

View File

@@ -0,0 +1,33 @@
#include <cmath>
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/eos/evaluation.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/eos/models/polytropic.hpp"
namespace eos = serif::eos;
namespace dimensions = serif::dimensions;
TEST_CASE("Polytropic pressure potential obeys its mechanical derivative contract", "[eos][barotropic_density][unit]") {
for (const double index : {1.0, 1.5, 2.0, 2.5, 3.0}) {
const eos::models::Polytrope model{index, 0.73};
for (const double density : {1.0e-6, 0.2, 2.0}) {
CAPTURE(index, density);
const dimensions::DensityValue state{density};
const double potential = eos::evaluate<dimensions::BarotropicPressurePotential>(model, state).value();
CHECK_THAT(potential, Catch::Matchers::WithinRel((index + 1.0) * 0.73 * std::pow(density, 1.0 / index), 2.0e-15));
const double step = density * 1.0e-4;
const double positive = eos::evaluate<dimensions::BarotropicPressurePotential>(model, dimensions::DensityValue{density + step}).value();
const double negative = eos::evaluate<dimensions::BarotropicPressurePotential>(model, dimensions::DensityValue{density - step}).value();
const double expected_slope = eos::partial_derivative<dimensions::Pressure, dimensions::MassDensity>(model, state).value() / density;
CHECK_THAT((positive - negative) / (2.0 * step), Catch::Matchers::WithinRel(expected_slope, 3.0e-9));
}
CHECK(eos::evaluate<dimensions::BarotropicPressurePotential>(model, dimensions::DensityValue{0.0}).value() == 0.0);
for (const double invalid : {-0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS(eos::evaluate<dimensions::BarotropicPressurePotential>(model, dimensions::DensityValue{invalid}), eos::EOSEvaluationError);
}
}
}

View File

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

View File

@@ -0,0 +1,105 @@
#include <cmath>
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/equations/barotropic_density.hpp"
#include "serif/solver/normalization/scales.hpp"
#include "serif/tests/equations/barotropic_eos.hpp"
namespace {
namespace dimensions = serif::dimensions;
namespace eos = serif::eos;
using MixedPowerBarotrope = serif::tests::equations::MixedPowerBarotrope;
using Balance = serif::equations::BarotropicDensityBalance<MixedPowerBarotrope>;
using Point = serif::equations::EquationPoint<Balance::Operands>;
/** @brief Check one scalar physical column independently of the other field and geometry columns. */
template <typename Term>
void check_field_derivative(const Balance& equation, const Point& point) {
constexpr double step = 2.0e-6;
Point positive = point, negative = point;
positive.fields.get<Term>() += step;
negative.fields.get<Term>() -= step;
const double difference = (equation.evaluate(positive).residual - equation.evaluate(negative).residual) / (2.0 * step);
CHECK_THAT(equation.evaluate(point).field_derivatives.get<Term>(), Catch::Matchers::WithinAbs(difference, 2.0e-8));
}
}
TEST_CASE("Barotropic density balance preserves finite pressure and primitive gauge invariance", "[equations][barotropic_density][unit]") {
const Balance equation{MixedPowerBarotrope{}, dimensions::PressureValue{2.0}};
const Balance shifted{MixedPowerBarotrope{.gauge = 7.5}, dimensions::PressureValue{2.0}};
Point point;
point.fields.get<Balance::Density>() = 2.0;
point.fields.get<Balance::Potential>() = -12.0;
point.fields.get<Balance::SurfaceLevel>() = -2.0 / 3.0;
const auto balance = equation.evaluate(point);
const auto shifted_balance = shifted.evaluate(point);
CHECK_THAT(balance.residual, Catch::Matchers::WithinAbs(0.0, 3.0e-15));
CHECK_THAT(shifted_balance.residual, Catch::Matchers::WithinAbs(balance.residual, 4.0e-15));
CHECK(shifted_balance.field_derivatives.values == balance.field_derivatives.values);
CHECK(shifted_balance.position_derivative == balance.position_derivative);
CHECK(equation.surface_pressure().value() == 2.0);
const Balance vacuum{MixedPowerBarotrope{}};
CHECK_THAT(vacuum.evaluate(point).residual, Catch::Matchers::WithinAbs(10.0 / 3.0, 3.0e-15));
point.fields.get<Balance::Density>() = 1.0;
point.fields.get<Balance::Potential>() = -2.0;
point.fields.get<Balance::SurfaceLevel>() = -2.0;
CHECK(equation.evaluate(point).residual == 0.0);
CHECK(shifted.evaluate(point).residual == 0.0);
}
TEST_CASE("Barotropic density tangent includes every field and physical position", "[equations][barotropic_density][unit]") {
const Balance equation{MixedPowerBarotrope{}, dimensions::PressureValue{2.0}, {1.0, 2.0, 3.0}, {0.2, -0.1, 0.3}};
Point point;
point.fields.get<Balance::Density>() = 1.3;
point.fields.get<Balance::Potential>() = -3.2;
point.fields.get<Balance::SurfaceLevel>() = -0.9;
point.fields.get<Balance::AngularVelocity>() = 0.8;
point.position = {1.2, 0.7, -0.2};
// The displacement from the rotation origin is (1, 0.8, -0.5), with projection 1.1/sqrt(14).
const double centrifugal_potential = 0.5 * (1.0 + 0.64 + 0.25 - 1.21 / 14.0);
const double expected = 2.6 + (4.0 / 3.0) * 1.3 * 1.3 * 1.3 - 10.0 / 3.0 - 3.2 - 0.64 * centrifugal_potential + 0.9;
const auto balance = equation.evaluate(point);
CHECK_THAT(balance.residual, Catch::Matchers::WithinAbs(expected, 4.0e-15));
CHECK_THAT(balance.field_derivatives.get<Balance::Density>(), Catch::Matchers::WithinAbs(2.0 + 4.0 * 1.3 * 1.3, 2.0e-15));
check_field_derivative<Balance::Density>(equation, point);
check_field_derivative<Balance::Potential>(equation, point);
check_field_derivative<Balance::SurfaceLevel>(equation, point);
check_field_derivative<Balance::AngularVelocity>(equation, point);
constexpr double step = 2.0e-6;
for (int axis = 0; axis < 3; ++axis) {
Point positive = point, negative = point;
positive.position[axis] += step;
negative.position[axis] -= step;
const double difference = (equation.evaluate(positive).residual - equation.evaluate(negative).residual) / (2.0 * step);
CHECK_THAT(balance.position_derivative[axis], Catch::Matchers::WithinAbs(difference, 2.0e-8));
}
point.fields.get<Balance::AngularVelocity>() = 0.0;
CHECK(equation.evaluate(point).position_derivative == serif::discretization::forms::VectorValue{});
}
TEST_CASE("Barotropic density balance rejects invalid interior states without clipping", "[equations][barotropic_density][unit]") {
const Balance equation{MixedPowerBarotrope{}};
Point point;
for (const double density : {0.0, -0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
point.fields.get<Balance::Density>() = density;
CHECK_THROWS_AS(equation.evaluate(point), eos::EOSEvaluationError);
}
point.fields.get<Balance::Density>() = 1.0e200;
CHECK_THROWS_AS(equation.evaluate(point), eos::EOSEvaluationError);
for (const double pressure : {-0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS((Balance{MixedPowerBarotrope{}, dimensions::PressureValue{pressure}}), eos::EOSEvaluationError);
}
CHECK_THROWS_AS((Balance{MixedPowerBarotrope{.gauge = std::numeric_limits<double>::infinity()}}), eos::EOSEvaluationError);
}
TEST_CASE("Pressure potential uses the gravitational specific energy reference scale", "[equations][barotropic_density][unit]") {
const auto scales = serif::solver::normalization::prepare_stellar_reference_scales(dimensions::MassValue{2.0},
dimensions::LengthValue{4.0}, dimensions::QuantityValue<dimensions::GravitationalConstant>{3.0});
REQUIRE(scales.has_value());
CHECK(serif::solver::normalization::characteristic_scale(*scales, dimensions::BarotropicPressurePotential{}).value() == 1.5);
}

View File

@@ -0,0 +1,143 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "serif/equations/barotropic_density.hpp"
#include "serif/tests/equations/barotropic_eos.hpp"
#include "serif/tests/equations/gravity_fixture.hpp"
namespace {
using Balance = serif::equations::BarotropicDensityBalance<serif::tests::equations::MixedPowerBarotrope>;
}
TEST_CASE("Barotropic density assembles a nonpolytropic value balance and complete ALE tangent", "[equations][barotropic_density][unit]") {
using namespace serif::tests::equations;
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
gravity::GravityFixture fixture(geometry);
auto density = fixture.density(), potential = fixture.potential();
auto surface_level = constant(1, -0.8), omega = constant(1, 0.2), displacement = fixture.displacement(0.3);
/** @brief Bind exactly the four physical inputs; the EOS supplies every thermodynamic value. */
const auto fields = [&](const auto& rho, const auto& phi, const auto& level, const auto& rotation) {
return backend::make_term_coefficients(backend::bind(fixture.density_binding, rho),
backend::bind(fixture.potential_binding, phi), backend::bind(fixture.constant_binding, level),
backend::bind(fixture.angular_velocity_binding, rotation));
};
const Balance balance{MixedPowerBarotrope{.gauge = 7.5}, serif::dimensions::PressureValue{0.3125},
{1.0, 2.0, 3.0}, {0.2, -0.1, 0.3}};
VolumeEquationFixture equation{balance, fixture.density_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, gravity::reference_policy()};
equation.prepare(fields(density, potential, surface_level, omega), constant(displacement.Size(), 0.0), fixture.workspace);
::mfem::Vector residual;
equation.residual(fixture.workspace, residual);
/** @brief Integrate the analytic physical residual independently of the equation's evaluator. */
::mfem::FunctionCoefficient expected_balance([](const ::mfem::Vector& position) {
const double rho = 0.55 + 0.025 * position[0] - 0.010 * position[1] + 0.006 * position[2];
const double phi = -0.5 + 0.1 * position[0] * position[1] - 0.07 * position[2] * position[2];
const double x = position[0] - 0.2, y = position[1] + 0.1, z = position[2] - 0.3;
const double axial_projection = x + 2.0 * y + 3.0 * z;
const double centrifugal = 0.5 * (x * x + y * y + z * z - axial_projection * axial_projection / 14.0);
// P_surface = 5/16 fixes rho_surface = 1/2 and A_surface - gauge = 7/6.
return 2.0 * rho + (4.0 / 3.0) * rho * rho * rho - 7.0 / 6.0 + phi - 0.04 * centrifugal + 0.8;
});
::mfem::LinearForm reference(&fixture.density_space);
::mfem::Array<int> stellar{1, 1, 0};
const auto& rule = ::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), gravity::reference_order);
auto* integrator = new ::mfem::DomainLFIntegrator(expected_balance);
integrator->SetIntRule(&rule);
reference.AddDomainIntegrator(integrator, stellar);
reference.Assemble();
CHECK(relative_error(residual, fixture.density_binding.gather(reference)) < 3.0e-12);
const auto density_direction = deterministic(density.Size(), 0.1), potential_direction = deterministic(potential.Size(), 0.3);
const auto level_direction = constant(1, -0.05), omega_direction = constant(1, 0.07), displacement_direction = fixture.displacement(0.2);
equation.prepare(fields(density, potential, surface_level, omega), displacement, fixture.workspace);
::mfem::Vector action, transpose;
equation.apply_jacobian(fields(density_direction, potential_direction, level_direction, omega_direction),
displacement_direction, fixture.workspace, action);
const auto dual = deterministic(residual.Size(), 0.7);
equation.apply_jacobian_transpose(dual, fixture.workspace, transpose);
const auto direction = packed(packed(density_direction, potential_direction, level_direction), omega_direction, displacement_direction);
CHECK(std::abs(dual * action - direction * transpose) < 4.0e-11);
constexpr double step = 1.0e-6;
auto plus_density = density, minus_density = density, plus_potential = potential, minus_potential = potential;
auto plus_level = surface_level, minus_level = surface_level, plus_omega = omega, minus_omega = omega;
auto plus_displacement = displacement, minus_displacement = displacement;
plus_density.Add(step, density_direction);
minus_density.Add(-step, density_direction);
plus_potential.Add(step, potential_direction);
minus_potential.Add(-step, potential_direction);
plus_level.Add(step, level_direction);
minus_level.Add(-step, level_direction);
plus_omega.Add(step, omega_direction);
minus_omega.Add(-step, omega_direction);
plus_displacement.Add(step, displacement_direction);
minus_displacement.Add(-step, displacement_direction);
::mfem::Vector positive, negative;
equation.prepare(fields(plus_density, plus_potential, plus_level, plus_omega), plus_displacement, fixture.workspace);
equation.residual(fixture.workspace, positive);
equation.prepare(fields(minus_density, minus_potential, minus_level, minus_omega), minus_displacement, fixture.workspace);
equation.residual(fixture.workspace, negative);
positive -= negative;
positive /= 2.0 * step;
CHECK(relative_error(action, positive) < 4.0e-8);
}
}
TEST_CASE("Reconstructed density retains the field and geometry Jacobian including its transpose", "[equations][unit][reconstruction]") {
using namespace serif::tests::equations;
namespace reconstruction = serif::discretization::reconstruction;
namespace discretization_backend = serif::discretization::backend::mfem;
using Density = blocks::density::mass;
using PolytropicBalance = serif::equations::BarotropicDensityBalance<serif::eos::models::Polytrope>;
gravity::GravityFixture fixture(::mfem::Mesh::MakeCartesian3D(2, 1, 1, ::mfem::Element::HEXAHEDRON));
const double exponent = 5.0 / 3.0;
const auto reconstruction_plan = discretization_backend::make_reconstruction(
reconstruction::SurfacePower<Density>{.density_exponent = exponent}, fixture.density_binding);
discretization_backend::TermBinding<Density, Schema> density_binding(fixture.density_space, reconstruction_plan);
auto density = fixture.project(density_binding, [exponent](const ::mfem::Vector& point) {
return std::pow(point[2], exponent) * (1.0 + 0.1 * point[0]);
});
const auto density_direction = fixture.project(density_binding, [exponent](const ::mfem::Vector& point) {
return 0.1 * std::pow(point[2], exponent) * (point[0] - 0.3 * point[1]);
});
const auto potential = fixture.potential(), level = constant(1, -0.8), omega = constant(1, 0.2);
const auto displacement = fixture.displacement(0.3), displacement_direction = fixture.displacement(0.2);
const auto fields = [&](const auto& rho) {
return backend::make_term_coefficients(backend::bind(density_binding, rho),
backend::bind(fixture.potential_binding, potential), backend::bind(fixture.constant_binding, level),
backend::bind(fixture.angular_velocity_binding, omega));
};
VolumeEquationFixture equation{PolytropicBalance{serif::eos::models::Polytrope{exponent, 0.7}, serif::dimensions::PressureValue{0.0}},
density_binding, fixture.displacement_binding, fixture.compactification, fixture.mapper, gravity::reference_policy()};
equation.prepare(fields(density), displacement, fixture.workspace);
CHECK(equation.integration_order(0) == ::mfem::IntRules.Get(::mfem::Geometry::CUBE, gravity::reference_order).GetOrder());
::mfem::Vector action, transpose;
const auto potential_direction = constant(potential.Size(), 0.0), level_direction = constant(1, 0.0), omega_direction = constant(1, 0.0);
equation.apply_jacobian(backend::make_term_coefficients(backend::bind(density_binding, density_direction),
backend::bind(fixture.potential_binding, potential_direction), backend::bind(fixture.constant_binding, level_direction),
backend::bind(fixture.angular_velocity_binding, omega_direction)), displacement_direction, fixture.workspace, action);
const auto dual = deterministic(density_binding.size(), 0.7);
equation.apply_jacobian_transpose(dual, fixture.workspace, transpose);
const auto direction = packed(packed(density_direction, potential_direction, level_direction), omega_direction, displacement_direction);
CHECK(std::abs(dual * action - direction * transpose) < 4e-11);
for (const double step : {1e-5, 1e-6}) {
auto plus_density = density, minus_density = density;
auto plus_displacement = displacement, minus_displacement = displacement;
plus_density.Add(step, density_direction);
minus_density.Add(-step, density_direction);
plus_displacement.Add(step, displacement_direction);
minus_displacement.Add(-step, displacement_direction);
::mfem::Vector plus, minus;
equation.prepare(fields(plus_density), plus_displacement, fixture.workspace);
equation.residual(fixture.workspace, plus);
equation.prepare(fields(minus_density), minus_displacement, fixture.workspace);
equation.residual(fixture.workspace, minus);
plus -= minus;
plus /= 2.0 * step;
CHECK(relative_error(action, plus) < 4e-8);
}
}

View File

@@ -0,0 +1,79 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/normalization/scales.hpp"
#include "serif/solver/normalization/physical_coordinates.hpp"
#include "serif/equations/barycenter.hpp"
#include "serif/utils/serialize/equations.hpp"
namespace {
namespace equations = serif::equations;
namespace dimensions = serif::dimensions;
using Density = serif::discretization::blocks::density::mass;
/** @brief Check the density partial and every explicit position partial by central differences. */
template <typename Equation, typename Point>
void check_partials(const Equation& equation, const Point& point) {
const auto linearization = equation.evaluate(point);
for (const double step : {1.0e-5, 1.0e-6}) {
auto plus = point, minus = point;
plus.fields.template get<Density>() += step;
minus.fields.template get<Density>() -= step;
const double difference = (equation.evaluate(plus).residual - equation.evaluate(minus).residual) / (2.0 * step);
const double expected = linearization.field_derivatives.template get<Density>();
CHECK(std::abs(difference - expected) < 2.0e-9 * std::max(1.0, std::abs(expected)));
}
for (int axis = 0; axis < 3; ++axis) {
auto plus = point, minus = point;
constexpr double step = 1.0e-5;
plus.position[axis] += step;
minus.position[axis] -= step;
const double difference = (equation.evaluate(plus).residual - equation.evaluate(minus).residual) / (2.0 * step);
CHECK(std::abs(difference - linearization.position_derivative[axis]) < 2.0e-9);
}
}
}
TEST_CASE("Barycenter constraints differentiate all axes about a displaced center and persist", "[equations][barycenter][unit]") {
const auto check_axis = []<int Axis>() {
using Center = equations::BarycenterConstraint<Axis>;
const std::array<double, 3> origin{0.1, -0.2, 0.3};
const std::array<double, 3> position{0.6, 0.3, -0.4};
constexpr double density = 1.2;
constexpr double mass = 2.7;
equations::EquationPoint<typename Center::Operands> center;
center.position = position;
center.fields.template get<Density>() = density;
const Center center_law{mass, origin};
CHECK(center_law.evaluate(center).residual == Catch::Approx(density * (position[Axis] - origin[Axis]) / mass));
check_partials(center_law, center);
namespace serialization = serif::utils::serialize;
const auto saved = serialization::encode(center_law);
const auto restored = serialization::decode<Center>(saved);
REQUIRE(restored);
CHECK(serialization::encode(*restored) == saved);
for (const double invalid : {std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
auto bad_center = origin;
bad_center[Axis] = invalid;
CHECK_THROWS(Center(mass, bad_center));
}
for (const double invalid : {0.0, -0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS(Center{invalid});
}
};
check_axis.template operator()<0>();
check_axis.template operator()<1>();
check_axis.template operator()<2>();
}
TEST_CASE("Frame acceleration coordinates use the stellar acceleration scale", "[equations][barycenter][unit]") {
namespace normalization = serif::solver::normalization;
const auto scales = normalization::prepare_stellar_reference_scales(dimensions::MassValue{12.0}, dimensions::LengthValue{3.0},
dimensions::QuantityValue<dimensions::GravitationalConstant>{2.0});
REQUIRE(scales);
CHECK(normalization::characteristic_scale(*scales, dimensions::Acceleration{}).value() == Catch::Approx(24.0 / 9.0));
}

View File

@@ -0,0 +1,91 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "serif/backend/mfem/discretization/basis_evaluation.hpp"
namespace
{
using namespace serif::discretization::backend::mfem;
/// Require repeatability after the original finite element, quadrature, and cache have been destroyed.
void check_retained_basis(const int basis)
{
BasisEvaluation retained;
BasisWorkspace workspace;
::mfem::Vector coefficients, dual, expected_values, expected_transpose;
{
const ::mfem::H1_FECollection collection(3, 3, basis);
const ::mfem::FiniteElement& element = *collection.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const ::mfem::IntegrationRule rule(::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7));
TensorBasisCache cache;
retained.sample(element, rule, ReferenceOperation::value_and_gradient, 1, cache, ContractionMode::automatic);
CHECK(retained.uses_tensor() == (basis != ::mfem::BasisType::Positive));
coefficients.SetSize(retained.width());
dual.SetSize(retained.height());
for (int i = 0; i < coefficients.Size(); ++i)
{
coefficients[i] = std::sin(0.37 * (i + 1));
}
for (int i = 0; i < dual.Size(); ++i)
{
dual[i] = std::cos(0.23 * (i + 1));
}
retained.evaluate(coefficients, expected_values, workspace);
retained.integrate(dual, expected_transpose, workspace);
}
::mfem::Vector values, transpose;
retained.evaluate(coefficients, values, workspace);
retained.integrate(dual, transpose, workspace);
values -= expected_values;
transpose -= expected_transpose;
CHECK(values.Normlinf() == 0.0);
CHECK(transpose.Normlinf() == 0.0);
}
}
TEST_CASE("MeanField scalar basis actions survive cache finite element and rule destruction", "[equations][tensor_basis][unit]")
{
check_retained_basis(::mfem::BasisType::GaussLobatto);
check_retained_basis(::mfem::BasisType::Positive);
}
TEST_CASE("MeanField basis cache follows rule contents without mutating published factors", "[equations][tensor_basis][unit]")
{
::mfem::H1_FECollection collection(3, 3);
const ::mfem::FiniteElement& element = *collection.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7);
::mfem::IntegrationRule copy(rule), changed_weight(rule), moved_point(rule);
changed_weight.IntPoint(0).weight *= 1.25;
moved_point.IntPoint(0).x += 0.03125;
TensorBasisCache cache;
const auto original = cache.get(element, rule, ReferenceOperation::value_and_gradient, 1);
REQUIRE(original);
::mfem::Vector coefficients(original->dofs()), expected, values;
coefficients = 0.37;
TensorWorkspace workspace;
original->apply(coefficients, expected, workspace);
CHECK(cache.get(element, copy, ReferenceOperation::value_and_gradient, 1) == original);
const auto reweighted = cache.get(element, changed_weight, ReferenceOperation::value_and_gradient, 1);
REQUIRE(reweighted);
CHECK(reweighted != original);
CHECK_FALSE(cache.get(element, moved_point, ReferenceOperation::value_and_gradient, 1));
CHECK(cache.size() == 3);
copy.IntPoint(0).x = moved_point.IntPoint(0).x;
CHECK_FALSE(cache.get(element, copy, ReferenceOperation::value_and_gradient, 1));
CHECK(cache.size() == 3);
CHECK(cache.get(element, rule, ReferenceOperation::value_and_gradient, 1) == original);
// Weights identify cache entries, but basis evaluation introduces no integration weight.
reweighted->apply(coefficients, values, workspace);
values -= expected;
CHECK(values.Normlinf() == 0.0);
cache.clear();
CHECK(cache.size() == 0);
original->apply(coefficients, values, workspace);
values -= expected;
CHECK(values.Normlinf() == 0.0);
}

View File

@@ -0,0 +1,121 @@
// EOS/geometry conditions and tolerances from MeanField's prepared closure tests.
#include <algorithm>
#include <array>
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
struct ClosureCondition final {
double n, K, enthalpy_offset, enthalpy_gradient, density_factor, density_offset, density_gradient;
double deformation, direction_phase;
};
constexpr std::array<ClosureCondition, 3> closure_conditions{
{{1.0, 0.8, 0.65, 0.06, 0.80, 0.015, 0.004, 0.0, 0.31}, {1.5, 1.2, 0.90, 0.09, 1.15, -0.003, 0.003, 0.45, 0.53},
{3.0, 1.5, 1.20, 0.12, 1.40, 0.006, 0.002, 1.0, 0.79}}
};
TEST_CASE("Equation Closure Covers Multiple EOS And Geometry Conditions", tags::integration & tags::equations) {
Fixture f;
for (const auto& condition : closure_conditions) {
CAPTURE(condition.n, condition.K, condition.deformation);
const eos::models::Polytrope eos(condition.n, condition.K);
const auto enthalpy_function = [&](const ::mfem::Vector& x) {
return condition.enthalpy_offset +
condition.enthalpy_gradient * (0.50 * x[0] - 0.30 * x[1] + 0.20 * x[2]);
};
const auto h = f.project(f.enthalpy_binding, enthalpy_function);
const auto rho = f.project(f.density_binding, [&](const ::mfem::Vector& x) {
return condition.density_factor * std::pow(enthalpy_function(x) / eos.enthalpy_scale(), condition.n) +
condition.density_offset +
condition.density_gradient * (0.40 * x[0] + 0.25 * x[1] - 0.15 * x[2]);
});
const auto u = f.displacement(condition.deformation);
auto drho = deterministic(rho.Size(), condition.direction_phase);
auto dh = f.project(f.enthalpy_binding, [](const ::mfem::Vector& x) {
return 0.07 + 0.015 * x[0] + 0.008 * x[2];
});
auto du = f.displacement(0.63);
auto density_action = manual_closure(f, eos, rho, h, u, &drho);
auto enthalpy_action = manual_closure(f, eos, rho, h, u, nullptr, &dh);
auto geometry_action = manual_closure(f, eos, rho, h, u, nullptr, nullptr, &du);
const double target =
std::min({density_action.Norml2(), enthalpy_action.Norml2(), geometry_action.Norml2()});
REQUIRE(target > 1.0e-12);
drho *= target / density_action.Norml2();
dh *= target / enthalpy_action.Norml2();
du *= target / geometry_action.Norml2();
PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector residual, action;
prepared.residual(f.workspace, residual);
prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, action);
CHECK(relative_error(residual, manual_closure(f, eos, rho, h, u)) < 5.0e-12);
CHECK(relative_error(action, manual_closure(f, eos, rho, h, u, &drho, &dh, &du)) < 5.0e-12);
const int expected_order = std::max(4, 5 + static_cast<int>(std::ceil((condition.n - 1.0) * 3))) + 6;
CHECK(prepared.integration_order(0) >= expected_order);
constexpr double step = 1.0e-5;
auto plus_rho = rho, minus_rho = rho, plus_h = h, minus_h = h, plus_u = u, minus_u = u;
plus_rho.Add(step, drho);
minus_rho.Add(-step, drho);
plus_h.Add(step, dh);
minus_h.Add(-step, dh);
plus_u.Add(step, du);
minus_u.Add(-step, du);
auto difference = manual_closure(f, eos, plus_rho, plus_h, plus_u);
difference -= manual_closure(f, eos, minus_rho, minus_h, minus_u);
difference /= 2.0 * step;
difference -= action;
CHECK(difference.Norml2() / (3.0 * target) < 2.0e-7);
}
}
TEST_CASE("Exact Equation Closure Remains Zero Under Deformation", tags::integration & tags::equations) {
Fixture f;
const auto h = constant(f.enthalpy_binding.size(), 1.20);
const auto rho = constant(f.density_binding.size(), std::pow(1.20 / 6.0, 3.0));
auto reference_rho = rho;
reference_rho += 1.0;
const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), du = f.displacement(0.67);
for (const double deformation : {0.0, 0.5, 1.0}) {
CAPTURE(deformation);
const auto u = f.displacement(deformation);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
::mfem::Vector exact, exact_geometry, reference, reference_geometry;
prepared.prepare(f.state(rho, h), u, f.workspace);
prepared.residual(f.workspace, exact);
prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, exact_geometry);
prepared.prepare(f.state(reference_rho, h), u, f.workspace);
prepared.residual(f.workspace, reference);
prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, reference_geometry);
REQUIRE(reference.Norml2() > 1.0e-12);
REQUIRE(reference_geometry.Norml2() > 1.0e-14);
CHECK(exact.Norml2() <= 5.0e-12 * reference.Norml2());
CHECK(exact_geometry.Norml2() <= 5.0e-12 * reference_geometry.Norml2());
}
}
TEST_CASE("Equation Quadrature Resolves Both Closure Forms Through The Existing Policy", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0);
using Mass = discretization::forms::mass<blocks::density::mass, blocks::density::mass>;
using Source = discretization::forms::source<::serif::tests::equations::Enthalpy, blocks::density::mass>;
discretization::quadrature::RuleSet rules;
rules.set_form_control<Mass>({.fixed_order = 18});
rules.set_form_control<Source>({.fixed_order = 8});
PreparedClosure prepared(
Closure{eos::models::Polytrope{1.0, 0.8}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper, discretization::quadrature::Policy{rules}
);
prepared.prepare(f.state(rho, h), u, f.workspace);
CHECK(prepared.integration_order(0) == ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 18).GetOrder());
CHECK_THROWS_AS(prepared.integration_order(2), std::out_of_range);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,36 @@
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
TEST_CASE("Equation Closure Geometry Has The Analytic Dilation Factor", tags::unit & tags::equations) {
Fixture f;
const auto rho = constant(f.density_binding.size(), 0.55), h = constant(f.enthalpy_binding.size(), 0.9);
const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), zero_u = f.displacement(0.0);
constexpr double dilation = 0.2, direction_scale = 0.07;
::mfem::VectorFunctionCoefficient coefficient(3, [](const ::mfem::Vector& x, ::mfem::Vector& u) {
u = x;
u *= dilation;
});
::mfem::GridFunction displacement(&f.displacement_space);
displacement.ProjectCoefficient(coefficient);
const auto u = f.displacement_binding.gather(displacement);
auto du = u;
du *= direction_scale / dilation;
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
::mfem::Vector identity, deformed, action;
prepared.prepare(f.state(rho, h), zero_u, f.workspace);
prepared.residual(f.workspace, identity);
prepared.prepare(f.state(rho, h), u, f.workspace);
prepared.residual(f.workspace, deformed);
prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, action);
identity *= std::pow(1.0 + dilation, 3.0);
CHECK(relative_error(deformed, identity) < 5.0e-12);
deformed *= 3.0 * direction_scale / (1.0 + dilation);
CHECK(relative_error(action, deformed) < 5.0e-12);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,175 @@
// Numerical contracts ported from MeanField tests/operators/kernels/barotropic_closure_kernels.cpp.
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
TEST_CASE("Equation Closure Vanishes For A Representable Constant State", tags::unit & tags::equations) {
Fixture f;
const eos::models::Polytrope eos(3.0, 1.5);
const double h_value = 0.8;
const auto h = constant(f.enthalpy_binding.size(), h_value);
const auto rho = constant(f.density_binding.size(), std::pow(h_value / 6.0, 3.0));
const auto u = f.displacement(0.0);
PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector residual, scale;
prepared.residual(f.workspace, residual);
const auto zero_h = constant(h.Size(), 0.0);
prepared.apply_jacobian(f.state(rho, zero_h), u, f.workspace, scale);
REQUIRE(scale.Norml2() > 0.0);
CHECK(residual.Norml2() / scale.Norml2() < 5.0e-12);
}
TEST_CASE("Equation Closure Density Action Matches The Stellar Mass Matrix", tags::unit & tags::equations) {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
CAPTURE(geometry);
Fixture f(geometry);
const auto rho = f.density();
const auto h = f.enthalpy();
const auto zero_h = constant(h.Size(), 0.0);
const auto u = f.displacement(0.0);
const auto drho = deterministic(rho.Size(), 0.37);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector action;
prepared.apply_jacobian(f.state(drho, zero_h), u, f.workspace, action);
::mfem::Array<int> marker(3);
marker = 0;
marker[0] = marker[1] = 1;
::mfem::BilinearForm mass(&f.density_space);
mass.AddDomainIntegrator(new ::mfem::MassIntegrator(), marker);
mass.Assemble();
mass.Finalize();
const auto full_direction = f.density_binding.scatter(drho);
::mfem::Vector full_action(full_direction.Size());
mass.Mult(full_direction, full_action);
CHECK(relative_error(action, f.density_binding.gather(full_action)) < 5.0e-12);
}
}
TEST_CASE("Equation Closure Thermodynamic Jacobian Matches Centered Differences", tags::unit & tags::equations) {
Fixture f;
const eos::models::Polytrope eos(3.0, 1.5);
const auto rho = f.density();
const auto h = f.enthalpy();
const auto u = f.displacement(1.0);
const auto drho = deterministic(rho.Size(), 0.63);
const auto dh = f.project(f.enthalpy_binding, [](const ::mfem::Vector& x) {
return 0.07 + 0.015 * x[0] + 0.008 * x[2];
});
const auto zero_u = constant(u.Size(), 0.0);
PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector analytic;
prepared.apply_jacobian(f.state(drho, dh), zero_u, f.workspace, analytic);
constexpr double step = 1.0e-6;
auto plus_rho = rho, minus_rho = rho, plus_h = h, minus_h = h;
plus_rho.Add(step, drho);
minus_rho.Add(-step, drho);
plus_h.Add(step, dh);
minus_h.Add(-step, dh);
auto difference = manual_closure(f, eos, plus_rho, plus_h, u);
difference -= manual_closure(f, eos, minus_rho, minus_h, u);
difference /= 2.0 * step;
CHECK(relative_error(analytic, difference) < 2.0e-8);
}
TEST_CASE("Equation Closure Excludes Vacuum And Uses Mapped Volume", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), h = f.enthalpy();
const auto zero_h = constant(h.Size(), 0.0);
const auto zero_u = f.displacement(0.0);
const auto ones = constant(rho.Size(), 1.0);
::mfem::Vector vacuum(f.density_space.GetVSize());
vacuum = 1.0;
vacuum -= f.density_binding.scatter(ones);
const auto vacuum_direction = f.density_binding.gather(vacuum);
CHECK(vacuum.Norml2() > 0.0);
::mfem::Vector identity, deformed, vacuum_action;
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
prepared.prepare(f.state(rho, h), zero_u, f.workspace);
prepared.apply_jacobian(f.state(ones, zero_h), zero_u, f.workspace, identity);
prepared.apply_jacobian(f.state(vacuum_direction, zero_h), zero_u, f.workspace, vacuum_action);
prepared.prepare(f.state(rho, h), f.displacement(1.0), f.workspace);
prepared.apply_jacobian(f.state(ones, zero_h), zero_u, f.workspace, deformed);
REQUIRE(identity.Norml2() > 0.0);
CHECK(vacuum_action.Norml2() <= 1.0e-13 * identity.Norml2());
CHECK(relative_error(deformed, identity) > 1.0e-5);
}
TEST_CASE("Equation Closure Geometry Jacobian Matches Centered Differences", tags::integration & tags::equations) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
Fixture f(::mfem::Element::HEXAHEDRON, ordering);
const eos::models::Polytrope eos(3.0, 1.5);
const auto rho = f.density(), h = f.enthalpy(), du = f.displacement(0.65);
const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0);
for (const double deformation : {0.0, 1.0}) {
CAPTURE(ordering, deformation);
const auto u = f.displacement(deformation);
PreparedClosure prepared(
Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper
);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector analytic;
prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, analytic);
auto plus = u, minus = u;
constexpr double step = 1.0e-5;
plus.Add(step, du);
minus.Add(-step, du);
auto difference = manual_closure(f, eos, rho, h, plus);
difference -= manual_closure(f, eos, rho, h, minus);
difference /= 2.0 * step;
REQUIRE(analytic.Norml2() > 1.0e-12);
CHECK(relative_error(analytic, difference) < 5.0e-8);
}
}
}
TEST_CASE("Equation Closure Geometry Action Is Linear And Zero Outside Stellar Support", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.8);
const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0),
zero_u = constant(u.Size(), 0.0);
const auto first = f.displacement(0.4), second = deterministic(u.Size(), 0.91);
auto combined = first;
combined *= 1.7;
combined.Add(-0.43, second);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector a, b, sum, zero;
prepared.apply_jacobian(f.state(zero_rho, zero_h), first, f.workspace, a);
prepared.apply_jacobian(f.state(zero_rho, zero_h), second, f.workspace, b);
prepared.apply_jacobian(f.state(zero_rho, zero_h), combined, f.workspace, sum);
prepared.apply_jacobian(f.state(zero_rho, zero_h), zero_u, f.workspace, zero);
a *= 1.7;
a.Add(-0.43, b);
CHECK(relative_error(sum, a) < 5.0e-12);
CHECK(zero.Norml2() == 0.0);
auto vacuum_direction = second;
::mfem::Array<int> dofs;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (f.mesh.GetAttribute(element) != 3) {
f.displacement_space.GetElementVDofs(element, dofs);
for (int dof : dofs) {
vacuum_direction[dof < 0 ? -1 - dof : dof] = 0.0;
}
}
}
REQUIRE(vacuum_direction.Norml2() > 0.0);
prepared.apply_jacobian(f.state(zero_rho, zero_h), vacuum_direction, f.workspace, zero);
CHECK(zero.Norml2() == 0.0);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,150 @@
// Prepared-state contracts ported from MeanField's prepared closure and context tests.
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
TEST_CASE("Prepared Equations Use Supported Field Dimensions", tags::unit & tags::equations) {
Fixture f;
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
CHECK_FALSE(prepared.is_prepared());
CHECK(prepared.preparation_count() == 0);
CHECK(f.density_binding.size() > 0);
CHECK(f.density_binding.size() < f.density_space.GetTrueVSize());
CHECK(f.enthalpy_binding.size() < f.enthalpy_space.GetTrueVSize());
CHECK(f.displacement_binding.size() == f.displacement_space.GetTrueVSize());
CHECK(prepared.height() == f.density_binding.size());
const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0);
prepared.prepare(f.state(rho, h), u, f.workspace);
CHECK(prepared.width() == rho.Size() + h.Size() + u.Size());
const auto expanded = f.density_binding.scatter(rho);
CHECK(relative_error(f.density_binding.gather(expanded), rho) == 0.0);
}
TEST_CASE("Prepared Equations Reject EOS Candidates And Recover", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), u = f.displacement(0.0);
auto h = f.enthalpy();
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
prepared.prepare(f.state(rho, h), u, f.workspace);
for (const double bad_value : {-1.0, 1.0e150}) {
CAPTURE(bad_value);
h = bad_value;
const auto rejected = prepared.try_prepare(f.state(rho, h), u, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION);
CHECK(rejected.error().element >= 0);
CHECK(rejected.error().quadrature_point >= 0);
CHECK_FALSE(prepared.is_prepared());
::mfem::Vector result;
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
CHECK_THROWS_AS(prepared.prepare(f.state(rho, h), u, f.workspace), std::domain_error);
}
h = 1.0;
REQUIRE(prepared.try_prepare(f.state(rho, h), u, f.workspace).has_value());
CHECK(prepared.preparation_count() == 2);
}
TEST_CASE("Prepared Equations Reject Folding Geometry And Recover", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), h = f.enthalpy();
::mfem::GridFunction folded(&f.displacement_space);
::mfem::VectorFunctionCoefficient coefficient(3, [](const ::mfem::Vector& x, ::mfem::Vector& u) {
u.SetSize(3);
u = 0.0;
u[0] = -2.0 * x[0];
});
folded.ProjectCoefficient(coefficient);
auto u = f.displacement_binding.gather(folded);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
const auto rejected = prepared.try_prepare(f.state(rho, h), u, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING);
CHECK(rejected.error().mapping_status == mapping::MappingStatus::NON_POSITIVE_DETERMINATE);
CHECK_FALSE(prepared.is_prepared());
CHECK_THROWS_AS(prepared.prepare(f.state(rho, h), u, f.workspace), std::domain_error);
u = 0.0;
CHECK(prepared.try_prepare(f.state(rho, h), u, f.workspace).has_value());
}
TEST_CASE("Prepared Closure Matches Manual Residual And Every Jacobian Column", tags::integration & tags::equations) {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
CAPTURE(geometry);
Fixture f(geometry);
const eos::models::Polytrope eos(3.0, 1.5);
const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(1.0);
const auto drho = deterministic(rho.Size(), 0.43), dh = deterministic(h.Size(), 0.52),
du = f.displacement(0.63);
const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0),
zero_u = constant(u.Size(), 0.0);
PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector residual, density_action, enthalpy_action, geometry_action, complete, packed_action;
prepared.residual(f.workspace, residual);
prepared.apply_jacobian(f.state(drho, zero_h), zero_u, f.workspace, density_action);
prepared.apply_jacobian(f.state(zero_rho, dh), zero_u, f.workspace, enthalpy_action);
prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, geometry_action);
prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, complete);
backend::JacobianOperator jacobian(prepared.cache());
jacobian.Mult(packed(drho, dh, du), packed_action);
CHECK(relative_error(residual, manual_closure(f, eos, rho, h, u)) < 2.0e-12);
CHECK(relative_error(density_action, manual_closure(f, eos, rho, h, u, &drho)) < 2.0e-12);
CHECK(relative_error(enthalpy_action, manual_closure(f, eos, rho, h, u, nullptr, &dh)) < 2.0e-12);
CHECK(relative_error(geometry_action, manual_closure(f, eos, rho, h, u, nullptr, nullptr, &du)) < 2.0e-12);
density_action += enthalpy_action;
density_action += geometry_action;
CHECK(relative_error(complete, density_action) < 2.0e-12);
CHECK(relative_error(packed_action, complete) < 2.0e-12);
CHECK(prepared.preparation_count() == 1);
}
}
TEST_CASE("Prepared Equations Own Frozen Numerical State And Reprepare Every Dependency", tags::unit & tags::equations) {
Fixture f;
const eos::models::Polytrope eos(3.0, 1.5);
auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.35);
const auto drho = deterministic(rho.Size(), 0.12), dh = deterministic(h.Size(), 0.53),
du = f.displacement(0.63);
PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector frozen, frozen_action;
prepared.residual(f.workspace, frozen);
prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, frozen_action);
for (int dependency = 0; dependency < 3; ++dependency) {
CAPTURE(dependency);
if (dependency == 0) {
rho = f.density(1.0);
} else if (dependency == 1) {
h = f.enthalpy(1.0);
} else {
u = f.displacement(0.85);
}
::mfem::Vector unchanged, unchanged_action;
prepared.residual(f.workspace, unchanged);
prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, unchanged_action);
CHECK(relative_error(unchanged, frozen) == 0.0);
CHECK(relative_error(unchanged_action, frozen_action) == 0.0);
prepared.prepare(f.state(rho, h), u, f.workspace);
prepared.residual(f.workspace, frozen);
prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, frozen_action);
CHECK(relative_error(frozen, manual_closure(f, eos, rho, h, u)) < 2.0e-12);
CHECK(relative_error(frozen_action, manual_closure(f, eos, rho, h, u, &drho, &dh, &du)) < 2.0e-12);
}
CHECK(prepared.preparation_count() == 4);
prepared.invalidate();
CHECK_FALSE(prepared.is_prepared());
CHECK_THROWS_AS(prepared.residual(f.workspace, frozen), std::logic_error);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,319 @@
#include "serif/tests/equations/fixture.hpp"
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <memory>
#include <stdexcept>
#include <utility>
namespace {
using namespace serif;
using namespace serif::tests::equations;
namespace dependencies = utils::dependencies;
namespace stages = backend::volume_keys;
using Vector = ::mfem::Vector;
using Mode = backend::PreparationMode;
using Status = dependencies::PreparationStatus;
using DensityValue = discretization::forms::Operand<blocks::density::mass>;
using EnthalpyValue = discretization::forms::Operand<::serif::tests::equations::Enthalpy>;
}
TEST_CASE("Named volume caches retain independent equation configurations", "[equations][computation_graphs][unit]") {
Fixture fixture;
const eos::models::Polytrope first_eos{1.5, 0.25};
const eos::models::Polytrope second_eos{3.0, 1.5};
auto first_density = fixture.density();
const auto first_enthalpy = fixture.enthalpy();
const auto first_displacement = fixture.displacement(0.04);
const auto second_density = fixture.density(1.0);
const auto second_enthalpy = fixture.enthalpy(0.5);
const auto second_displacement = fixture.displacement(-0.03);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const auto first_inputs = backend::make_volume_inputs<Closure>(
discretization, fixture.state(first_density, first_enthalpy), first_displacement
);
const auto second_inputs = backend::make_volume_inputs<Closure>(
discretization, fixture.state(second_density, second_enthalpy), second_displacement
);
auto first = backend::make_volume_equation_cache(Closure{first_eos}, discretization, first_inputs);
auto second = backend::make_volume_equation_cache(Closure{second_eos}, discretization, second_inputs);
CHECK_FALSE(first.ready());
CHECK_FALSE(second.ready());
backend::prepare_volume(first, discretization, fixture.state(first_density, first_enthalpy), first_displacement);
backend::prepare_volume(second, discretization, fixture.state(second_density, second_enthalpy), second_displacement);
Vector first_residual, second_residual;
backend::volume_residual(first.get<stages::Linearization>(), first_residual);
backend::volume_residual(second.get<stages::Linearization>(), second_residual);
CHECK(relative_error(first_residual, manual_closure(fixture, first_eos, first_density, first_enthalpy, first_displacement)) < 2.0e-12);
CHECK(relative_error(second_residual, manual_closure(fixture, second_eos, second_density, second_enthalpy, second_displacement)) < 2.0e-12);
first_density += 0.07;
backend::prepare_volume(first, discretization, fixture.state(first_density, first_enthalpy), first_displacement);
backend::volume_residual(first.get<stages::Linearization>(), first_residual);
CHECK(relative_error(first_residual, manual_closure(fixture, first_eos, first_density, first_enthalpy, first_displacement)) < 2.0e-12);
Vector still_second;
backend::volume_residual(second.get<stages::Linearization>(), still_second);
CHECK(relative_error(still_second, second_residual) == 0.0);
CHECK(second.preparation_count() == 1);
CHECK(first.preparation_count() == 2);
}
TEST_CASE("Volume graph nodes expose coefficient and geometry reuse independently of workspace storage", "[equations][computation_graphs][unit]") {
Fixture fixture;
const eos::models::Polytrope eos{1.5, 0.25};
auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
auto displacement = fixture.displacement(0.04);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const auto inputs = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
const auto initial = cache.report();
CHECK(initial.status<stages::Basis<0>>() == Status::computed);
CHECK(initial.status<stages::Geometry<0>>() == Status::computed);
CHECK(initial.status<stages::ReferenceValues<0, DensityValue>>() == Status::computed);
CHECK(initial.status<stages::ReferenceValues<0, EnthalpyValue>>() == Status::computed);
CHECK(initial.status<stages::QuadratureLinearization<0>>() == Status::computed);
CHECK(initial.status<stages::GeometryDerivatives<0>>() == Status::computed);
const auto original_basis = cache.get<stages::Basis<0>>().front();
REQUIRE(original_basis->reference_geometry.size() == static_cast<std::size_t>(original_basis->rule->GetNPoints()));
const auto original_derivatives = cache.get<stages::GeometryDerivatives<0>>().front();
CHECK(cache.get<stages::Linearization>().geometry_derivatives[0].front() == original_derivatives);
density += 0.03;
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
const auto coefficients_changed = cache.report();
CHECK(coefficients_changed.status<stages::Basis<0>>() == Status::reused);
CHECK(coefficients_changed.status<stages::Geometry<0>>() == Status::reused);
CHECK(coefficients_changed.status<stages::ReferenceValues<0, DensityValue>>() == Status::computed);
CHECK(coefficients_changed.status<stages::ReferenceValues<0, EnthalpyValue>>() == Status::reused);
CHECK(coefficients_changed.status<stages::QuadratureLinearization<0>>() == Status::computed);
CHECK(coefficients_changed.status<stages::GeometryDerivatives<0>>() == Status::computed);
CHECK(cache.get<stages::GeometryDerivatives<0>>().front() != original_derivatives);
Vector residual;
backend::volume_residual(cache.get<stages::Linearization>(), residual);
CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
displacement *= 1.5;
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
const auto geometry_changed = cache.report();
CHECK(geometry_changed.status<stages::Basis<0>>() == Status::reused);
CHECK(cache.get<stages::Basis<0>>().front() == original_basis);
CHECK(geometry_changed.status<stages::Geometry<0>>() == Status::computed);
CHECK(geometry_changed.status<stages::ReferenceValues<0, DensityValue>>() == Status::reused);
CHECK(geometry_changed.status<stages::ReferenceValues<0, EnthalpyValue>>() == Status::reused);
CHECK(geometry_changed.status<stages::QuadratureLinearization<0>>() == Status::computed);
CHECK(geometry_changed.status<stages::GeometryDerivatives<0>>() == Status::computed);
backend::volume_residual(cache.get<stages::Linearization>(), residual);
CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
// Equal coefficient copies and different application scratch describe the same numerical state.
const Vector density_copy(density), enthalpy_copy(enthalpy), displacement_copy(displacement);
backend::Workspace another_workspace;
backend::prepare_volume(cache, discretization, fixture.state(density_copy, enthalpy_copy), displacement_copy);
const auto unchanged = cache.report();
CHECK(unchanged.computed_count() == 0);
CHECK(unchanged.status<stages::Linearization>() == Status::reused);
CHECK(unchanged.status<stages::GeometryDerivatives<0>>() == Status::reused);
Vector repeated;
backend::volume_residual(cache.get<stages::Linearization>(), repeated);
CHECK(relative_error(repeated, residual) == 0.0);
// Application reads accepted snapshots until the caller explicitly prepares new inputs.
const auto direction = packed(fixture.density(0.7), fixture.enthalpy(0.2), fixture.displacement(0.01));
const auto dual = deterministic(fixture.density_binding.size(), 0.3);
Vector action, transpose, frozen_action, frozen_transpose;
backend::apply_jacobian(cache.get<stages::Linearization>(), direction, fixture.workspace, action);
backend::apply_jacobian_transpose(cache.get<stages::Linearization>(), dual, fixture.workspace, transpose);
density += 0.09;
displacement *= 1.1;
backend::volume_residual(cache.get<stages::Linearization>(), repeated);
backend::apply_jacobian(cache.get<stages::Linearization>(), direction, another_workspace, frozen_action);
backend::apply_jacobian_transpose(cache.get<stages::Linearization>(), dual, another_workspace, frozen_transpose);
CHECK(relative_error(repeated, residual) == 0.0);
CHECK(relative_error(frozen_action, action) == 0.0);
CHECK(relative_error(frozen_transpose, transpose) == 0.0);
}
TEST_CASE("Rejected volume graph candidates never expose partially updated operator results", "[equations][computation_graphs][unit]") {
Fixture fixture;
const eos::models::Polytrope eos{1.5, 0.25};
auto density = fixture.density();
auto enthalpy = fixture.enthalpy();
auto displacement = fixture.displacement(0.04);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const auto inputs = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
// Several upstream results change before the equation rejects this candidate.
density += 0.05;
displacement *= 1.5;
enthalpy = -1.0;
const auto rejected = backend::try_prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<stages::Linearization>(), std::logic_error);
CHECK_THROWS_AS(cache.get<stages::Basis<0>>(), std::logic_error);
CHECK_THROWS_AS(cache.get<stages::GeometryDerivatives<0>>(), std::logic_error);
Vector result;
CHECK_THROWS_AS(backend::volume_residual(cache.get<stages::Linearization>(), result), std::logic_error);
enthalpy = fixture.enthalpy(0.5);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
backend::volume_residual(cache.get<stages::Linearization>(), result);
CHECK(relative_error(result, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
CHECK(cache.preparation_count() == 2);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement, Mode::rebuild);
Vector rebuilt;
backend::volume_residual(cache.get<stages::Linearization>(), rebuilt);
CHECK(relative_error(result, rebuilt) < 2.0e-15);
}
TEST_CASE("Volume caches reject another discretization of the same type and recover", "[equations][computation_graphs][unit]") {
Fixture fixture;
Fixture other_fixture;
const eos::models::Polytrope eos{1.5, 0.25};
const auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
const auto displacement = fixture.displacement(0.04);
const auto other_density = other_fixture.density(0.5);
const auto other_enthalpy = other_fixture.enthalpy(0.7);
const auto other_displacement = other_fixture.displacement(-0.03);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const backend::VolumeDiscretization other_discretization{
.residual_binding = other_fixture.density_binding,
.displacement_binding = other_fixture.displacement_binding,
.compactification = other_fixture.compactification,
.mapper = other_fixture.mapper
};
const auto inputs = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
CHECK(cache.preparation_count() == 1);
CHECK_THROWS_AS(
backend::try_prepare_volume(cache, other_discretization, other_fixture.state(other_density, other_enthalpy), other_displacement),
std::invalid_argument
);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<stages::Linearization>(), std::logic_error);
CHECK_THROWS_AS(cache.get<stages::Basis<0>>(), std::logic_error);
CHECK_THROWS_AS(cache.get<stages::GeometryDerivatives<0>>(), std::logic_error);
CHECK(cache.preparation_count() == 1);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
Vector residual;
backend::volume_residual(cache.get<stages::Linearization>(), residual);
CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
CHECK(cache.preparation_count() == 2);
}
TEST_CASE("The volume graph refreshes immutable reference geometry after mesh coordinate updates", "[equations][computation_graphs][unit]") {
Fixture fixture;
const eos::models::Polytrope eos{1.5, 0.25};
const auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
const auto displacement = fixture.displacement(0.04);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const auto inputs = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
const auto original = cache.get<stages::Basis<0>>().front();
const double original_weight = original->reference_geometry.front().weight;
fixture.mesh.Transform([](const ::mfem::Vector& position, ::mfem::Vector& transformed) {
transformed = position;
transformed[0] *= 1.2;
});
backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
CHECK(cache.report().status<stages::Basis<0>>() == Status::computed);
const auto changed = cache.get<stages::Basis<0>>().front();
CHECK(changed != original);
CHECK(changed->reference_geometry.front().weight > original_weight);
CHECK(original->reference_geometry.front().weight == original_weight);
Vector residual;
backend::volume_residual(cache.get<stages::Linearization>(), residual);
CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
fixture.mesh.Transform([](const ::mfem::Vector& position, ::mfem::Vector& transformed) {
transformed = position;
transformed[0] = 0.0;
});
const auto rejected = backend::try_prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING);
CHECK_FALSE(cache.ready());
CHECK(original->reference_geometry.front().weight == original_weight);
}
TEST_CASE("Volume graph source tracking rejects stale reused input contexts", "[equations][computation_graphs][unit]") {
const bool initially_prepared = GENERATE(false, true);
Fixture fixture;
const eos::models::Polytrope eos{1.5, 0.25};
const auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
const auto displacement = fixture.displacement(0.04);
const backend::VolumeDiscretization discretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper
};
const auto inputs = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs);
if (initially_prepared) {
REQUIRE(cache.try_prepare(inputs));
}
SECTION("Coordinate changes require fresh source metadata") {
fixture.mesh.NodesUpdated();
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
const auto current = backend::make_volume_inputs<Closure>(discretization, fixture.state(density, enthalpy), displacement);
REQUIRE(cache.try_prepare(current));
Vector residual;
backend::volume_residual(cache.get<stages::Linearization>(), residual);
CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
}
SECTION("Domain edits invalidate the borrowed bindings") {
fixture.mesh.GetElement(0)->SetAttribute(3);
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
}
SECTION("Topology edits invalidate the borrowed bindings") {
fixture.mesh.UniformRefinement();
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
}
}

View File

@@ -0,0 +1,77 @@
#include <memory>
#include <tuple>
#include <type_traits>
#include <catch2/catch_test_macros.hpp>
#include "serif/discretization/blocks/fields.hpp"
#include "serif/equations/concepts.hpp"
#include "serif/operators/contributions.hpp"
namespace {
/// Record exactly when an equation's owned numerical coefficient is destroyed.
struct TrackedCoefficient {
double value;
std::shared_ptr<int> destructions;
/// Construct the tracked value without an intermediate temporary.
TrackedCoefficient(double coefficient, std::shared_ptr<int> count) : value(coefficient), destructions(std::move(count)) {}
/// Release the observable coefficient lifetime.
~TrackedCoefficient() { ++*destructions; }
};
/// A move-only leaf prevents traversal from concealing lifetime mistakes through equation copies.
struct TrackedEquation {
using Test = serif::discretization::blocks::density::mass;
using Density = serif::discretization::forms::Operand<Test>;
using Operands = serif::utils::types::TypeList<Density>;
static constexpr std::string_view name = "tracked equation";
std::unique_ptr<TrackedCoefficient> slope;
/// Own a numerical coefficient whose destruction remains observable outside this equation.
TrackedEquation(double coefficient, const std::shared_ptr<int>& count) : slope(std::make_unique<TrackedCoefficient>(coefficient, count)) {}
/// Evaluate a linear residual using the retained coefficient.
[[nodiscard]] serif::equations::PointLinearization<Operands> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
serif::equations::PointLinearization<Operands> result;
result.residual = slope->value * point.fields.get<Density>();
result.field_derivatives.get<Density>() = slope->value;
return result;
}
};
/// Generate a nonempty move-only contribution tuple by value, as mixed gravity generates its leaves.
struct GeneratedEquation {
using Test = TrackedEquation::Test;
static constexpr std::string_view name = "generated contribution";
double slope;
std::shared_ptr<int> destructions;
/// The caller must retain this tuple if it wants to retain references to its equation.
[[nodiscard]] auto contributions() const { return std::tuple{TrackedEquation{slope, destructions}}; }
};
static_assert(serif::equations::Equation<GeneratedEquation>);
static_assert(!std::is_copy_constructible_v<TrackedEquation>);
}
TEST_CASE("Contribution bindings retain generated tuples and borrowed move-only leaves", "[equations][contributions][unit]") {
using namespace serif::equations;
const auto destructions = std::make_shared<int>(0);
auto owner = std::make_shared<const GeneratedEquation>(3.0, destructions);
{
const auto leaves = serif::operators::bind_contributions(owner);
STATIC_CHECK(std::tuple_size_v<decltype(leaves)> == 1);
owner.reset();
// The generated tuple remains alive after its parent equation is released.
REQUIRE(*destructions == 0);
CHECK(std::get<0>(leaves).coefficient == 1.0);
EquationPoint<TrackedEquation::Operands> point;
point.fields.get<TrackedEquation::Density>() = 4.0;
CHECK(std::get<0>(leaves).equation->evaluate(point).residual == 12.0);
}
CHECK(*destructions == 1);
}

View File

@@ -0,0 +1,91 @@
#include <limits>
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/equations/primal_gravity.hpp"
#include "serif/equations/registry.hpp"
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
// An external-style EOS needs only the advertised relation and derivative.
struct LinearEOS final {
using Relations = eos::relations::RelationCatalog<eos::relations::DensityFromSpecificEnthalpy>;
std::shared_ptr<int> evaluations;
dimensions::DensityValue evaluate(
eos::relations::DensityFromSpecificEnthalpy,
dimensions::SpecificEnthalpyValue h
) const {
++*evaluations;
return dimensions::DensityValue{2.0 * h.value()};
}
dimensions::PartialDerivative<dimensions::MassDensity, dimensions::SpecificEnthalpy> partial_derivative(
eos::relations::DensityFromSpecificEnthalpy,
dimensions::WithRespectTo<dimensions::SpecificEnthalpy>,
dimensions::SpecificEnthalpyValue
) const {
return dimensions::PartialDerivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>{2.0};
}
};
TEST_CASE("Equation Closure Accepts Compatible EOS Without Backend Specialization", tags::unit & tags::equations) {
Fixture f;
const auto calls = std::make_shared<int>(0);
const auto equation = ::serif::tests::equations::BarotropicClosure{LinearEOS{calls}, 1.0};
VolumeEquationFixture prepared(
equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper
);
const auto rho = constant(f.density_binding.size(), 1.6), h = constant(f.enthalpy_binding.size(), 0.8);
const auto u = f.displacement(0.8);
prepared.prepare(f.state(rho, h), u, f.workspace);
const int preparation_calls = *calls;
REQUIRE(preparation_calls > 0);
::mfem::Vector residual, action;
prepared.residual(f.workspace, residual);
prepared.apply_jacobian(f.state(rho, h), u, f.workspace, action);
CHECK(residual.Norml2() < 1.0e-13);
CHECK(action.Norml2() < 1.0e-13);
CHECK(*calls == preparation_calls);
}
TEST_CASE("Equation Closure Local Derivatives Match The Analytic Polytrope", tags::unit & tags::equations) {
for (const double n : {1.0, 1.5, 3.0}) {
Closure equation{eos::models::Polytrope{n, 1.5}};
serif::equations::EquationPoint<Closure::Operands> point;
point.fields.get<blocks::density::mass>() = 0.55;
point.fields.get<::serif::tests::equations::Enthalpy>() = 0.9;
const auto result = equation.evaluate(point);
const double scale = (n + 1.0) * 1.5;
CHECK_THAT(result.residual, Catch::Matchers::WithinRel(0.55 - std::pow(0.9 / scale, n), 1.0e-14));
CHECK(result.field_derivatives.get<blocks::density::mass>() == 1.0);
CHECK_THAT(
result.field_derivatives.get<::serif::tests::equations::Enthalpy>(),
Catch::Matchers::WithinRel(-n / scale * std::pow(0.9 / scale, n - 1.0), 1.0e-14)
);
CHECK(result.position_derivative == std::array<double, 3>{});
}
}
TEST_CASE("Equation Registry Defines Rows From Its Equations", tags::unit & tags::equations) {
const serif::equations::EquationRegistry registry{
Closure{eos::models::Polytrope{1.0, 0.8}}, serif::equations::PrimalPoissonBalance{1.0}
};
int count = 0;
registry.for_each([&](const auto& equation) {
CHECK_FALSE(equation.name.empty());
++count;
});
CHECK(count == 2);
CHECK(registry.get<Closure>().name == "barotropic closure");
CHECK_THROWS_AS(
(::serif::tests::equations::BarotropicClosure{LinearEOS{std::make_shared<int>(0)}, -1.0}), std::invalid_argument
);
CHECK_THROWS_AS(
(serif::equations::PrimalPoissonBalance{std::numeric_limits<double>::infinity()}),
std::invalid_argument
);
}
} // namespace serif::tests::equations

106
tests/equations/fixture.cpp Normal file
View File

@@ -0,0 +1,106 @@
#include "serif/tests/equations/fixture.hpp"
#include <algorithm>
#include <cmath>
#include <memory>
namespace serif::tests::equations {
::mfem::Mesh make_mesh(::mfem::Element::Type geometry) {
auto mesh = ::mfem::Mesh::MakeCartesian3D(3, 1, 1, geometry, 1.5, 1.0, 1.0);
::mfem::Vector center(3);
for (int element = 0; element < mesh.GetNE(); ++element) {
auto* transformation = mesh.GetElementTransformation(element);
transformation->Transform(::mfem::Geometries.GetCenter(transformation->GetGeometryType()), center);
mesh.GetElement(element)->SetAttribute(1 + static_cast<int>(center[0] / 0.5));
}
mesh.SetAttributes();
return mesh;
}
::mfem::Vector constant(int size, double value) {
::mfem::Vector result(size);
result = value;
return result;
}
::mfem::Vector deterministic(int size, double phase) {
::mfem::Vector result(size);
for (int i = 0; i < size; ++i) {
result[i] = 0.1 * std::sin(0.13 * i + phase);
}
return result;
}
::mfem::Vector packed(
const ::mfem::Vector& density,
const ::mfem::Vector& enthalpy,
const ::mfem::Vector& displacement
) {
::mfem::Vector result(density.Size() + enthalpy.Size() + displacement.Size());
std::copy_n(density.GetData(), density.Size(), result.GetData());
std::copy_n(enthalpy.GetData(), enthalpy.Size(), result.GetData() + density.Size());
std::copy_n(displacement.GetData(), displacement.Size(), result.GetData() + density.Size() + enthalpy.Size());
return result;
}
double relative_error(const ::mfem::Vector& actual, const ::mfem::Vector& expected) {
::mfem::Vector difference(actual);
difference -= expected;
return difference.Norml2() / std::max(expected.Norml2(), 1.0e-30);
}
Fixture::Fixture(::mfem::Element::Type geometry, ::mfem::Ordering::Type ordering) : Fixture(make_mesh(geometry), ordering) {}
Fixture::Fixture(::mfem::Mesh supplied_mesh, ::mfem::Ordering::Type ordering) :
mesh(std::move(supplied_mesh)),
density_space(&mesh, &density_collection),
enthalpy_space(&mesh, &enthalpy_collection),
potential_space(&mesh, &potential_collection),
displacement_space(&mesh, &enthalpy_collection, 3, ordering),
compactification(&enthalpy_space),
density_binding(density_space),
enthalpy_binding(enthalpy_space),
displacement_binding(displacement_space),
mapper(
{},
std::make_unique<mapping::compactification::KelvinCompactification>(
utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0}
)
) {
compactification = 0.0;
}
::mfem::Vector Fixture::project(
const discretization::backend::mfem::DofMap& field,
std::function<double(const ::mfem::Vector&)> function
) {
::mfem::FunctionCoefficient coefficient(std::move(function));
::mfem::GridFunction values(&field.space());
values.ProjectCoefficient(coefficient);
return field.gather(values);
}
::mfem::Vector Fixture::density(double phase) {
return project(density_binding, [phase](const ::mfem::Vector& x) {
return 0.55 + 0.025 * x[0] - 0.010 * x[1] + 0.006 * x[2] + 0.03 * phase;
});
}
::mfem::Vector Fixture::enthalpy(double phase) {
return project(enthalpy_binding, [phase](const ::mfem::Vector& x) {
return 0.90 + 0.020 * x[0] - 0.010 * x[1] + 0.005 * x[2] + 0.02 * phase;
});
}
::mfem::Vector Fixture::displacement(double scale) {
::mfem::VectorFunctionCoefficient coefficient(3, [scale](const ::mfem::Vector& x, ::mfem::Vector& u) {
u.SetSize(3);
u[0] = scale * (0.03 * x[0] + 0.02 * x[0] * x[1]);
u[1] = scale * (-0.02 * x[1] + 0.01 * x[0] * x[2]);
u[2] = scale * (0.04 * x[2] - 0.015 * x[0] * x[1]);
});
::mfem::GridFunction values(&displacement_space);
values.ProjectCoefficient(coefficient);
return displacement_binding.gather(values);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,165 @@
#include <algorithm>
#include <cmath>
#include <vector>
#include <stroid/stroid.h>
#include "serif/tests/equations/gravity_port.hpp"
namespace serif::tests::equations::gravity_port {
/// Project an old test polynomial into the same registered coefficient representation.
Vector project_vector(const discretization::backend::mfem::DofMap& binding, std::function<void(const Vector&, Vector&)> function) {
::mfem::VectorFunctionCoefficient coefficient(3, std::move(function));
::mfem::GridFunction values(&binding.space());
values.ProjectCoefficient(coefficient);
return binding.gather(values);
}
Coefficients zeros(const GravityFixture& f) {
return {constant(f.potential_binding.size(), 0.0),
constant(f.density_binding.size(), 0.0), constant(f.displacement_binding.size(), 0.0)};
}
Vector sequence(int size, double scale, double phase) {
Vector v(size);
for (int i = 0; i < size; ++i) {
const double index = i + 1;
v[i] = scale * (std::sin(0.31 * index + phase) + 0.37 * std::cos(0.17 * index - phase));
}
return v;
}
Vector prepared_sequence(int size, double phase) {
Vector v(size);
for (int i = 0; i < size; ++i) {
const double index = i + 1;
v[i] = std::sin(0.37 * index + phase) + 0.31 * std::cos(0.19 * index - 0.5 * phase);
}
return v;
}
Vector operator_sequence(int size, double phase) {
Vector v(size);
for (int i = 0; i < size; ++i) {
const double index = i + 1;
v[i] = 0.4 * std::sin(0.37 * index + phase) + 0.2 * std::cos(0.19 * index - phase);
}
return v;
}
Coefficients state(GravityFixture& f, bool deformed) {
return {sequence(f.potential_binding.size(), 0.3, 0.37),
sequence(f.density_binding.size(), 0.7, 0.11), prepared_displacement(f, deformed ? 1.0 : 0.0)};
}
Coefficients direction(GravityFixture& f, int column) {
auto d = zeros(f);
if (column == 0) {
d.density = sequence(d.density.Size(), 0.13, 0.19);
} else {
d.potential = sequence(d.potential.Size(), 0.09, 0.41);
}
return d;
}
Vector prepared_displacement(GravityFixture& f, double scale) {
return project_vector(f.displacement_binding, [scale](const Vector& x, Vector& u) {
u.SetSize(3);
u[0] = scale * (0.04 * x[0] + 0.01 * x[1] * x[2]);
u[1] = scale * (-0.03 * x[1] + 0.008 * x[0] * x[2]);
u[2] = scale * (0.02 * x[2] - 0.006 * x[0] * x[1]);
});
}
Vector affine_displacement(GravityFixture& f, double scale) {
return project_vector(f.displacement_binding, [scale](const Vector& x, Vector& u) {
u = x;
u *= scale;
});
}
Vector kernel_displacement(GravityFixture& f) {
return project_vector(f.displacement_binding, [](const Vector& x, Vector& u) {
u.SetSize(3);
u[0] = 0.025 * x[0] + 0.006 * x[1] * x[2];
u[1] = -0.018 * x[1] + 0.005 * x[0] * x[2];
u[2] = 0.014 * x[2] + 0.004 * x[0] * x[1];
});
}
Vector geometry_direction(GravityFixture& f, int index) {
return project_vector(f.displacement_binding, [index](const Vector& x, Vector& u) {
u.SetSize(3);
if (index == 0) {
u[0] = 0.16 * x[0] + 0.03 * x[1];
u[1] = -0.11 * x[1] + 0.02 * x[2];
u[2] = 0.13 * x[2] - 0.025 * x[0];
} else {
u[0] = -0.07 * x[1] + 0.025 * x[2];
u[1] = 0.09 * x[0] + 0.04 * x[2];
u[2] = -0.08 * x[2] + 0.03 * x[0] * x[1];
}
});
}
Vector kernel_density(GravityFixture& f, bool secondary) {
return f.project(f.density_binding, [secondary](const Vector& x) {
return secondary ? 0.8 - 0.11 * x[0] + 0.13 * x[1] - 0.06 * x[2] + 0.03 * x[1] * x[2]
: 1.2 + 0.16 * x[0] - 0.09 * x[1] + 0.07 * x[2] + 0.04 * x[0] * x[1];
});
}
Vector domain_density(GravityFixture& f, bool stellar) {
Vector density(f.density_space.GetVSize());
std::vector<bool> stellar_dofs(density.Size(), false);
::mfem::Array<int> dofs;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (!Schema::template domain_id_belongs_to<discretization::domain::StellarDomains>(f.mesh.GetAttribute(element))) {
continue;
}
f.density_space.GetElementDofs(element, dofs);
for (const int dof : dofs) {
stellar_dofs[dof] = true;
}
}
for (int dof = 0; dof < density.Size(); ++dof) {
density[dof] = stellar_dofs[dof] == stellar ? 1.0 : 0.0;
}
return density;
}
std::unique_ptr<GravityFixture> stellar_fixture(int refinement_levels, int geometry_order) {
stroid::config::MeshConfig config;
config.refinement_levels = refinement_levels;
config.order = geometry_order;
config.include_external_domain = true;
config.r_core = 0.25;
config.r_star = 1.0;
config.r_infinity = 4.0;
config.flattening = 0.0;
config.optimization_methods = stroid::config::OptimizationMethods{false, true};
auto mesh = stroid::GenerateMesh(config);
REQUIRE(mesh.exterior_coordinate != nullptr);
REQUIRE(mesh.exterior_coordinate->values != nullptr);
auto fixture = std::make_unique<GravityFixture>(::mfem::Mesh(*mesh.mesh));
::mfem::GridFunctionCoefficient coordinate(mesh.exterior_coordinate->values.get());
fixture->compactification.ProjectCoefficient(coordinate);
return fixture;
}
Coefficients shifted(const Coefficients& v, const Coefficients& d, double scale) {
auto result = v;
result.potential.Add(scale, d.potential);
result.density.Add(scale, d.density);
result.displacement.Add(scale, d.displacement);
return result;
}
double relative(const Vector& actual, const Vector& expected, double floor) {
REQUIRE(actual.Size() == expected.Size());
Vector difference(actual);
difference -= expected;
return difference.Norml2() / std::max(expected.Norml2(), floor);
}
double symmetric_relative(const Vector& first, const Vector& second) {
REQUIRE(first.Size() == second.Size());
Vector difference(first);
difference -= second;
return difference.Norml2() / std::max({first.Norml2(), second.Norml2(), 1.0e-14});
}
double prepared_relative(const Vector& actual, const Vector& expected) {
return relative(actual, expected, std::numeric_limits<double>::epsilon());
}
double pairing_relative(double actual, double expected) {
return std::abs(actual - expected) / std::max(std::abs(expected), std::numeric_limits<double>::epsilon());
}
Vector combination(const Vector& first, double a, const Vector& second, double b) {
Vector result(first);
result *= a;
result.Add(b, second);
return result;
}
} // namespace serif::tests::equations::gravity_port

View File

@@ -0,0 +1,134 @@
#include <algorithm>
#include <limits>
#include "serif/tests/equations/gravity_port.hpp"
namespace {
using namespace serif;
using namespace serif::tests::equations;
using gravity::GravityFixture;
using gravity_port::project_vector;
using Vector = ::mfem::Vector;
using Displacement = blocks::displacement::vector;
using Divergence = discretization::forms::Operand<Displacement, discretization::forms::Operation::Divergence>;
struct DivergenceInput {
using Test = blocks::density::mass;
using Operands = utils::types::TypeList<Divergence>;
static constexpr std::string_view name = "H1 divergence input test";
serif::equations::PointLinearization<Operands> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
serif::equations::PointLinearization<Operands> result;
result.residual = point.fields.get<Divergence>();
result.field_derivatives.get<Divergence>() = 1.0;
return result;
}
int additional_order(Divergence, int) const {
return 0;
}
};
/// A smooth nonlinear displacement direction, independent of the field being differentiated.
Vector direction_displacement(GravityFixture& fixture) {
return project_vector(fixture.displacement_binding, [](const Vector& position, Vector& value) {
const double x = position[0], y = position[1], z = position[2];
value.SetSize(3);
value[0] = 0.019 * x + 0.011 * y * z - 0.006 * z * z + 0.004 * 0.83 * y;
value[1] = -0.016 * y + 0.008 * x * z + 0.005 * x * x - 0.003 * 0.83 * z;
value[2] = 0.013 * z - 0.010 * x * y + 0.006 * y * y + 0.004 * 0.83 * x;
});
}
double relative(const Vector& actual, const Vector& expected) {
Vector error(actual);
error -= expected;
return error.Norml2() / std::max({actual.Norml2(), expected.Norml2(), 100.0 * std::numeric_limits<double>::epsilon()});
}
}
TEST_CASE("H1 divergence inputs retain component gradients under a nonsymmetric map", "[equations][h1_divergence][unit]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture fixture(geometry, ordering);
const auto value = project_vector(fixture.displacement_binding, [](const Vector& x, Vector& u) {
u.SetSize(3);
u[0] = 0.4 * x[0] - 0.2 * x[1] + 0.3 * x[2];
u[1] = 0.1 * x[0] + 0.5 * x[1] - 0.1 * x[2];
u[2] = -0.3 * x[0] + 0.2 * x[1] + 0.6 * x[2];
});
const auto geometry_value = project_vector(fixture.displacement_binding, [](const Vector& x, Vector& u) {
u.SetSize(3);
u[0] = 0.2 * x[0] + 0.07 * x[1];
u[1] = -0.04 * x[0] - 0.1 * x[1] + 0.05 * x[2];
u[2] = 0.03 * x[0] + 0.1 * x[2];
});
const auto direction = gravity_port::prepared_displacement(fixture, 0.83);
const auto geometry_direction = direction_displacement(fixture);
auto state = [&](const Vector& v) {
return backend::make_term_coefficients(backend::bind(fixture.displacement_binding, v));
};
VolumeEquationFixture prepared{
DivergenceInput{}, fixture.density_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, gravity::reference_policy()
};
prepared.prepare(state(value), geometry_value, fixture.workspace);
Vector actual;
prepared.residual(fixture.workspace, actual);
::mfem::DenseMatrix A(3), inverse(3);
A = 0.0;
A(0, 0) = 1.2;
A(0, 1) = 0.07;
A(1, 0) = -0.04;
A(1, 1) = 0.9;
A(1, 2) = 0.05;
A(2, 0) = 0.03;
A(2, 2) = 1.1;
::mfem::CalcInverse(A, inverse);
constexpr double gradient[3][3] = {{0.4, -0.2, 0.3}, {0.1, 0.5, -0.1}, {-0.3, 0.2, 0.6}};
double divergence = 0.0;
for (int component = 0; component < 3; ++component) {
for (int derivative = 0; derivative < 3; ++derivative) {
divergence += gradient[component][derivative] * inverse(derivative, component);
}
}
::mfem::ConstantCoefficient coefficient(divergence * A.Det());
::mfem::LinearForm reference_load(&fixture.density_space);
::mfem::Array<int> stellar{1, 1, 0};
reference_load.AddDomainIntegrator(new ::mfem::DomainLFIntegrator(coefficient), stellar);
reference_load.Assemble();
CHECK(relative(actual, fixture.density_binding.gather(reference_load)) < 2.0e-12);
for (int column = 0; column < 3; ++column) {
auto selected = direction, selected_geometry = geometry_direction;
if (column == 0) {
selected_geometry = 0.0;
} else if (column == 1) {
selected = 0.0;
}
prepared.prepare(state(value), geometry_value, fixture.workspace);
Vector derivative;
prepared.apply_jacobian(state(selected), selected_geometry, fixture.workspace, derivative);
auto plus = value, minus = value, plus_geometry = geometry_value, minus_geometry = geometry_value;
constexpr double step = 1.0e-5;
plus.Add(step, selected);
minus.Add(-step, selected);
plus_geometry.Add(step, selected_geometry);
minus_geometry.Add(-step, selected_geometry);
Vector plus_residual, minus_residual;
prepared.prepare(state(plus), plus_geometry, fixture.workspace);
prepared.residual(fixture.workspace, plus_residual);
prepared.prepare(state(minus), minus_geometry, fixture.workspace);
prepared.residual(fixture.workspace, minus_residual);
plus_residual -= minus_residual;
plus_residual *= 0.5 / step;
CHECK(relative(derivative, plus_residual) < 3.0e-8);
}
}
}
}

View File

@@ -0,0 +1,109 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "mfem/mesh/mesh.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/backend/mfem/operators/operand_evaluation.hpp"
#include "serif/discretization/blocks/fields.hpp"
namespace {
using Scalar = ::serif::tests::equations::Enthalpy;
using Gradient = serif::discretization::forms::Operand<Scalar, serif::discretization::forms::Operation::Gradient>;
using Evaluator = serif::operators::backend::mfem::OperandEvaluator<Gradient>;
using serif::discretization::backend::mfem::BasisEvaluation;
using serif::discretization::backend::mfem::BasisWorkspace;
using serif::discretization::backend::mfem::ContractionMode;
using serif::discretization::backend::mfem::TensorBasisCache;
}
TEST_CASE("Scalar H1 gradients agree with physical MFEM bases and transpose actions", "[equations][scalar_gradient][unit]") {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
auto mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, geometry, 1.7, 0.9, 1.2);
// Shear as well as unequal lengths exposes inverse/transpose mistakes.
for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) {
double* position = mesh.GetVertex(vertex);
position[0] += 0.23 * position[1];
position[2] -= 0.17 * position[1];
}
::mfem::H1_FECollection collection(3, 3);
TensorBasisCache cache;
BasisWorkspace workspace;
for (int index = 0; index < mesh.GetNE(); ++index) {
auto& transformation = *mesh.GetElementTransformation(index);
const auto& element = *collection.FiniteElementForGeometry(mesh.GetElementBaseGeometry(index));
const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 8);
for (const auto mode : {ContractionMode::automatic, ContractionMode::dense}) {
BasisEvaluation basis;
Evaluator::sample_basis(element, rule, basis, cache, mode, transformation, workspace);
::mfem::Vector coefficients(element.GetDof()), values, dual(3 * rule.GetNPoints()), transpose;
for (int dof = 0; dof < coefficients.Size(); ++dof) {
coefficients[dof] = std::sin(0.37 * (dof + 1));
}
Evaluator::evaluate(basis, coefficients, values, workspace);
::mfem::DenseMatrix physical_basis(element.GetDof(), 3);
for (int point = 0; point < rule.GetNPoints(); ++point) {
transformation.SetIntPoint(&rule.IntPoint(point));
element.CalcPhysDShape(transformation, physical_basis);
for (int axis = 0; axis < 3; ++axis) {
double expected = 0.0;
for (int dof = 0; dof < coefficients.Size(); ++dof) {
expected += physical_basis(dof, axis) * coefficients[dof];
}
CHECK(std::abs(values[3 * point + axis] - expected) < 2.0e-11);
dual[3 * point + axis] = std::cos(0.19 * (3 * point + axis + 1));
}
}
Evaluator::integrate(basis, dual, transpose, workspace);
CHECK(std::abs(values * dual - coefficients * transpose) < 2.0e-10);
}
}
}
}
TEST_CASE("Scalar H1 gradient geometry derivatives follow inverse transpose mapping", "[equations][scalar_gradient][unit]") {
serif::mapping::MappingPointContext context;
context.mapping_jacobian.SetSize(3);
context.mapping_jacobian = 0.0;
context.mapping_jacobian(0, 0) = 1.2;
context.mapping_jacobian(1, 1) = 0.8;
context.mapping_jacobian(2, 2) = 1.1;
context.mapping_jacobian(0, 1) = 0.21;
context.mapping_jacobian(2, 0) = -0.13;
context.inverse_mapping_jacobian.SetSize(3);
::mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian);
serif::mapping::MappingPointVariation variation;
variation.mapping_jacobian_variation.SetSize(3);
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
variation.mapping_jacobian_variation(row, column) = std::sin(0.41 * (3 * row + column + 1));
}
}
::mfem::DenseMatrix product(3);
variation.inverse_mapping_jacobian_variation.SetSize(3);
::mfem::Mult(context.inverse_mapping_jacobian, variation.mapping_jacobian_variation, product);
::mfem::Mult(product, context.inverse_mapping_jacobian, variation.inverse_mapping_jacobian_variation);
variation.inverse_mapping_jacobian_variation *= -1.0;
const Evaluator::ReferenceValue reference{0.7, -0.2, 0.4};
const auto physical = Evaluator::physical_value(reference, context);
// A^T grad_x = grad_X is independent of the implementation's inverse multiplication.
for (int axis = 0; axis < 3; ++axis) {
double recovered = 0.0;
for (int component = 0; component < 3; ++component) {
recovered += context.mapping_jacobian(component, axis) * physical[component];
}
CHECK(std::abs(recovered - reference[axis]) < 2.0e-14);
}
const auto derivative = Evaluator::geometry_variation(reference, context, variation);
auto plus = context, minus = context;
constexpr double step = 1.0e-6;
plus.mapping_jacobian.Add(step, variation.mapping_jacobian_variation);
minus.mapping_jacobian.Add(-step, variation.mapping_jacobian_variation);
::mfem::CalcInverse(plus.mapping_jacobian, plus.inverse_mapping_jacobian);
::mfem::CalcInverse(minus.mapping_jacobian, minus.inverse_mapping_jacobian);
const auto plus_gradient = Evaluator::physical_value(reference, plus);
const auto minus_gradient = Evaluator::physical_value(reference, minus);
for (int axis = 0; axis < 3; ++axis) {
CHECK(std::abs(derivative[axis] - (plus_gradient[axis] - minus_gradient[axis]) / (2.0 * step)) < 2.0e-10);
}
}

View File

@@ -0,0 +1,86 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
namespace {
using namespace serif::tests::equations;
using Scalar = ::serif::tests::equations::Enthalpy;
using Gradient = serif::discretization::forms::Operand<Scalar, serif::discretization::forms::Operation::Gradient>;
/** @brief Exercise a scalar diffusion row through the production volume assembly. */
struct ScalarDiffusion : serif::equations::Contribution<Gradient> {
using Operands = serif::utils::types::TypeList<Gradient>;
static constexpr std::string_view name = "scalar gradient assembly regression";
/** @brief The flux is grad(z), so its partial with respect to grad(z) is identity. */
serif::equations::PointLinearization<Operands, serif::discretization::forms::VectorValue>
evaluate(const serif::equations::EquationPoint<Operands>& point) const {
serif::equations::PointLinearization<Operands, serif::discretization::forms::VectorValue> result;
result.residual = point.fields.get<Gradient>();
for (int axis = 0; axis < 3; ++axis) {
result.field_derivatives.get<Gradient>()[axis][axis] = 1.0;
}
return result;
}
/** @brief This linear flux introduces no extra polynomial coefficient. */
static int additional_order(Gradient, int) { return 0; }
};
}
TEST_CASE("Scalar gradient test rows assemble diffusion and differentiate moving geometry", "[equations][scalar_gradient][unit]") {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
Fixture fixture(geometry);
const auto values = deterministic(fixture.enthalpy_binding.size(), 0.31);
const auto direction = deterministic(values.Size(), 0.57);
const auto displacement = fixture.displacement(0.6);
const auto displacement_direction = fixture.displacement(0.2);
const auto zero = constant(displacement.Size(), 0.0);
const auto fields = [&](const ::mfem::Vector& coefficients) {
return backend::make_term_coefficients(backend::bind(fixture.enthalpy_binding, coefficients));
};
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = 10});
VolumeEquationFixture prepared{ScalarDiffusion{}, fixture.enthalpy_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, serif::discretization::quadrature::Policy{rules}};
prepared.prepare(fields(values), zero, fixture.workspace);
::mfem::Vector residual;
prepared.residual(fixture.workspace, residual);
::mfem::BilinearForm reference(&fixture.enthalpy_space);
::mfem::Array<int> stellar{1, 1, 0};
reference.AddDomainIntegrator(new ::mfem::DiffusionIntegrator(), stellar);
reference.Assemble();
reference.Finalize();
const auto full_values = fixture.enthalpy_binding.scatter(values);
::mfem::Vector full_residual(full_values.Size());
reference.Mult(full_values, full_residual);
CHECK(relative_error(residual, fixture.enthalpy_binding.gather(full_residual)) < 3.0e-12);
prepared.prepare(fields(values), displacement, fixture.workspace);
if (geometry == ::mfem::Element::HEXAHEDRON) {
CHECK(prepared.tensor_element_count() > 0);
}
::mfem::Vector action, transpose;
prepared.apply_jacobian(fields(direction), displacement_direction, fixture.workspace, action);
const auto dual = deterministic(residual.Size(), 0.83);
prepared.apply_jacobian_transpose(dual, fixture.workspace, transpose);
const auto packed_direction = packed(constant(0, 0.0), direction, displacement_direction);
CHECK(std::abs(dual * action - packed_direction * transpose) < 3.0e-11);
constexpr double step = 1.0e-6;
auto plus = values, minus = values, plus_displacement = displacement, minus_displacement = displacement;
plus.Add(step, direction);
minus.Add(-step, direction);
plus_displacement.Add(step, displacement_direction);
minus_displacement.Add(-step, displacement_direction);
::mfem::Vector plus_residual, minus_residual;
prepared.prepare(fields(plus), plus_displacement, fixture.workspace);
prepared.residual(fixture.workspace, plus_residual);
prepared.prepare(fields(minus), minus_displacement, fixture.workspace);
prepared.residual(fixture.workspace, minus_residual);
plus_residual -= minus_residual;
plus_residual /= 2.0 * step;
CHECK(relative_error(action, plus_residual) < 3.0e-8);
}
}

View File

@@ -0,0 +1,118 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "mfem/mesh/mesh.hpp"
#include "serif/backend/mfem/operators/operand_evaluation.hpp"
#include "serif/discretization/blocks/fields.hpp"
namespace {
namespace forms = serif::discretization::forms;
namespace backend = serif::operators::backend::mfem;
using Gradient = forms::Operand<serif::discretization::blocks::displacement::vector, forms::Operation::Gradient>;
using Evaluator = backend::OperandEvaluator<Gradient>;
}
TEST_CASE("Vector H1 gradients retain native physical components and transpose actions", "[equations][vector_gradient][unit]") {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
auto mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, geometry, 1.7, 0.9, 1.2);
for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) {
double* position = mesh.GetVertex(vertex);
position[0] += 0.23 * position[1];
position[2] -= 0.17 * position[1];
}
::mfem::H1_FECollection collection(3, 3);
backend::TensorBasisCache cache;
backend::BasisWorkspace workspace;
for (int index = 0; index < mesh.GetNE(); ++index) {
auto& transformation = *mesh.GetElementTransformation(index);
const auto& element = *collection.FiniteElementForGeometry(mesh.GetElementBaseGeometry(index));
const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 8);
for (const auto mode : {backend::ContractionMode::automatic, backend::ContractionMode::dense}) {
backend::BasisEvaluation basis;
Evaluator::sample_basis(element, rule, basis, cache, mode, transformation, workspace);
CHECK(basis.uses_tensor() == (geometry == ::mfem::Element::HEXAHEDRON && mode == backend::ContractionMode::automatic));
::mfem::Vector coefficients(3 * element.GetDof()), values, dual(9 * rule.GetNPoints()), transpose;
for (int repeat = 0; repeat < 2; ++repeat) {
for (int dof = 0; dof < coefficients.Size(); ++dof) {
coefficients[dof] = repeat == 0 ? std::sin(0.37 * (dof + 1)) : 0.0;
}
Evaluator::evaluate(basis, coefficients, values, workspace);
::mfem::DenseMatrix physical_basis(element.GetDof(), 3);
for (int point = 0; point < rule.GetNPoints(); ++point) {
transformation.SetIntPoint(&rule.IntPoint(point));
element.CalcPhysDShape(transformation, physical_basis);
for (int component = 0; component < 3; ++component) {
for (int derivative = 0; derivative < 3; ++derivative) {
double expected = 0.0;
for (int dof = 0; dof < element.GetDof(); ++dof) {
expected += physical_basis(dof, derivative) * coefficients[component * element.GetDof() + dof];
}
const int entry = 9 * point + 3 * component + derivative;
CHECK(std::abs(values[entry] - expected) < 2.0e-11);
dual[entry] = repeat == 0 ? std::cos(0.19 * (entry + 1)) : 0.0;
}
}
}
Evaluator::integrate(basis, dual, transpose, workspace);
CHECK(std::abs(values * dual - coefficients * transpose) < 2.0e-10);
if (repeat != 0) {
CHECK(values.Norml2() == 0.0);
CHECK(transpose.Norml2() == 0.0);
}
}
}
}
}
}
TEST_CASE("Vector gradient mapping differentiates right multiplication by the inverse map", "[equations][vector_gradient][unit]") {
serif::mapping::MappingPointContext context;
context.mapping_jacobian.SetSize(3);
context.mapping_jacobian = 0.0;
context.mapping_jacobian(0, 0) = 1.2;
context.mapping_jacobian(1, 1) = 0.8;
context.mapping_jacobian(2, 2) = 1.1;
context.mapping_jacobian(0, 1) = 0.21;
context.mapping_jacobian(2, 0) = -0.13;
context.inverse_mapping_jacobian.SetSize(3);
::mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian);
serif::mapping::MappingPointVariation variation;
variation.mapping_jacobian_variation.SetSize(3);
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
variation.mapping_jacobian_variation(row, column) = std::sin(0.41 * (3 * row + column + 1));
}
}
::mfem::DenseMatrix product(3);
variation.inverse_mapping_jacobian_variation.SetSize(3);
::mfem::Mult(context.inverse_mapping_jacobian, variation.mapping_jacobian_variation, product);
::mfem::Mult(product, context.inverse_mapping_jacobian, variation.inverse_mapping_jacobian_variation);
variation.inverse_mapping_jacobian_variation *= -1.0;
const Evaluator::ReferenceValue reference{0.7, -0.2, 0.4, -0.6, 0.3, 0.1, 0.8, -0.9, 0.5};
const auto physical = Evaluator::physical_value(reference, context);
const auto derivative = Evaluator::geometry_variation(reference, context, variation);
for (int component = 0; component < 3; ++component) {
for (int axis = 0; axis < 3; ++axis) {
double recovered = 0.0;
for (int inner = 0; inner < 3; ++inner) {
recovered += physical[component][inner] * context.mapping_jacobian(inner, axis);
}
CHECK(std::abs(recovered - reference[3 * component + axis]) < 2.0e-14);
}
}
for (const double step : {1.0e-5, 1.0e-6}) {
auto plus = context, minus = context;
plus.mapping_jacobian.Add(step, variation.mapping_jacobian_variation);
minus.mapping_jacobian.Add(-step, variation.mapping_jacobian_variation);
::mfem::CalcInverse(plus.mapping_jacobian, plus.inverse_mapping_jacobian);
::mfem::CalcInverse(minus.mapping_jacobian, minus.inverse_mapping_jacobian);
const auto plus_gradient = Evaluator::physical_value(reference, plus);
const auto minus_gradient = Evaluator::physical_value(reference, minus);
for (int component = 0; component < 3; ++component) {
for (int axis = 0; axis < 3; ++axis) {
CHECK(std::abs(derivative[component][axis] - (plus_gradient[component][axis] - minus_gradient[component][axis]) / (2.0 * step)) < 3.0e-9);
}
}
}
}

View File

@@ -0,0 +1,196 @@
#include <algorithm>
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/bilinearform.hpp"
#include "mfem/fem/bilininteg.hpp"
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/nonconforming.hpp"
namespace {
using namespace serif::tests::equations;
namespace forms = serif::discretization::forms;
using Vector = ::mfem::Vector;
using VectorField = blocks::displacement::vector;
using Value = forms::Operand<VectorField>;
using Gradient = forms::Operand<VectorField, forms::Operation::Gradient>;
using StellarDomains = serif::discretization::domain::StellarDomains;
/** @brief Supply the componentwise diffusion flux for comparison with native MFEM. */
struct VectorDiffusion : serif::equations::Contribution<Gradient, StellarDomains> {
using Operands = serif::utils::types::TypeList<Gradient>;
static constexpr std::string_view name = "vector diffusion regression";
/** @brief Each physical gradient component contributes its own identity derivative. */
serif::equations::PointLinearization<Operands, forms::MatrixValue> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
serif::equations::PointLinearization<Operands, forms::MatrixValue> result;
result.residual = point.fields.get<Gradient>();
for (int entry = 0; entry < 9; ++entry) {
result.field_derivatives.get<Gradient>()[entry][entry] = 1.0;
}
return result;
}
/** @brief The diffusion flux has no additional polynomial coefficient. */
static int additional_order(Gradient, int) { return 0; }
};
/** @brief Couple transposed gradient entries and vector values to expose tensor indexing errors. */
struct CoupledFlux : serif::equations::Contribution<Gradient, StellarDomains> {
using Operands = serif::utils::types::TypeList<Gradient, Value>;
static constexpr std::string_view name = "coupled tensor flux regression";
/** @brief Differentiate a nonlinear manufactured flux, including its explicit position dependence. */
serif::equations::PointLinearization<Operands, forms::MatrixValue> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
const auto& gradient = point.fields.get<Gradient>();
const auto& value = point.fields.get<Value>();
serif::equations::PointLinearization<Operands, forms::MatrixValue> result;
for (int component = 0; component < 3; ++component) {
for (int axis = 0; axis < 3; ++axis) {
const int entry = 3 * component + axis;
result.residual[component][axis] = gradient[component][axis] + 0.37 * gradient[axis][component] +
0.11 * gradient[component][axis] * gradient[component][axis] + 0.19 * value[component] * value[axis] +
0.07 * point.position[axis] * value[component];
auto& gradient_partial = result.field_derivatives.get<Gradient>();
gradient_partial[entry][entry] += 1.0 + 0.22 * gradient[component][axis];
gradient_partial[entry][3 * axis + component] += 0.37;
auto& value_partial = result.field_derivatives.get<Value>();
value_partial[entry][component] += 0.19 * value[axis] + 0.07 * point.position[axis];
value_partial[entry][axis] += 0.19 * value[component];
result.position_derivative[entry][axis] = 0.07 * value[component];
}
}
return result;
}
/** @brief Account for the quadratic factors in this manufactured flux. */
template <typename Operand> static int additional_order(Operand, int order) { return order + 1; }
};
/** @brief Exercise the reverse rectangular derivative from tensor input to vector output. */
struct CoupledForce : serif::equations::Contribution<Value, StellarDomains> {
using Operands = serif::utils::types::TypeList<Gradient, Value>;
static constexpr std::string_view name = "coupled vector force regression";
/** @brief Contract the gradient with velocity and differentiate both independent operands. */
serif::equations::PointLinearization<Operands, forms::VectorValue> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
const auto& gradient = point.fields.get<Gradient>();
const auto& value = point.fields.get<Value>();
serif::equations::PointLinearization<Operands, forms::VectorValue> result;
for (int component = 0; component < 3; ++component) {
for (int axis = 0; axis < 3; ++axis) {
result.residual[component] += gradient[component][axis] * value[axis];
result.field_derivatives.get<Gradient>()[component][3 * component + axis] = value[axis];
result.field_derivatives.get<Value>()[component][axis] = gradient[component][axis];
}
}
return result;
}
/** @brief The convection-like product adds one field's polynomial order. */
template <typename Operand> static int additional_order(Operand, int order) { return order; }
};
/** @brief Publish the flux and force as two contributions of one vector row. */
struct CoupledEquation {
using Test = VectorField;
static constexpr std::string_view name = "coupled tensor flux and vector force regression";
[[nodiscard]] auto contributions() const { return std::tuple{CoupledFlux{}, CoupledForce{}}; }
};
/** @brief Fix identical quadrature for native MFEM and the prepared equation. */
serif::discretization::quadrature::Policy integration_policy() {
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = 12});
return serif::discretization::quadrature::Policy{rules};
}
/** @brief Give the physical vector field a coefficient input independent of mesh displacement. */
auto field(const Fixture& fixture, const Vector& values) {
return backend::make_term_coefficients(backend::bind(fixture.displacement_binding, values));
}
/** @brief Compare native local assembly followed by P transpose with the reduced backend residual. */
void check_native_diffusion(Fixture& fixture, backend::ContractionMode mode) {
const Vector values = deterministic(fixture.displacement_binding.size(), 0.31);
const Vector zero = constant(values.Size(), 0.0);
VolumeEquationFixture prepared{VectorDiffusion{}, fixture.displacement_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, integration_policy(), mode};
prepared.prepare(field(fixture, values), zero, fixture.workspace);
Vector actual;
prepared.residual(fixture.workspace, actual);
::mfem::BilinearForm reference(&fixture.displacement_space);
auto* integrator = new ::mfem::VectorDiffusionIntegrator();
integrator->SetIntRule(&::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), 12));
::mfem::Array<int> stellar{1, 1, 0};
reference.AddDomainIntegrator(integrator, stellar);
reference.Assemble();
reference.Finalize();
const Vector full_values = fixture.displacement_binding.scatter(values);
Vector native(full_values.Size());
reference.Mult(full_values, native);
CHECK(relative_error(actual, fixture.displacement_binding.accumulate_dual(native)) < 2.0e-11);
if (fixture.mesh.GetElementBaseGeometry(0) == ::mfem::Geometry::CUBE) {
CHECK((prepared.tensor_element_count() > 0) == (mode == backend::ContractionMode::automatic));
}
}
/** @brief Check each independent field/geometry column, combined action, and complete transpose. */
void check_coupled_derivatives(Fixture& fixture, backend::ContractionMode mode) {
const Vector values = deterministic(fixture.displacement_binding.size(), 0.31);
const Vector direction = deterministic(values.Size(), 0.57);
const Vector displacement = fixture.displacement(0.6);
const Vector displacement_direction = fixture.displacement(0.2);
const CoupledEquation equation;
VolumeEquationFixture prepared{equation, fixture.displacement_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, integration_policy(), mode};
const Vector dual = deterministic(fixture.displacement_binding.size(), 0.83);
for (int column = 0; column < 3; ++column) {
Vector selected = direction, selected_geometry = displacement_direction;
if (column == 0) { selected_geometry = 0.0; }
if (column == 1) { selected = 0.0; }
prepared.prepare(field(fixture, values), displacement, fixture.workspace);
Vector action, transpose;
prepared.apply_jacobian(field(fixture, selected), selected_geometry, fixture.workspace, action);
prepared.apply_jacobian_transpose(dual, fixture.workspace, transpose);
const Vector packed_direction = packed(constant(0, 0.0), selected, selected_geometry);
const double forward = dual * action;
CHECK(std::abs(forward - packed_direction * transpose) < 3.0e-11 * std::max(1.0, std::abs(forward)));
// Geometry directions are small; these steps avoid subtractive cancellation without relaxing the tolerance.
for (const double step : {1.0e-4, 1.0e-5}) {
CAPTURE(column, step);
Vector plus = values, minus = values, plus_geometry = displacement, minus_geometry = displacement;
plus.Add(step, selected);
minus.Add(-step, selected);
plus_geometry.Add(step, selected_geometry);
minus_geometry.Add(-step, selected_geometry);
Vector plus_residual, minus_residual;
prepared.prepare(field(fixture, plus), plus_geometry, fixture.workspace);
prepared.residual(fixture.workspace, plus_residual);
prepared.prepare(field(fixture, minus), minus_geometry, fixture.workspace);
prepared.residual(fixture.workspace, minus_residual);
plus_residual -= minus_residual;
plus_residual /= 2.0 * step;
CHECK(relative_error(action, plus_residual) < 5.0e-8);
}
}
}
}
TEST_CASE("Vector gradient equations retain dense tensor and ordering equivalence", "[equations][vector_gradient][unit]") {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
Fixture fixture(geometry, ordering);
for (const auto mode : {backend::ContractionMode::automatic, backend::ContractionMode::dense}) {
CAPTURE(geometry, ordering, mode);
check_native_diffusion(fixture, mode);
check_coupled_derivatives(fixture, mode);
}
}
}
}
TEST_CASE("Vector gradient equations preserve true-coordinate work on STROID hanging meshes", "[equations][vector_gradient][integration][nonconforming]") {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
auto generated = serif::tests::nonconforming::mesh(0, 3);
REQUIRE(generated.mesh->Nonconforming());
Fixture fixture(::mfem::Mesh(*generated.mesh), ordering);
REQUIRE(fixture.displacement_space.GetTrueVSize() < fixture.displacement_space.GetVSize());
CAPTURE(ordering);
check_native_diffusion(fixture, backend::ContractionMode::automatic);
check_coupled_derivatives(fixture, backend::ContractionMode::automatic);
}
}

View File

@@ -0,0 +1,109 @@
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/mapping/volume_variation.hpp"
#include <algorithm>
#include <cmath>
namespace serif::tests::equations {
::mfem::Vector manual_closure(
Fixture& f,
const eos::models::Polytrope& eos,
const ::mfem::Vector& density,
const ::mfem::Vector& enthalpy,
const ::mfem::Vector& displacement,
const ::mfem::Vector* density_direction,
const ::mfem::Vector* enthalpy_direction,
const ::mfem::Vector* displacement_direction
) {
::mfem::GridFunction rho(&f.density_space), h(&f.enthalpy_space), u(&f.displacement_space);
::mfem::GridFunction drho(&f.density_space), dh(&f.enthalpy_space), du(&f.displacement_space);
rho = f.density_binding.scatter(density);
h = f.enthalpy_binding.scatter(enthalpy);
u = f.displacement_binding.scatter(displacement);
drho = 0.0;
dh = 0.0;
du = 0.0;
if (density_direction != nullptr) {
drho = f.density_binding.scatter(*density_direction);
}
if (enthalpy_direction != nullptr) {
dh = f.enthalpy_binding.scatter(*enthalpy_direction);
}
if (displacement_direction != nullptr) {
du = f.displacement_binding.scatter(*displacement_direction);
}
const bool action =
density_direction != nullptr || enthalpy_direction != nullptr || displacement_direction != nullptr;
const double n = eos.polytropic_index();
const double scale = (n + 1.0) * eos.polytropic_constant();
::mfem::Vector result(f.density_space.GetVSize());
result = 0.0;
mapping::DMWorkspace workspace;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (!Schema::domain_id_belongs_to<discretization::domain::StellarDomains>(f.mesh.GetAttribute(element))) {
continue;
}
auto& transformation = *f.mesh.GetElementTransformation(element);
const auto& test = *f.density_space.GetFE(element);
const auto& displacement_element = *f.displacement_space.GetFE(element);
const int order = std::max(
2 * test.GetOrder(), test.GetOrder() + f.enthalpy_space.GetFE(element)->GetOrder() +
static_cast<int>(std::ceil(std::max(0.0, n - 1.0) * 3))
) +
transformation.OrderW() + 3 * (displacement_element.GetOrder() - 1);
const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), order);
::mfem::Array<int> dofs;
auto* dof_transformation = f.displacement_space.GetElementVDofs(element, dofs);
::mfem::Vector u_dofs, du_dofs;
u.GetSubVector(dofs, u_dofs);
du.GetSubVector(dofs, du_dofs);
if (dof_transformation != nullptr) {
dof_transformation->InvTransformPrimal(u_dofs);
dof_transformation->InvTransformPrimal(du_dofs);
}
const auto u_data = mapping::element_displacement_data_from_element_vdofs(displacement_element, u_dofs);
const auto du_data = mapping::element_displacement_data_from_element_vdofs(displacement_element, du_dofs);
::mfem::Vector compactification_dofs(f.enthalpy_space.GetFE(element)->GetDof());
compactification_dofs = 0.0;
const mapping::ElementCompactificationData compactification(
*f.enthalpy_space.GetFE(element), compactification_dofs
);
const mapping::ElementMappingData mapping_data{u_data, compactification};
::mfem::Vector shape(test.GetDof()), local(test.GetDof());
local = 0.0;
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto& ip = rule.IntPoint(point);
const double enthalpy_value = h.GetValue(element, ip);
const double eos_density = std::pow(enthalpy_value / scale, n);
const double eos_derivative = n / scale * std::pow(enthalpy_value / scale, n - 1.0);
const double closure = rho.GetValue(element, ip) - eos_density;
const double variation = drho.GetValue(element, ip) - eos_derivative * dh.GetValue(element, ip);
mapping::VolumeMappingContext context;
const auto status = f.mapper.evaluate_volume(mapping_data, transformation, ip, workspace, context);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_MAPPING>(
status == mapping::MappingStatus::VALID, "Invalid manual-reference geometry."
);
double integrand = context.quadrature.physical_integration_weight * closure;
if (action) {
mapping::VolumeMappingVariation geometry_variation;
const auto variation_status = serif::tests::evaluate_volume_variation(
f.mapper, mapping_data, du_data, transformation, ip, context, workspace, geometry_variation
);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_MAPPING>(
variation_status == mapping::MappingStatus::VALID, "Invalid manual-reference variation."
);
integrand = context.quadrature.physical_integration_weight * variation +
geometry_variation.weight_variation * closure;
}
test.CalcShape(ip, shape);
local.Add(integrand, shape);
}
auto* test_transformation = f.density_space.GetElementVDofs(element, dofs);
if (test_transformation != nullptr) {
test_transformation->TransformDual(local);
}
result.AddElementVector(dofs, local);
}
return f.density_binding.gather(result);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,245 @@
#include <cmath>
#include <limits>
#include "serif/equations/mass_normalization.hpp"
#include "serif/tests/equations/gravity_port.hpp"
namespace {
using namespace serif;
using namespace serif::tests::equations;
using namespace serif::tests::equations::gravity;
using Equation = serif::equations::MassNormalization;
using Prepared = VolumeEquationFixture<Equation, Schema>;
using Vector = ::mfem::Vector;
auto state(GravityFixture& f, const Vector& density) {
return backend::make_term_coefficients(backend::bind(f.density_binding, density));
}
auto make_operator(GravityFixture& f, double target) {
return Prepared{Equation{target}, f.constant_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
}
double residual(GravityFixture& f, Prepared& op, const Vector& density, const Vector& displacement) {
op.prepare(state(f, density), displacement, f.workspace);
Vector result;
op.residual(f.workspace, result);
REQUIRE(result.Size() == 1);
return result[0];
}
double action(GravityFixture& f, Prepared& op, const Vector& density, const Vector& displacement) {
Vector result;
op.apply_jacobian(state(f, density), displacement, f.workspace, result);
REQUIRE(result.Size() == 1);
return result[0];
}
// MeanField prepared_mass_normalization.cpp:66,98,132. Preserve the original profiles.
Vector density(GravityFixture& f, double phase, bool direction = false) {
return f.project(f.density_binding, [=](const Vector& x) {
if (direction) {
return 0.19 * std::sin(0.71 * x[0] + phase) - 0.13 * std::cos(0.89 * x[1] - phase) + 0.08 * x[2];
}
return 0.91 + 0.07 * std::sin(0.83 * x[0] + phase) + 0.05 * std::cos(0.61 * x[1] - phase) + 0.03 * x[2] * x[2];
});
}
Vector displacement(GravityFixture& f, double scale) {
return gravity_port::project_vector(f.displacement_binding, [=](const Vector& x, Vector& value) {
value.SetSize(3);
value[0] = scale * (0.07 * x[0] + 0.018 * x[1] * x[2]);
value[1] = scale * (-0.05 * x[1] + 0.013 * x[0] * x[2]);
value[2] = scale * (0.04 * x[2] - 0.011 * x[0] * x[1]);
});
}
double relative(double actual, double expected) {
return std::abs(actual - expected) / std::max(std::abs(expected), 100.0 * std::numeric_limits<double>::epsilon());
}
// Independent MFEM quadrature of rho det(A) and delta-rho det(A) + rho cof(A):delta-A.
double reference(GravityFixture& f, const Vector& rho, const Vector& u, const Vector* drho = nullptr, const Vector* du = nullptr) {
::mfem::GridFunction density(&f.density_space), displacement(&f.displacement_space);
::mfem::GridFunction delta_density(&f.density_space), delta_displacement(&f.displacement_space);
density = f.density_binding.scatter(rho);
displacement = f.displacement_binding.scatter(u);
if (drho) { delta_density = f.density_binding.scatter(*drho); }
if (du) { delta_displacement = f.displacement_binding.scatter(*du); }
double result = 0.0;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (!f.density_binding.supports(element)) { continue; }
auto& transform = *f.mesh.GetElementTransformation(element);
const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order);
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto& ip = rule.IntPoint(point);
transform.SetIntPoint(&ip);
::mfem::DenseMatrix A(3), dA(3), inverse(3);
displacement.GetVectorGradient(transform, A);
for (int axis = 0; axis < 3; ++axis) { A(axis, axis) += 1.0; }
double integrand = density.GetValue(transform, ip);
if (drho || du) {
integrand = drho ? delta_density.GetValue(transform, ip) : 0.0;
if (du) {
delta_displacement.GetVectorGradient(transform, dA);
::mfem::CalcInverse(A, inverse);
double trace = 0.0;
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) { trace += inverse(i, j) * dA(j, i); }
}
integrand += density.GetValue(transform, ip) * trace;
}
}
result += ip.weight * transform.Weight() * A.Det() * integrand;
}
}
return result;
}
void check_columns(GravityFixture& f) {
CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering());
f.set_compactification();
const auto zero_rho = constant(f.density_binding.size(), 0.0), zero_u = constant(f.displacement_binding.size(), 0.0);
// Original density and geometry finite differences, with their original steps and tolerances.
for (int column = 0; column < 2; ++column) {
const auto rho = density(f, column == 0 ? 0.31 : 0.37);
const auto u = displacement(f, column == 0 ? 0.43 : 0.51);
const auto drho = column == 0 ? density(f, 0.67, true) : zero_rho;
const auto du = column == 0 ? zero_u : displacement(f, -0.79);
auto op = make_operator(f, column == 0 ? 1.23 : 1.11);
(void)residual(f, op, rho, u);
const double actual = action(f, op, drho, du);
const double step = column == 0 ? 1.0e-3 : 1.0e-6;
auto plus_rho = rho, minus_rho = rho, plus_u = u, minus_u = u;
plus_rho.Add(step, drho); minus_rho.Add(-step, drho);
plus_u.Add(step, du); minus_u.Add(-step, du);
const double difference = (residual(f, op, plus_rho, plus_u) - residual(f, op, minus_rho, minus_u)) / (2.0 * step);
CAPTURE(column, actual, difference);
CHECK(relative(actual, difference) < (column == 0 ? 3.0e-8 : 2.0e-7));
}
// Original complete action, independent reference, column sum, and supported local adapter routing.
const auto rho = density(f, 0.47), u = displacement(f, 0.57);
const auto drho = density(f, 0.71, true), du = displacement(f, -0.63);
auto op = make_operator(f, 1.19);
const auto initial = residual(f, op, rho, u);
CHECK(relative(initial, reference(f, rho, u) - 1.19) < 1.0e-12);
const double complete = action(f, op, drho, du);
CHECK(relative(complete, reference(f, rho, u, &drho, &du)) < 2.0e-11);
CHECK(relative(complete, action(f, op, drho, zero_u) + action(f, op, zero_rho, du)) < 2.0e-15);
backend::JacobianOperator adapter(op.cache());
CHECK(adapter.Height() == 1);
CHECK(adapter.Width() == drho.Size() + du.Size());
Vector joined(adapter.Width()), result;
std::copy_n(drho.GetData(), drho.Size(), joined.GetData());
std::copy_n(du.GetData(), du.Size(), joined.GetData() + drho.Size());
adapter.Mult(joined, result);
CHECK(result[0] == complete);
}
}
TEST_CASE("Mass normalization retains affine volume scaling and target subtraction", "[equations][mass_normalization][integration]") {
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
const auto rho = constant(f.density_binding.size(), 1.37), zero = constant(f.displacement_binding.size(), 0.0);
auto op = make_operator(f, 0.73);
const double undeformed = residual(f, op, rho, zero) + 0.73;
CHECK(relative(undeformed, reference(f, rho, zero)) < 1.0e-12);
CHECK(relative(residual(f, op, rho, zero), undeformed - 0.73) < 2.0e-15);
const double deformed = residual(f, op, rho, gravity_port::affine_displacement(f, 0.086)) + 0.73;
CHECK(relative(deformed / undeformed, std::pow(1.0 + 0.086, 3)) < 5.0e-7);
}
TEST_CASE("Mass normalization retains density geometry and complete action accuracy", "[equations][mass_normalization][integration]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture f(geometry, ordering);
check_columns(f);
}
}
auto fixture = gravity_port::stellar_fixture();
check_columns(*fixture);
}
TEST_CASE("Global mass preparations own their density state", "[equations][mass_normalization][unit]") {
GravityFixture f;
auto rho = density(f, 0.47);
const auto u = displacement(f, 0.57);
VolumeEquationFixture op{Equation{0.73}, f.constant_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
const double expected = reference(f, rho, u) - 0.73;
op.prepare(state(f, rho), u, f.workspace);
Vector result;
op.residual(f.workspace, result);
CHECK(relative(result[0], expected) < 2.0e-15);
rho *= 1.2;
op.residual(f.workspace, result);
CHECK(relative(result[0], expected) < 2.0e-15);
op.prepare(state(f, rho), u, f.workspace);
op.residual(f.workspace, result);
CHECK(relative(result[0], reference(f, rho, u) - 0.73) < 2.0e-15);
const auto drho = density(f, 0.71, true), du = displacement(f, -0.63);
op.apply_jacobian(state(f, drho), du, f.workspace, result);
CHECK(relative(result[0], reference(f, rho, u, &drho, &du)) < 2.0e-11);
}
TEST_CASE("Mass normalization rejects invalid targets density and geometry then recovers", "[equations][mass_normalization][unit]") {
for (double target : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS(Equation{target}, std::invalid_argument);
}
GravityFixture f;
auto op = make_operator(f, 1.0);
auto rho = constant(f.density_binding.size(), 1.0);
auto u = constant(f.displacement_binding.size(), 0.0);
const double expected = residual(f, op, rho, u);
rho = std::numeric_limits<double>::quiet_NaN();
auto rejected = op.try_prepare(state(f, rho), u, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE);
CHECK_FALSE(op.is_prepared());
CHECK_THROWS_AS(op.prepare(state(f, rho), u, f.workspace), std::domain_error);
rho = 1.0;
u = gravity_port::affine_displacement(f, -2.0);
rejected = op.try_prepare(state(f, rho), u, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING);
u = 0.0;
CHECK(residual(f, op, rho, u) == expected);
}
// MeanField prepared_mass_normalization.cpp:282: finite coefficients can overflow during interpolation.
TEST_CASE("Mass normalization rejects nonfinite density interpolation and recovers", "[equations][mass_normalization][unit]") {
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
auto rho = constant(f.density_binding.size(), -0.5 * std::numeric_limits<double>::max());
const auto u = constant(f.displacement_binding.size(), 0.0);
auto op = make_operator(f, std::numeric_limits<double>::max());
const auto rejected = op.try_prepare(state(f, rho), u, f.workspace);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE);
CHECK(rejected.error().element >= 0);
CHECK_FALSE(op.is_prepared());
CHECK_THROWS_AS(op.prepare(state(f, rho), u, f.workspace), std::domain_error);
rho = 1.0;
auto recovered = make_operator(f, 1.0);
REQUIRE(recovered.try_prepare(state(f, rho), u, f.workspace).has_value());
CHECK(recovered.is_prepared());
}
TEST_CASE("Global scalar bindings retain one coefficient and reject stale meshes", "[equations][mass_normalization][unit]") {
GravityFixture f;
auto& binding = f.constant_binding;
const auto coefficient = constant(1, 0.39);
CHECK(binding.size() == 1);
CHECK(binding.full_size() == 1);
CHECK(binding.gather(binding.scatter(coefficient))[0] == coefficient[0]);
CHECK_THROWS_AS(binding.space(), std::logic_error);
CHECK_THROWS_AS(binding.validate_values(constant(2, 0.0)), std::invalid_argument);
for (int element = 0; element < f.mesh.GetNE(); ++element) {
Vector local;
binding.element_values(element, coefficient, local);
REQUIRE(local.Size() == 1);
CHECK(local[0] == (binding.supports(element) ? coefficient[0] : 0.0));
}
f.mesh.GetElement(0)->SetAttribute(3);
CHECK_THROWS(binding.validate());
}

View File

@@ -0,0 +1,31 @@
equation_test_sources = files(
'reference_tables_meanfield.cpp',
'basis_lifetime_meanfield.cpp',
'pressure_boundary.cpp',
'fixture.cpp',
'manual_closure.cpp',
'closure_kernels.cpp',
'closure_prepared.cpp',
'closure_conditions.cpp',
'closure_dilation.cpp',
'state_validation.cpp',
'equation_contracts.cpp',
'gravity_port_fixture.cpp',
'h1_divergence.cpp',
'h1_gradient.cpp',
'h1_gradient_equation.cpp',
'h1_vector_gradient.cpp',
'h1_vector_gradient_equation.cpp',
'barycenter.cpp',
'barotropic_density.cpp',
'barotropic_density_assembly.cpp',
'surface_potential_test.cpp',
'mass_normalization.cpp',
'angular_momentum.cpp',
'pressure_surface.cpp',
'tensor_basis.cpp',
'serial_backend.cpp',
'computation_graphs.cpp',
'contribution_lifetimes.cpp',
'scoped_computations.cpp',
)

View File

@@ -0,0 +1,159 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <tuple>
#include <variant>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/discretization/boundary_trace.hpp"
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/tests/equations/gravity_fixture.hpp"
#include "serif/tests/equations/volume_equation_fixture.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
namespace {
namespace test = serif::tests::equations;
namespace gravity = test::gravity;
namespace blocks = serif::discretization::blocks;
namespace physics = serif::geometry::surface;
namespace surface = physics::backend::mfem;
namespace volume = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
using Vector = mfem::Vector;
using Enthalpy = ::serif::tests::equations::Enthalpy;
/** @brief Identify boundary coefficients that depend on several native independent coordinates. */
[[nodiscard]] std::vector<int> hanging_boundary_dofs(const mfem::FiniteElementSpace& space, const mfem::Array<int>& attributes) {
const mfem::SparseMatrix* prolongation = space.GetConformingProlongation();
REQUIRE(prolongation != nullptr);
mfem::Array<int> boundary;
space.GetEssentialVDofs(attributes, boundary);
std::vector<int> hanging;
for (int local = 0; local < boundary.Size(); ++local) {
if (boundary[local] == 0) {
continue;
}
int contributors = 0;
for (int entry = prolongation->GetI()[local]; entry < prolongation->GetI()[local + 1]; ++entry) {
contributors += prolongation->GetData()[entry] != 0.0;
}
if (contributors > 1) {
hanging.push_back(local);
}
}
return hanging;
}
/** @brief Evaluate an MFEM field at native restriction representatives, without backend trace or reduction helpers. */
[[nodiscard]] Vector native_boundary_samples(const mfem::FiniteElementSpace& row_space,
const mfem::Array<int>& independent_rows, const mfem::GridFunction& field) {
const mfem::SparseMatrix* restriction = row_space.GetConformingRestriction();
REQUIRE(restriction != nullptr);
std::vector<int> selected(row_space.GetVSize(), -1);
for (int row = 0; row < independent_rows.Size(); ++row) {
const int independent = independent_rows[row];
const int entry = restriction->GetI()[independent];
REQUIRE(restriction->GetI()[independent + 1] == entry + 1);
REQUIRE(restriction->GetData()[entry] == 1.0);
selected[restriction->GetJ()[entry]] = row;
}
Vector samples(independent_rows.Size());
samples = std::numeric_limits<double>::quiet_NaN();
mfem::Array<int> dofs;
for (int element = 0; element < row_space.GetNE(); ++element) {
row_space.GetElementDofs(element, dofs);
const mfem::IntegrationRule& nodes = row_space.GetFE(element)->GetNodes();
for (int local = 0; local < dofs.Size(); ++local) {
const int row = selected[mfem::FiniteElementSpace::DecodeDof(dofs[local])];
if (row < 0 || std::isfinite(samples[row])) {
continue;
}
samples[row] = field.GetValue(element, nodes.IntPoint(local));
}
}
for (int row = 0; row < samples.Size(); ++row) {
REQUIRE(std::isfinite(samples[row]));
}
return samples;
}
} // namespace
TEST_CASE("Nonconforming boundary interpolation retains weighted traces across scalar orders", "[unit][equations][pressure_boundary][nonconforming]") {
namespace finite_elements = serif::discretization::backend::mfem;
namespace boundary = serif::tests::surface::boundary;
using StellarSurface = serif::discretization::domain::StellarSurfaceBoundary;
mfem::Mesh mesh = boundary::make_boundary_mesh();
mesh.EnsureNCMesh();
const mfem::Array<int> refinement{0};
mesh.GeneralRefinement(refinement);
mfem::H1_FECollection cubic_collection(3, 3), quadratic_collection(2, 3);
mfem::FiniteElementSpace row_space(&mesh, &cubic_collection), input_space(&mesh, &quadratic_collection);
finite_elements::TermBinding<Enthalpy, boundary::Schema> row_binding(row_space);
finite_elements::TermBinding<boundary::QuadraticComposition, boundary::Schema> input_binding(input_space);
REQUIRE(row_space.GetTrueVSize() < row_space.GetVSize());
REQUIRE(input_space.GetTrueVSize() < input_space.GetVSize());
// Every cell is material, so supported coordinates have the native true ordering.
REQUIRE(row_binding.size() == row_space.GetTrueVSize());
REQUIRE(input_binding.size() == input_space.GetTrueVSize());
const mfem::Array<int> attributes{1, 0};
REQUIRE_FALSE(hanging_boundary_dofs(row_space, attributes).empty());
const std::vector<int> input_hanging = hanging_boundary_dofs(input_space, attributes);
REQUIRE_FALSE(input_hanging.empty());
const auto selected = std::make_shared<const finite_elements::DofSelection>(
finite_elements::select_boundary_dofs<StellarSurface>(row_binding));
mfem::Array<int> native_selected;
row_space.GetEssentialTrueDofs(attributes, native_selected);
REQUIRE(selected->size() == native_selected.Size());
for (int row = 0; row < selected->size(); ++row) {
CHECK(selected->reduced_dofs()[row] == native_selected[row]);
}
const auto trace = finite_elements::make_boundary_trace(selected, input_binding);
const Vector values = boundary::sequence(input_binding.size(), 0.8, 0.37);
mfem::GridFunction field(&input_space);
field.SetFromTrueDofs(values);
const Vector expected = native_boundary_samples(row_space, native_selected, field);
Vector actual(selected->size());
trace.interpolation().Mult(values, actual);
CHECK(test::relative_error(actual, expected) < 2.0e-12);
// This deliberately broken local copy proves that weighted boundary inputs affect the tested rows.
mfem::GridFunction missing_hanging_values(&input_space);
missing_hanging_values = field;
for (const int local : input_hanging) {
missing_hanging_values[local] = 0.0;
}
Vector omitted = native_boundary_samples(row_space, native_selected, missing_hanging_values);
omitted -= expected;
REQUIRE(omitted.Norml2() > 1.0e-3);
const Vector dual = boundary::sequence(selected->size(), -0.2, 0.81);
Vector transpose(input_binding.size());
trace.interpolation().MultTranspose(dual, transpose);
CHECK(std::abs(values * transpose - actual * dual) < 2.0e-12 * std::max(1.0, std::abs(actual * dual)));
Vector zero_trace(row_binding.size());
zero_trace = 1.0;
for (const int row : selected->reduced_dofs()) {
zero_trace[row] = 0.0;
}
mfem::GridFunction constrained(&row_space);
constrained.SetFromTrueDofs(zero_trace);
double unselected_boundary_maximum = 0.0;
for (int face = 0; face < mesh.GetNBE(); ++face) {
mfem::ElementTransformation& transformation = *mesh.GetBdrElementTransformation(face);
const mfem::IntegrationRule& rule = mfem::IntRules.Get(transformation.GetGeometryType(), 8);
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
const double value = constrained.GetValue(transformation, rule.IntPoint(sample));
if (mesh.GetBdrAttribute(face) == 1) {
CHECK(std::abs(value) < 2.0e-13);
} else {
unselected_boundary_maximum = std::max(unselected_boundary_maximum, std::abs(value));
}
}
}
CHECK(unselected_boundary_maximum > 0.5);
}

View File

@@ -0,0 +1,316 @@
#include "serif/utils/error/errors.hpp"
#include <cmath>
#include <limits>
#include <memory>
#include <utility>
#include "serif/equations/barotropic_density.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/geometry/surface/evaluation.hpp"
#include "serif/tests/equations/volume_equation_fixture.hpp"
#include "serif/tests/equations/gravity_port.hpp"
namespace {
using namespace serif;
using namespace serif::tests::equations;
using namespace serif::tests::equations::gravity;
namespace physics = serif::geometry::surface;
namespace surface = physics::backend::mfem;
namespace dependencies = serif::utils::dependencies;
using Enthalpy = ::serif::tests::equations::Enthalpy;
using SurfaceState = dependencies::Bindings<dependencies::Binding<dimensions::MassDensity, Density>>;
using Formulation = physics::PressureSurfaceFormulation<eos::models::Polytrope, SurfaceState>;
using EnthalpyState = dependencies::Bindings<dependencies::Binding<dimensions::SpecificEnthalpy, Enthalpy>>;
using EnthalpyFormulation = physics::PressureSurfaceFormulation<eos::models::Polytrope, EnthalpyState>;
using Balance = serif::equations::BarotropicDensityBalance<eos::models::Polytrope>;
using Vector = ::mfem::Vector;
/** @brief Bind the carrier without introducing unrelated field requirements. */
auto enthalpy_state(GravityFixture& f, const Vector& h) {
return backend::make_term_coefficients(backend::bind(f.enthalpy_binding, h));
}
/** @brief Retain every density-balance column when testing strong boundary replacement. */
auto balance_state(GravityFixture& f, const Vector& rho, const Vector& phi, const Vector& C, const Vector& omega) {
return backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.potential_binding, phi),
backend::bind(f.constant_binding, C), backend::bind(f.angular_velocity_binding, omega));
}
/** @brief Track both polytropic parameters alongside the prescribed pressure and current coefficients. */
template <typename Boundary, typename Coefficients>
auto pressure_inputs(const std::shared_ptr<const Boundary>& boundary, const eos::models::Polytrope& model, dimensions::PressureValue pressure, const Coefficients& coefficients) {
const auto equation_of_state = dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const eos::models::Polytrope& eos) {
return std::pair{eos.polytropic_index(), eos.polytropic_constant()};
}));
return surface::make_pressure_boundary_inputs(boundary, equation_of_state, physics::Isobaric{pressure}, coefficients);
}
/** @brief Constrain the Cartesian fixture's stellar x=0 face, leaving its other faces free. */
void tag_cartesian_boundary(GravityFixture& f) {
for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) {
auto& transform = *f.mesh.GetBdrElementTransformation(boundary);
Vector center(3);
transform.Transform(::mfem::Geometries.GetCenter(transform.GetGeometryType()), center);
f.mesh.GetBdrElement(boundary)->SetAttribute(std::abs(center[0]) < 1.0e-14 ? 1 : 2);
}
f.mesh.SetAttributes();
}
/** @brief Select essential DOFs independently with MFEM, without the boundary trace compiler. */
Vector boundary_mask(GravityFixture& f) {
::mfem::Array<int> attributes(f.mesh.bdr_attributes.Max()), marker;
attributes = 0;
attributes[0] = 1;
f.density_space.GetEssentialVDofs(attributes, marker);
Vector full(marker.Size());
for (int dof = 0; dof < marker.Size(); ++dof) {
full[dof] = marker[dof] ? 1.0 : 0.0;
}
return f.density_binding.gather(full);
}
/** @brief Preserve MeanField prepared_stellar_equilibrium.cpp:810 profiles and surface-row tolerances. */
void check_rows(GravityFixture& f) {
CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering());
f.set_compactification();
const eos::models::Polytrope eos{3.0, 0.25};
const dimensions::PressureValue pressure{0.03125};
// For n=3 and K=1/4, rho(P) = (P/K)^(3/4), independently of the EOS implementation.
const double required_rho = std::pow(pressure.value() / 0.25, 0.75);
const auto rho = f.project(f.density_binding, [](const Vector& x) {
return 0.82 + 0.08 * std::sin(0.62 * x[0] + 0.53) + 0.045 * std::cos(0.57 * x[1] - 0.53) + 0.02 * x[2] * x[2];
});
const auto drho = f.project(f.density_binding, [](const Vector& x) {
return 0.21 * std::sin(0.74 * x[0] + 0.97) - 0.14 * std::cos(0.64 * x[1] - 0.97) + 0.075 * x[2];
});
const auto phi = f.project(f.potential_binding, [](const Vector& x) {
return 0.24 + 0.09 * std::sin(0.67 * x[0] + 0.47) - 0.06 * std::cos(0.53 * x[1] - 0.47) + 0.035 * x[2];
});
const auto dphi = f.project(f.potential_binding, [](const Vector& x) {
return 0.17 * std::sin(0.81 * x[0] + 0.89) + 0.11 * std::cos(0.69 * x[1] - 0.89) - 0.07 * x[2];
});
const auto C = constant(1, 1.07), dC = constant(1, -0.37);
const auto omega = constant(1, 0.82), domega = constant(1, 0.13);
// Volume displacement is already supported; surface-parameter extension is deliberately not used.
const auto u = f.displacement(0.08), du = f.displacement(-0.11);
const auto state = balance_state(f, rho, phi, C, omega);
const auto direction = balance_state(f, drho, dphi, dC, domega);
VolumeEquationFixture balance{Balance{eos, pressure}, f.density_binding, f.displacement_binding,
f.compactification, f.mapper, reference_policy()};
const auto boundary = surface::make_pressure_boundary<Formulation>(state);
const auto inputs = pressure_inputs(boundary, eos, pressure, state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(surface::make_pressure_boundary_graph<Formulation>(), inputs);
const auto mask = boundary_mask(f);
REQUIRE(boundary->selected_rows->size() > 0);
REQUIRE(boundary->selected_rows->size() < f.density_binding.size());
CHECK(boundary->selected_rows->size() == static_cast<int>(mask.Sum()));
int previous = -1;
for (int dof : boundary->selected_rows->reduced_dofs()) {
CHECK(dof > previous);
CHECK(mask[dof] == 1.0);
previous = dof;
}
balance.prepare(state, u, f.workspace);
REQUIRE(cache.try_prepare(inputs));
Vector residual, action;
balance.residual(f.workspace, residual);
balance.apply_jacobian(direction, du, f.workspace, action);
const auto interior_residual = residual, interior_action = action;
surface::apply_residual_rows(*boundary, cache.get<physics::keys::Linearization>(), residual);
surface::apply_jacobian_rows(*boundary, cache.get<physics::keys::Linearization>(), direction, action);
for (int row = 0; row < rho.Size(); ++row) {
CAPTURE(row);
if (mask[row]) {
CHECK(std::abs(residual[row] - (rho[row] - required_rho)) < 1.0e-13);
CHECK(std::abs(action[row] - drho[row]) < 1.0e-13);
} else {
CHECK(residual[row] == interior_residual[row]);
CHECK(action[row] == interior_action[row]);
}
}
const auto zero_rho = constant(rho.Size(), 0.0);
const auto other_columns = balance_state(f, zero_rho, dphi, dC, domega);
Vector other_action;
balance.apply_jacobian(other_columns, du, f.workspace, other_action);
const auto before_replacement = other_action;
surface::apply_jacobian_rows(*boundary, cache.get<physics::keys::Linearization>(), other_columns, other_action);
double removed_action = 0.0;
for (int row = 0; row < rho.Size(); ++row) {
if (mask[row]) {
CHECK(other_action[row] == 0.0);
removed_action += std::abs(before_replacement[row]);
} else {
CHECK(other_action[row] == before_replacement[row]);
}
}
CHECK(removed_action > 0.0);
// The constrained density-balance row retains the old complete-root difference step and threshold.
constexpr double step = 1.0e-5;
const auto shifted_residual = [&](double scale) {
auto trial_rho = rho, trial_phi = phi, trial_C = C, trial_omega = omega, trial_u = u;
trial_rho.Add(scale, drho); trial_phi.Add(scale, dphi); trial_C.Add(scale, dC);
trial_omega.Add(scale, domega); trial_u.Add(scale, du);
const auto trial = balance_state(f, trial_rho, trial_phi, trial_C, trial_omega);
balance.prepare(trial, trial_u, f.workspace);
REQUIRE(cache.try_prepare(pressure_inputs(boundary, eos, pressure, trial)));
Vector result;
balance.residual(f.workspace, result);
surface::apply_residual_rows(*boundary, cache.get<physics::keys::Linearization>(), result);
return result;
};
auto difference = shifted_residual(step);
difference -= shifted_residual(-step);
difference /= 2.0 * step;
auto error = action;
error -= difference;
CHECK(error.Norml2() / std::max({action.Norml2(), difference.Norml2(), 100.0 * std::numeric_limits<double>::epsilon()}) < 2.0e-6);
}
/** @brief A second EOS prevents assumptions of a polytrope or h(0)=0. */
struct OffsetEOS {
using Relations = eos::relations::RelationCatalog<eos::relations::SpecificEnthalpyFromPressure>;
double offset{0.3};
/** @brief Give zero pressure a nonzero enthalpy that changes with the EOS parameters. */
dimensions::SpecificEnthalpyValue evaluate(eos::relations::SpecificEnthalpyFromPressure, dimensions::PressureValue pressure) const {
return dimensions::SpecificEnthalpyValue{offset + 2.0 * pressure.value()};
}
};
}
TEST_CASE("Surface pressure resolves the prescribed quantity through the EOS", "[equations][pressure_surface][unit]") {
// MeanField constant_surface_compilation.cpp: pressure-only contract and exact polytropic residual/derivative.
const eos::models::Polytrope eos{3.0, 0.25};
const dimensions::PressureValue pressure{0.03125};
const physics::Isobaric condition{pressure};
CHECK(condition.target_pressure().value() == pressure.value());
physics::SurfacePoint<Formulation> point{};
point.fields.get<Density>() = eos::evaluate<dimensions::MassDensity>(eos, pressure).value();
const auto result = physics::evaluate_pressure_surface<Formulation>(eos, condition, point);
REQUIRE(result);
CHECK(result->residual == 0.0);
CHECK(result->field_derivatives.get<Density>() * -0.19 == -0.19);
CHECK(result->position_derivative == discretization::forms::VectorValue{});
for (double invalid : {-0.1, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS((physics::Isobaric{dimensions::PressureValue{invalid}}), std::invalid_argument);
}
OffsetEOS offset_eos;
using OffsetFormulation = physics::PressureSurfaceFormulation<OffsetEOS, EnthalpyState>;
physics::SurfacePoint<OffsetFormulation> offset_point{};
offset_point.fields.get<Enthalpy>() = 0.3;
const physics::Isobaric zero_pressure{dimensions::PressureValue{0.0}};
const auto original = physics::evaluate_pressure_surface<OffsetFormulation>(offset_eos, zero_pressure, offset_point);
REQUIRE(original);
CHECK(original->residual == 0.0);
offset_eos.offset = 1.2;
const auto updated = physics::evaluate_pressure_surface<OffsetFormulation>(offset_eos, zero_pressure, offset_point);
REQUIRE(updated);
CHECK(updated->residual == 0.3 - 1.2);
CHECK(original->residual == 0.0);
offset_eos.offset = std::numeric_limits<double>::infinity();
const auto rejected = physics::evaluate_pressure_surface<OffsetFormulation>(offset_eos, condition, offset_point);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().reason == physics::SurfaceFailureReason::non_finite_eos_response);
}
TEST_CASE("Pressure constraints replace density-balance boundary rows at the original accuracy", "[equations][pressure_surface][integration]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture f(geometry, ordering);
tag_cartesian_boundary(f);
check_rows(f);
}
}
auto fixture = gravity_port::stellar_fixture();
check_rows(*fixture);
}
TEST_CASE("Surface pressure preserves exact analytic closure density-balance and mass rows", "[equations][pressure_surface][integration]") {
// MeanField prepared_stellar_equilibrium.cpp:979. Retain all physical constants and numerical thresholds.
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
const eos::models::Polytrope eos{1.0, 0.25};
const dimensions::SpecificEnthalpyValue enthalpy{0.60};
const double density = eos::evaluate<dimensions::MassDensity>(eos, enthalpy).value();
const auto pressure = eos::evaluate<dimensions::Pressure>(eos, enthalpy);
double volume = 0.0;
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (!f.enthalpy_binding.supports(element)) {
continue;
}
auto& transform = *f.mesh.GetElementTransformation(element);
const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order);
for (int q = 0; q < rule.GetNPoints(); ++q) {
const auto& ip = rule.IntPoint(q);
transform.SetIntPoint(&ip);
volume += ip.weight * transform.Weight();
}
}
const double target_mass = density * volume;
const auto rho = constant(f.density_binding.size(), density), h = constant(f.enthalpy_binding.size(), enthalpy.value());
// A uniform state at the surface density has A(rho) = A(rho_surface), so the balance closes at Us = phi.
const auto phi = constant(f.potential_binding.size(), 0.20), C = constant(1, 0.20);
const auto omega = constant(1, 0.0), u = constant(f.displacement_binding.size(), 0.0);
const auto state = backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.enthalpy_binding, h),
backend::bind(f.potential_binding, phi), backend::bind(f.constant_binding, C),
backend::bind(f.angular_velocity_binding, omega));
VolumeEquationFixture closure{Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
VolumeEquationFixture balance{Balance{eos, pressure}, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
VolumeEquationFixture mass{serif::equations::MassNormalization{target_mass}, f.constant_binding,
f.displacement_binding, f.compactification, f.mapper, reference_policy()};
const auto boundary = surface::make_pressure_boundary<Formulation>(state);
const auto inputs = pressure_inputs(boundary, eos, pressure, state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(surface::make_pressure_boundary_graph<Formulation>(), inputs);
closure.prepare(state, u, f.workspace);
balance.prepare(state, u, f.workspace);
mass.prepare(state, u, f.workspace);
REQUIRE(cache.try_prepare(inputs));
Vector result;
closure.residual(f.workspace, result);
CHECK(result.Norml2() < 2.0e-12);
balance.residual(f.workspace, result);
surface::apply_residual_rows(*boundary, cache.get<physics::keys::Linearization>(), result);
CHECK(result.Norml2() < 2.0e-12);
mass.residual(f.workspace, result);
REQUIRE(result.Size() == 1);
CHECK(std::abs(result[0]) < 2.0e-11 * target_mass);
}
TEST_CASE("Boundary selection requires supported tagged nodal coefficients", "[equations][pressure_surface][unit]") {
GravityFixture f;
tag_cartesian_boundary(f);
const Vector h = constant(f.enthalpy_binding.size(), 0.7);
const auto state = enthalpy_state(f, h);
SECTION("Absent boundary") {
for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) {
f.mesh.GetBdrElement(boundary)->SetAttribute(2);
}
CHECK_THROWS_AS(surface::make_pressure_boundary<EnthalpyFormulation>(state), std::invalid_argument);
}
SECTION("Boundary outside the stellar support") {
for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) {
f.mesh.GetBdrElement(boundary)->SetAttribute(1);
}
CHECK_THROWS_AS(surface::make_pressure_boundary<EnthalpyFormulation>(state), std::invalid_argument);
}
SECTION("Positive H1 coefficients are not nodal values") {
::mfem::H1_FECollection positive{Enthalpy::FunctionalSpace::order, 3, ::mfem::BasisType::Positive};
::mfem::FiniteElementSpace space{&f.mesh, &positive};
discretization::backend::mfem::TermBinding<Enthalpy, Schema> binding{space};
const Vector values = constant(binding.size(), 0.7);
const auto positive_state = backend::make_term_coefficients(backend::bind(binding, values));
CHECK_THROWS_AS(surface::make_pressure_boundary<EnthalpyFormulation>(positive_state), std::invalid_argument);
}
SECTION("Indices must be sorted unique and in range") {
using discretization::backend::mfem::DofSelection;
for (const ::mfem::Array<int>& indices : {::mfem::Array<int>{-1}, ::mfem::Array<int>{0, 0},
::mfem::Array<int>{1, 0}, ::mfem::Array<int>{f.enthalpy_binding.size()}}) {
CHECK_THROWS_AS((DofSelection{f.enthalpy_binding, indices}), std::invalid_argument);
}
CHECK_THROWS_AS(f.enthalpy_binding.reduced_dof(-1), std::out_of_range);
CHECK_THROWS_AS(f.enthalpy_binding.reduced_dof(f.enthalpy_binding.full_size()), std::out_of_range);
}
}

View File

@@ -0,0 +1,92 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "serif/backend/mfem/discretization/basis_evaluation.hpp"
namespace
{
using namespace serif::discretization::backend::mfem;
/// Check every basis coefficient against MFEM, including bases that require dense fallback.
void check_native_basis(const ::mfem::FiniteElement& element, const ::mfem::IntegrationRule& rule, TensorBasisCache& cache)
{
const bool vector = element.GetRangeType() == ::mfem::FiniteElement::VECTOR;
const ReferenceOperation operation = vector ? ReferenceOperation::value : ReferenceOperation::value_and_gradient;
const int components = vector ? 3 : 4;
BasisEvaluation evaluation;
evaluation.sample(element, rule, operation, 1, cache, ContractionMode::automatic);
BasisWorkspace workspace;
::mfem::Vector coefficients(element.GetDof()), values, shape(element.GetDof());
::mfem::DenseMatrix derivatives(element.GetDof(), 3);
for (int dof = 0; dof < element.GetDof(); ++dof)
{
coefficients = 0.0;
coefficients[dof] = 1.0;
evaluation.evaluate(coefficients, values, workspace);
REQUIRE(std::all_of(values.GetData(), values.GetData() + values.Size(), [](const double value)
{
return std::isfinite(value);
}));
double error = 0.0;
for (int point = 0; point < rule.GetNPoints(); ++point)
{
if (vector)
{
element.CalcVShape(rule.IntPoint(point), derivatives);
}
else
{
element.CalcShape(rule.IntPoint(point), shape);
element.CalcDShape(rule.IntPoint(point), derivatives);
error = std::max(error, std::abs(values[components * point] - shape[dof]));
}
for (int axis = 0; axis < 3; ++axis)
{
const int sample = components * point + axis + (vector ? 0 : 1);
error = std::max(error, std::abs(values[sample] - derivatives(dof, axis)));
}
}
CHECK(error < 2.0e-12);
}
}
}
TEST_CASE("MeanField native scalar reference values and gradients include positive and closed bases", "[equations][tensor_basis][unit]")
{
for (const int order : {1, 3})
{
::mfem::H1_FECollection h1(order, 3);
::mfem::L2_FECollection l2(order - 1, 3);
TensorBasisCache cache;
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 1);
check_native_basis(*h1.FiniteElementForGeometry(::mfem::Geometry::CUBE), rule, cache);
check_native_basis(*l2.FiniteElementForGeometry(::mfem::Geometry::CUBE), rule, cache);
}
::mfem::H1_FECollection nodal(3, 3), positive(3, 3, ::mfem::BasisType::Positive);
::mfem::L2_FECollection open(3, 3), closed(3, 3, ::mfem::BasisType::GaussLobatto);
TensorBasisCache cache;
const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 5);
std::array<std::shared_ptr<const TensorBasis>, 4> plans;
const std::array<const ::mfem::FiniteElementCollection*, 4> collections{&nodal, &positive, &open, &closed};
for (std::size_t index = 0; index < collections.size(); ++index)
{
const ::mfem::FiniteElement& element = *collections[index]->FiniteElementForGeometry(::mfem::Geometry::CUBE);
check_native_basis(element, rule, cache);
plans[index] = cache.get(element, rule, ReferenceOperation::value_and_gradient, 1);
if (plans[index])
{
for (std::size_t previous = 0; previous < index; ++previous)
{
CHECK(plans[index] != plans[previous]);
}
}
}
}

View File

@@ -0,0 +1,198 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <memory>
#include <tuple>
#include <type_traits>
#include <variant>
#include "serif/backend/mfem/model/volume_displacement.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/tests/equations/fixture.hpp"
namespace
{
using namespace serif;
using namespace serif::tests::equations;
namespace equations_backend = serif::operators::backend::mfem;
namespace dependencies = utils::dependencies;
using Status = dependencies::PreparationStatus;
using Mass = serif::equations::MassNormalization;
struct FirstClosure
{
};
struct SecondClosure
{
};
struct MassRow
{
};
using First = equations_backend::volume_keys::Scoped<FirstClosure>;
using Second = equations_backend::volume_keys::Scoped<SecondClosure>;
using MassStages = equations_backend::volume_keys::Scoped<MassRow>;
/** @brief Reuse the ordinary fixture bindings without introducing an equation-specific owner. */
auto closure_discretization(const Fixture& fixture)
{
return equations_backend::VolumeDiscretization{
.residual_binding = fixture.density_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper};
}
/** @brief Compose declarations once and retain the ordinary dependency-cache failure type. */
template <typename Context, typename Nodes>
auto make_cache(const Context& context, Nodes nodes)
{
return std::apply([&](auto... node)
{
return dependencies::make_computation_cache<equations_backend::PreparationRejection>(context, std::move(node)...);
},
std::move(nodes));
}
} // namespace
TEST_CASE("Scoped instances of one volume equation share fields without sharing private stages", "[unit][equations][scoped_computations]")
{
Fixture fixture;
const auto discretization = closure_discretization(fixture);
auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
const auto displacement = fixture.displacement(0.03);
const eos::models::Polytrope first_eos{1.5, 0.25};
const eos::models::Polytrope second_eos{3.0, 1.5};
const auto inputs = [&]()
{
const auto coefficients = fixture.state(density, enthalpy);
const auto first = equations_backend::make_volume_inputs<Closure, FirstClosure>(discretization, coefficients, displacement);
const auto second = equations_backend::make_volume_inputs<Closure, SecondClosure>(discretization, coefficients, displacement);
return dependencies::make_dependency_context(
first.resource<First::Bindings>(), second.resource<Second::Bindings>(),
first.resource<equations_backend::DisplacementCoefficients>(), first.resource<equations_backend::CompactificationCoefficients>(),
first.resource<blocks::density::mass>(), first.resource<::serif::tests::equations::Enthalpy>());
};
auto cache = make_cache(inputs(), std::tuple_cat(
equations_backend::make_volume_equation_computations<FirstClosure>(Closure{first_eos}, discretization),
equations_backend::make_volume_equation_computations<SecondClosure>(Closure{second_eos}, discretization)));
STATIC_CHECK_FALSE(std::same_as<First::Linearization, Second::Linearization>);
REQUIRE(cache.try_prepare(inputs()));
CHECK(relative_error(cache.get<First::Linearization>().residual, manual_closure(fixture, first_eos, density, enthalpy, displacement)) < 2.0e-12);
CHECK(relative_error(cache.get<Second::Linearization>().residual, manual_closure(fixture, second_eos, density, enthalpy, displacement)) < 2.0e-12);
const auto density_direction = deterministic(density.Size(), 0.3);
const auto enthalpy_direction = deterministic(enthalpy.Size(), 0.7);
const auto displacement_direction = fixture.displacement(0.1);
const auto direction = packed(density_direction, enthalpy_direction, displacement_direction);
const auto dual = deterministic(density.Size(), 0.9);
mfem::Vector action, transpose;
equations_backend::Workspace workspace;
equations_backend::apply_jacobian(cache.get<Second::Linearization>(), direction, workspace, action);
CHECK(relative_error(action, manual_closure(fixture, second_eos, density, enthalpy, displacement, &density_direction, &enthalpy_direction, &displacement_direction)) < 2.0e-12);
equations_backend::apply_jacobian_transpose(cache.get<Second::Linearization>(), dual, workspace, transpose);
CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(2.0e-12));
const auto frozen = cache.share<Second::Linearization>();
REQUIRE(cache.try_prepare(inputs()));
CHECK(cache.report().computed_count() == 0);
CHECK(cache.share<Second::Linearization>() == frozen);
density += 0.04;
REQUIRE(cache.try_prepare(inputs(), dependencies::RequestedResults<First::Linearization>{}));
CHECK(cache.report().status<First::Basis<0>>() == Status::reused);
CHECK(cache.report().status<First::QuadratureLinearization<0>>() == Status::computed);
CHECK_FALSE(cache.contains<Second::Linearization>());
CHECK(equations_backend::volume_work_report<FirstClosure>(cache).physics_points > 0);
REQUIRE(cache.try_prepare(inputs()));
CHECK(cache.report().status<First::Linearization>() == Status::reused);
CHECK(cache.report().status<Second::QuadratureLinearization<0>>() == Status::computed);
CHECK(cache.share<Second::Linearization>() != frozen);
CHECK(relative_error(cache.get<Second::Linearization>().residual, manual_closure(fixture, second_eos, density, enthalpy, displacement)) < 2.0e-12);
}
TEST_CASE("Distinct volume equations prepare in one graph from common coefficient sources", "[unit][equations][scoped_computations]")
{
Fixture fixture;
const eos::models::Polytrope eos{1.5, 0.25};
auto density = fixture.density();
auto enthalpy = fixture.enthalpy();
auto displacement = fixture.displacement(0.03);
const auto closure = closure_discretization(fixture);
const equations_backend::VolumeDiscretization mass{
.residual_binding = fixture.constant_binding,
.displacement_binding = fixture.displacement_binding,
.compactification = fixture.compactification,
.mapper = fixture.mapper};
const auto coefficients = [&]()
{
return equations_backend::make_term_coefficients(equations_backend::bind(fixture.density_binding, density), equations_backend::bind(fixture.enthalpy_binding, enthalpy));
};
const auto inputs = [&]()
{
const auto state = coefficients();
const auto volume = equations_backend::make_volume_inputs<Closure, FirstClosure>(closure, state, displacement);
const auto integral = equations_backend::make_volume_inputs<Mass, MassRow>(mass, state, displacement);
return dependencies::make_dependency_context(
volume.resource<First::Bindings>(), integral.resource<MassStages::Bindings>(),
volume.resource<equations_backend::DisplacementCoefficients>(), volume.resource<equations_backend::CompactificationCoefficients>(),
volume.resource<blocks::density::mass>(), volume.resource<::serif::tests::equations::Enthalpy>());
};
auto cache = make_cache(inputs(), std::tuple_cat(
equations_backend::make_volume_equation_computations<FirstClosure>(Closure{eos}, closure),
equations_backend::make_volume_equation_computations<MassRow>(Mass{0.8}, mass)));
REQUIRE(cache.try_prepare(inputs()));
const auto frozen_volume = cache.share<First::Linearization>();
REQUIRE(cache.try_prepare(inputs()));
CHECK(cache.report().computed_count() == 0);
enthalpy += 0.03;
REQUIRE(cache.try_prepare(inputs()));
CHECK(cache.report().status<First::Linearization>() == Status::computed);
CHECK(cache.report().status<MassStages::Linearization>() == Status::reused);
density += 0.02;
REQUIRE(cache.try_prepare(inputs()));
CHECK(cache.report().status<MassStages::Linearization>() == Status::computed);
enthalpy = -1.0;
const auto rejected = cache.try_prepare(inputs());
REQUIRE_FALSE(rejected);
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<MassStages::Linearization>());
CHECK(frozen_volume->residual.Size() == fixture.density_binding.size());
}
TEST_CASE("Scoped equation metadata connects the shared deformation displacement source", "[unit][equations][scoped_computations]")
{
namespace deformation = serif::geometry::deformation;
namespace deformation_backend = deformation::backend::mfem;
using Failure = std::variant<equations_backend::PreparationRejection, deformation::DeformationFailure>;
Fixture fixture;
const auto discretization = closure_discretization(fixture);
const auto density = fixture.density();
const auto enthalpy = fixture.enthalpy();
const auto displacement = fixture.displacement(0.03);
std::vector<double> generated(displacement.GetData(), displacement.GetData() + displacement.Size());
const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh);
const auto volume = equations_backend::make_volume_inputs<Closure, FirstClosure>(discretization, fixture.state(density, enthalpy));
const auto context = dependencies::make_dependency_context(
volume.resource<First::Bindings>(), volume.resource<equations_backend::CompactificationCoefficients>(),
volume.resource<blocks::density::mass>(), volume.resource<::serif::tests::equations::Enthalpy>(),
dependencies::own<deformation::keys::ReferenceDiscretization>(reference, deformation_backend::ReferenceDiscretizationTracking{}),
dependencies::borrow<deformation::keys::VolumeDisplacement>(generated));
const eos::models::Polytrope eos{1.5, 0.25};
auto cache = std::apply([&](auto... node)
{
return dependencies::make_computation_cache<Failure>(context, std::move(node)...);
}, std::tuple_cat(
deformation_backend::make_volume_displacement_computations<First::Bindings>(fixture.displacement_binding),
equations_backend::make_volume_equation_computations<FirstClosure>(Closure{eos}, discretization)));
REQUIRE(cache.try_prepare(context));
CHECK(relative_error(cache.get<equations_backend::DisplacementCoefficients>().values, displacement) == 0.0);
CHECK(relative_error(cache.get<First::Linearization>().residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12);
REQUIRE(cache.try_prepare(context));
CHECK(cache.report().computed_count() == 0);
generated[0] += 0.001;
REQUIRE(cache.try_prepare(context));
CHECK(cache.report().status<deformation_backend::volume_displacement_keys::CheckedBinding>() == Status::reused);
CHECK(cache.report().status<First::Geometry<0>>() == Status::computed);
}

View File

@@ -0,0 +1,440 @@
#include <cmath>
#include <limits>
#include <memory>
#include "serif/tests/equations/gravity_port.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/equations/angular_momentum.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/equations/barotropic_density.hpp"
#include "serif/backend/mfem/operators/volume_field_block.hpp"
#include "serif/tests/nonconforming.hpp"
namespace {
using namespace serif;
using namespace serif::tests::equations;
using namespace serif::tests::equations::gravity;
using Vector = ::mfem::Vector;
using Mode = backend::PreparationMode;
struct Values {
Vector rho, h, phi, c, omega, u;
/// Smooth values covering every migrated equation's unknowns.
explicit Values(GravityFixture& f) : rho(f.density()), h(f.enthalpy()), phi(f.potential()),
c(constant(1, 0.7)), omega(constant(1, 0.4)), u(f.displacement(0.04)) {}
/// Bind each physical term once, independently of which equation consumes it.
auto state(GravityFixture& f) const {
return backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.enthalpy_binding, h),
backend::bind(f.potential_binding, phi),
backend::bind(f.constant_binding, c), backend::bind(f.angular_velocity_binding, omega));
}
};
/// Use the prepared plan's public column order to construct an independent packed direction.
template <typename OperatorT, typename StateT>
Vector pack(const OperatorT& op, const StateT& state, const Vector& displacement) {
Vector result(op.width());
int offset = 0;
OperatorT::Plan::for_each_column([&]<typename OperandT>(OperandT, std::size_t) {
const auto& values = state.template get<typename OperandT::term_type>().values;
std::copy_n(values.GetData(), values.Size(), result.GetData() + offset);
offset += values.Size();
});
std::copy_n(displacement.GetData(), displacement.Size(), result.GetData() + offset);
return result;
}
/// Check full field/geometry transposes, dense/tensor agreement, freezing and the MFEM adapter.
template <typename EquationT, typename BindingT>
void check_operator(GravityFixture& f, EquationT equation, const BindingT& row) {
CAPTURE(EquationT::name, f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering());
Values values(f), direction(f);
direction.rho = deterministic(values.rho.Size(), 0.3);
direction.h = deterministic(values.h.Size(), 0.5);
direction.phi = deterministic(values.phi.Size(), 0.9);
direction.c[0] = -0.23;
direction.omega[0] = 0.17;
direction.u = f.displacement(-0.07);
VolumeEquationFixture automatic{equation, row, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
VolumeEquationFixture dense{equation, row, f.displacement_binding, f.compactification, f.mapper, reference_policy(), backend::ContractionMode::dense};
automatic.prepare(values.state(f), values.u, f.workspace);
dense.prepare(values.state(f), values.u, f.workspace);
CHECK(dense.tensor_element_count() == 0);
CHECK((automatic.tensor_element_count() > 0) == (f.mesh.GetElementGeometry(0) == ::mfem::Geometry::CUBE));
Vector residual, expected_residual, action, expected_action, transpose, expected_transpose;
automatic.residual(f.workspace, residual);
dense.residual(f.workspace, expected_residual);
CHECK(relative_error(residual, expected_residual) < 2.0e-12);
const auto input = pack(automatic, direction.state(f), direction.u);
automatic.apply_jacobian(input, f.workspace, action);
dense.apply_jacobian(input, f.workspace, expected_action);
CHECK(relative_error(action, expected_action) < 2.0e-12);
auto dual = deterministic(automatic.height(), -0.8);
automatic.apply_jacobian_transpose(dual, f.workspace, transpose);
dense.apply_jacobian_transpose(dual, f.workspace, expected_transpose);
CHECK(relative_error(transpose, expected_transpose) < 2.0e-12);
// The old mass-adapter transpose pairing threshold is retained for every equation.
CHECK(gravity_port::pairing_relative(input * transpose, action * dual) < 2.0e-12);
backend::JacobianOperator adapter{automatic.cache()};
Vector adapted;
adapter.MultTranspose(dual, adapted);
CHECK(relative_error(adapted, transpose) < 2.0e-15);
Vector zero = constant(dual.Size(), 0.0);
automatic.apply_jacobian_transpose(zero, f.workspace, adapted);
CHECK(adapted.Norml2() == 0.0);
// Probe both ends of each coefficient block and the geometry block independently.
int offset = 0;
const auto check_column = [&](int index) {
Vector basis = constant(automatic.width(), 0.0), column;
basis[index] = 1.0;
automatic.apply_jacobian(basis, f.workspace, column);
CHECK(std::abs(column * dual - transpose[index]) < 2.0e-12 * std::max(1.0, std::abs(transpose[index])));
};
decltype(automatic)::Plan::for_each_column([&]<typename OperandT>(OperandT, std::size_t) {
const auto& input_values = values.state(f).template get<typename OperandT::term_type>().values;
check_column(offset);
check_column(offset + input_values.Size() - 1);
offset += input_values.Size();
});
check_column(offset);
check_column(automatic.width() - 1);
values.rho = values.h = values.phi = 0.0;
values.c = values.omega = values.u = 0.0;
automatic.apply_jacobian_transpose(dual, f.workspace, adapted);
CHECK(relative_error(adapted, transpose) < 2.0e-15);
Vector wrong_size(automatic.height() + 1);
CHECK_THROWS_AS(automatic.apply_jacobian_transpose(wrong_size, f.workspace, adapted), std::invalid_argument);
dual[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(automatic.apply_jacobian_transpose(dual, f.workspace, adapted), std::domain_error);
}
/** @brief Ensure a material-only equation actually encounters weighted input or test coefficients. */
void require_material_hanging_coefficient(const discretization::backend::mfem::DofMap& binding,
const discretization::backend::mfem::DofMap& material) {
const ::mfem::SparseMatrix* prolongation = binding.space().GetConformingProlongation();
REQUIRE(prolongation != nullptr);
bool found = false;
::mfem::Array<int> dofs;
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (!material.supports(element)) {
continue;
}
binding.space().GetElementVDofs(element, dofs);
for (const int encoded : dofs) {
const int local = ::mfem::FiniteElementSpace::DecodeDof(encoded);
int contributions = 0;
for (int entry = prolongation->GetI()[local]; entry < prolongation->GetI()[local + 1]; ++entry) {
if (prolongation->GetData()[entry] != 0.0) {
++contributions;
}
}
found = found || contributions > 1;
}
}
REQUIRE(found);
}
/** @brief Isolate the production stellar source without assembling vacuum diffusion. */
struct StellarDensitySource : serif::equations::PrimalPoissonBalance::DensitySource {
static constexpr std::string_view name = "stellar density source";
/** @brief Preserve the primal Poisson source coefficient and sign. */
explicit StellarDensitySource(const double coefficient) : DensitySource(coefficient) {}
};
struct CountingClosure {
using Test = Closure::Test;
using Operands = Closure::Operands;
static constexpr std::string_view name = "counting closure";
Closure equation{eos::models::Polytrope{1.5, 0.25}};
std::shared_ptr<int> calls = std::make_shared<int>(0);
/// Count local evaluations without changing the physical residual or derivative.
auto evaluate(const serif::equations::EquationPoint<Operands>& point) const { ++*calls; return equation.evaluate(point); }
/// Use the real closure's quadrature contract.
template <typename OperandT> int additional_order(OperandT operand, int order) const { return equation.additional_order(operand, order); }
};
}
TEST_CASE("Every migrated volume equation has a complete serial Jacobian transpose", "[equations][serial_backend][integration]") {
for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture f(geometry, ordering);
f.set_compactification();
const eos::models::Polytrope eos{1.5, 0.25};
check_operator(f, Closure{eos}, f.density_binding);
check_operator(f, PotentialRow{four_pi_G}, f.potential_binding);
check_operator(f, serif::equations::BarotropicDensityBalance{eos}, f.density_binding);
check_operator(f, serif::equations::MassNormalization{0.8}, f.constant_binding);
check_operator(f, serif::equations::AngularMomentumConstraint{0.3}, f.angular_velocity_binding);
}
}
}
TEST_CASE("Curved compactified geometry preserves transpose pairings", "[equations][serial_backend][integration]") {
auto fixture = gravity_port::stellar_fixture();
auto& f = *fixture;
check_operator(f, PotentialRow{four_pi_G}, f.potential_binding);
const eos::models::Polytrope eos{1.5, 0.25};
check_operator(f, serif::equations::BarotropicDensityBalance{eos}, f.density_binding);
}
TEST_CASE("Selective preparation refreshes only changed operand samples and geometry", "[equations][serial_backend][unit]") {
GravityFixture f;
f.set_compactification();
CountingClosure equation;
VolumeEquationFixture op{equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
Values values(f);
op.prepare(values.state(f), values.u, f.workspace);
const auto initial = op.preparation_report();
REQUIRE(initial.physics_points > 0);
CHECK(initial.physics_points == static_cast<std::size_t>(*equation.calls));
const auto initial_calls = *equation.calls;
Vector before, after;
op.residual(f.workspace, before);
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK_FALSE(op.preparation_report().did_work());
CHECK(op.preparation_report().reused_elements > 0);
CHECK(*equation.calls == initial_calls);
op.residual(f.workspace, after);
CHECK(relative_error(after, before) == 0.0);
values.rho += 0.03;
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == 0);
CHECK(op.preparation_report().sampled_operands * 2 == initial.sampled_operands);
CHECK(op.preparation_report().physics_points == initial.physics_points);
CHECK(*equation.calls == 2 * initial_calls);
op.residual(f.workspace, after);
op.prepare(values.state(f), values.u, f.workspace);
op.residual(f.workspace, before);
CHECK(relative_error(after, before) < 2.0e-15);
values.u *= 1.01;
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == initial.geometry_points);
CHECK(op.preparation_report().sampled_operands == 0);
f.mesh.NodesUpdated();
CHECK_THROWS_AS(op.residual(f.workspace, after), std::logic_error);
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == initial.geometry_points);
CHECK(op.preparation_report().sampled_operands == initial.sampled_operands);
values.h = -1.0;
CHECK_FALSE(op.try_prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged).has_value());
CHECK_FALSE(op.is_prepared());
CHECK_THROWS_AS(op.residual(f.workspace, after), std::logic_error);
values.h = f.enthalpy();
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == initial.geometry_points);
CHECK(op.preparation_report().sampled_operands == initial.sampled_operands);
op.invalidate();
CHECK_FALSE(op.preparation_report().did_work());
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().did_work());
}
TEST_CASE("Selective preparation follows contribution dependencies and copied coefficient values", "[equations][serial_backend][unit]") {
GravityFixture f;
Values values(f);
// Only the stellar density source reads density; the potential flux on every domain is reused.
VolumeEquationFixture op{serif::equations::PrimalPoissonBalance{four_pi_G}, f.potential_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
op.prepare(values.state(f), values.u, f.workspace);
const auto initial = op.preparation_report();
auto copied = values;
op.prepare(copied.state(f), copied.u, f.workspace, Mode::reuse_unchanged);
CHECK_FALSE(op.preparation_report().did_work());
const auto elements = op.preparation_report().reused_elements;
copied.rho *= 1.1;
op.prepare(copied.state(f), copied.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == 0);
CHECK(op.preparation_report().reused_elements > 0);
CHECK(op.preparation_report().reused_elements < elements);
CHECK(op.preparation_report().sampled_operands > 0);
CHECK(op.preparation_report().sampled_operands < initial.sampled_operands);
CHECK(op.preparation_report().physics_points > 0);
CHECK(op.preparation_report().physics_points < initial.physics_points);
Vector incremental, refreshed;
op.residual(f.workspace, incremental);
op.prepare(copied.state(f), copied.u, f.workspace);
op.residual(f.workspace, refreshed);
CHECK(relative_error(incremental, refreshed) < 2.0e-15);
}
TEST_CASE("Selective preparation refreshes compactification without resampling unchanged operands", "[equations][serial_backend][unit]") {
GravityFixture f;
f.set_compactification();
Values values(f);
VolumeEquationFixture op{PotentialRow{four_pi_G}, f.potential_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
op.prepare(values.state(f), values.u, f.workspace);
const auto initial = op.preparation_report();
Vector frozen, unchanged, incremental, refreshed, action, transpose;
op.residual(f.workspace, frozen);
f.compactification *= 0.97;
op.residual(f.workspace, unchanged);
CHECK(relative_error(unchanged, frozen) == 0.0);
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == initial.geometry_points);
CHECK(op.preparation_report().sampled_operands == 0);
op.residual(f.workspace, incremental);
CHECK(relative_error(incremental, frozen) > 1.0e-8);
const auto direction = pack(op, values.state(f), values.u);
const auto dual = deterministic(op.height(), 0.31);
op.apply_jacobian(direction, f.workspace, action);
op.apply_jacobian_transpose(dual, f.workspace, transpose);
op.prepare(values.state(f), values.u, f.workspace);
op.residual(f.workspace, refreshed);
CHECK(relative_error(incremental, refreshed) < 2.0e-12);
op.apply_jacobian(direction, f.workspace, refreshed);
CHECK(relative_error(action, refreshed) < 2.0e-12);
op.apply_jacobian_transpose(dual, f.workspace, refreshed);
CHECK(relative_error(transpose, refreshed) < 2.0e-12);
}
TEST_CASE("Dependency preparation detects coefficient binding replacement and ignores unused state roles", "[equations][serial_backend][unit]") {
GravityFixture f;
Values values(f);
CountingClosure equation;
VolumeEquationFixture op{equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()};
op.prepare(values.state(f), values.u, f.workspace);
const auto initial = op.preparation_report();
const auto calls = *equation.calls;
values.omega[0] *= 1.2; // Rotation is present in the state but is not an input to closure.
op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged);
CHECK_FALSE(op.preparation_report().did_work());
CHECK(*equation.calls == calls);
discretization::backend::mfem::TermBinding<Density, Schema> replacement{f.density_space};
const auto rebound = backend::make_term_coefficients(backend::bind(replacement, values.rho), backend::bind(f.enthalpy_binding, values.h));
op.prepare(rebound, values.u, f.workspace, Mode::reuse_unchanged);
CHECK(op.preparation_report().geometry_points == initial.geometry_points);
CHECK(op.preparation_report().sampled_operands == initial.sampled_operands);
CHECK(*equation.calls == 2 * calls);
Vector incremental, rebuilt;
op.residual(f.workspace, incremental);
op.prepare(rebound, values.u, f.workspace);
op.residual(f.workspace, rebuilt);
CHECK(relative_error(incremental, rebuilt) < 2.0e-15);
}
TEST_CASE("STROID hanging nodes preserve complete volume actions and sparse field blocks", "[integration][serial_backend][nonconforming]") {
const auto generated = serif::tests::nonconforming::mesh(1, 2);
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
GravityFixture fixture(::mfem::Mesh(*generated.mesh), ordering);
::mfem::GridFunctionCoefficient coordinate(generated.exterior_coordinate->values.get());
fixture.compactification.ProjectCoefficient(coordinate);
REQUIRE(fixture.potential_space.GetTrueVSize() < fixture.potential_space.GetVSize());
check_operator(fixture, PotentialRow{four_pi_G}, fixture.potential_binding);
Values values(fixture);
VolumeEquationFixture equation{PotentialRow{four_pi_G}, fixture.potential_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, reference_policy()};
equation.prepare(values.state(fixture), values.u, fixture.workspace);
const auto matrix = backend::assemble_volume_field<Potential>(equation.data());
const auto diagonal = backend::volume_field_diagonal<Potential>(equation.data());
const Vector input = deterministic(fixture.potential_binding.size(), 0.7);
Vector actual, expected(matrix->Height()), native_diagonal;
auto direction = gravity_port::zeros(fixture);
direction.potential = input;
equation.apply_jacobian(fixture.state(direction.potential, direction.density), direction.displacement, fixture.workspace, actual);
matrix->Mult(input, expected);
CHECK(relative_error(actual, expected) < 3.0e-12);
matrix->GetDiag(native_diagonal);
for (int index = 0; index < native_diagonal.Size(); ++index) {
CHECK(diagonal[index] == native_diagonal[index]);
}
}
}
TEST_CASE("STROID material hanging nodes preserve nonlinear density-balance and density-source actions", "[integration][serial_backend][nonconforming]") {
const auto generated = serif::tests::nonconforming::mesh(0, 3);
GravityFixture fixture(::mfem::Mesh(*generated.mesh), ::mfem::Ordering::byVDIM);
::mfem::GridFunctionCoefficient coordinate(generated.exterior_coordinate->values.get());
fixture.compactification.ProjectCoefficient(coordinate);
require_material_hanging_coefficient(fixture.density_binding, fixture.density_binding);
require_material_hanging_coefficient(fixture.displacement_binding, fixture.density_binding);
const serif::equations::BarotropicDensityBalance density_balance{eos::models::Polytrope{1.5, 0.25}};
check_operator(fixture, density_balance, fixture.density_binding);
check_operator(fixture, StellarDensitySource{four_pi_G}, fixture.potential_binding);
// Positive interior density keeps this nonlinear tangent away from the vacuum singularity.
Values values(fixture), direction(fixture);
direction.rho = deterministic(values.rho.Size(), 0.3);
direction.phi = 0.0;
direction.c = 0.0;
direction.omega = 0.0;
direction.u = fixture.displacement(0.001);
VolumeEquationFixture density_equation{density_balance, fixture.density_binding, fixture.displacement_binding,
fixture.compactification, fixture.mapper, reference_policy()};
density_equation.prepare(values.state(fixture), values.u, fixture.workspace);
const auto density_matrix = backend::assemble_volume_field<blocks::density::mass>(density_equation.data());
const Vector zero_displacement = constant(values.u.Size(), 0.0);
Vector actual, expected(density_matrix->Height());
density_equation.apply_jacobian(direction.state(fixture), zero_displacement, fixture.workspace, actual);
density_matrix->Mult(direction.rho, expected);
CHECK(relative_error(actual, expected) < 3.0e-12);
const Vector density_dual = deterministic(density_matrix->Height(), -0.8);
Vector complete_transpose, sparse_transpose(density_matrix->Width());
density_equation.apply_jacobian_transpose(density_dual, fixture.workspace, complete_transpose);
density_matrix->MultTranspose(density_dual, sparse_transpose);
const Vector field_transpose(complete_transpose.GetData(), direction.rho.Size());
CHECK(relative_error(sparse_transpose, field_transpose) < 3.0e-12);
// The nonlinear residual independently checks the constrained field and geometry tangent together.
density_equation.apply_jacobian(direction.state(fixture), direction.u, fixture.workspace, actual);
constexpr double step = 1.0e-6;
Values plus(values), minus(values);
plus.rho.Add(step, direction.rho);
minus.rho.Add(-step, direction.rho);
plus.u.Add(step, direction.u);
minus.u.Add(-step, direction.u);
Vector positive, negative;
density_equation.prepare(plus.state(fixture), plus.u, fixture.workspace);
density_equation.residual(fixture.workspace, positive);
density_equation.prepare(minus.state(fixture), minus.u, fixture.workspace);
density_equation.residual(fixture.workspace, negative);
positive -= negative;
positive /= 2.0 * step;
CHECK(relative_error(actual, positive) < 4.0e-8);
VolumeEquationFixture source_equation{StellarDensitySource{four_pi_G}, fixture.potential_binding,
fixture.displacement_binding, fixture.compactification, fixture.mapper, reference_policy()};
source_equation.prepare(values.state(fixture), values.u, fixture.workspace);
const auto source_matrix = backend::assemble_volume_field<blocks::density::mass>(source_equation.data());
ReferenceGeometry geometry{fixture, fixture.mapper, values.u};
ReferenceVolume volume{geometry};
::mfem::MixedBilinearForm native_source(&fixture.density_space, &fixture.potential_space);
auto* integrator = new ::mfem::MixedScalarMassIntegrator(volume);
const auto& rule = ::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), reference_order);
integrator->SetIntRule(&rule);
::mfem::Array<int> stellar{1, 1, 0};
native_source.AddDomainIntegrator(integrator, stellar);
native_source.Assemble();
native_source.Finalize();
// Native local assembly and native P transpose retain all hanging-node cross terms.
Vector native_full(fixture.potential_binding.full_size()), native_true(fixture.potential_binding.true_size());
native_source.Mult(fixture.density_binding.scatter(values.rho), native_full);
native_full *= four_pi_G;
fixture.potential_space.GetConformingProlongation()->MultTranspose(native_full, native_true);
const Vector native_residual = fixture.potential_binding.gather_true(native_true);
source_equation.residual(fixture.workspace, actual);
CHECK(relative_error(actual, native_residual) < 3.0e-12);
expected.SetSize(source_matrix->Height());
source_matrix->Mult(values.rho, expected);
CHECK(relative_error(expected, native_residual) < 3.0e-12);
const Vector source_dual = deterministic(source_matrix->Height(), 0.7);
native_full.SetSize(fixture.density_binding.full_size());
native_true.SetSize(fixture.density_binding.true_size());
native_source.MultTranspose(fixture.potential_binding.scatter(source_dual), native_full);
native_full *= four_pi_G;
fixture.density_space.GetConformingProlongation()->MultTranspose(native_full, native_true);
const Vector native_transpose = fixture.density_binding.gather_true(native_true);
sparse_transpose.SetSize(source_matrix->Width());
source_matrix->MultTranspose(source_dual, sparse_transpose);
CHECK(relative_error(sparse_transpose, native_transpose) < 3.0e-12);
}

View File

@@ -0,0 +1,252 @@
#include <algorithm>
#include <limits>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/backend/mfem/discretization/element_coordinates.hpp"
#include "serif/tests/test_tags.hpp"
namespace serif::tests::equations {
TEST_CASE("Scalar Operand Evaluation Rejects Incompatible MFEM Elements", tags::unit & tags::equations) {
Fixture f;
::mfem::ND_FECollection collection(1, 3);
::mfem::FiniteElementSpace space(&f.mesh, &collection);
const auto& element = *space.GetFE(0);
const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 2);
backend::BasisEvaluation basis;
backend::TensorBasisCache cache;
backend::BasisWorkspace workspace;
auto& transformation = *f.mesh.GetElementTransformation(0);
using Operand = discretization::forms::Operand<blocks::density::mass>;
// Direct sampling must enforce the scalar representation even without a TermBinding.
CHECK_THROWS_AS(
backend::OperandEvaluator<Operand>::sample_basis(element, rule, basis, cache, backend::ContractionMode::automatic, transformation, workspace),
std::invalid_argument
);
}
TEST_CASE("Equation State Guards Remain Active For Invalid Sizes And Nonfinite Values", tags::unit & tags::equations) {
Fixture f;
auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
::mfem::Vector result;
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
CHECK_THROWS_AS(prepared.width(), std::logic_error);
const auto wrong = constant(rho.Size() + 1, 0.0);
CHECK_THROWS_AS(prepared.prepare(f.state(wrong, h), u, f.workspace), std::invalid_argument);
CHECK_FALSE(prepared.is_prepared());
rho[0] = std::numeric_limits<double>::quiet_NaN();
const auto invalid = prepared.try_prepare(f.state(rho, h), u, f.workspace);
REQUIRE_FALSE(invalid.has_value());
CHECK(invalid.error().reason == backend::PreparationRejectionReason::NON_FINITE);
rho = f.density();
prepared.prepare(f.state(rho, h), u, f.workspace);
CHECK_THROWS_AS(prepared.apply_jacobian(f.state(wrong, h), u, f.workspace, result), std::invalid_argument);
CHECK_THROWS_AS(prepared.apply_jacobian(wrong, f.workspace, result), std::invalid_argument);
h[0] = std::numeric_limits<double>::infinity();
CHECK_THROWS_AS(prepared.apply_jacobian(f.state(rho, h), u, f.workspace, result), std::domain_error);
}
TEST_CASE("Equation Bindings Reject Wrong Spaces And Different Meshes", tags::unit & tags::equations) {
Fixture f, other;
::mfem::H1_FECollection wrong_collection(2, 3);
::mfem::FiniteElementSpace wrong_order(&f.mesh, &wrong_collection);
CHECK_THROWS_AS(
(discretization::backend::mfem::TermBinding<::serif::tests::equations::Enthalpy, Schema>{wrong_order}), std::invalid_argument
);
CHECK_THROWS_AS(
(discretization::backend::mfem::TermBinding<blocks::density::mass, Schema>{wrong_order}), std::invalid_argument
);
const auto rho = f.density(), h = other.enthalpy(), u = f.displacement(0.0);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
const auto wrong_state =
backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(other.enthalpy_binding, h));
CHECK_THROWS_AS(prepared.prepare(wrong_state, u, f.workspace), std::invalid_argument);
const auto local_h = f.enthalpy();
prepared.prepare(f.state(rho, local_h), u, f.workspace);
::mfem::Vector result;
CHECK_THROWS_AS(prepared.apply_jacobian(wrong_state, u, f.workspace, result), std::invalid_argument);
}
TEST_CASE("Prepared Equations Reject Changed Discretization And Domain Support", tags::unit & tags::equations) {
Fixture f;
const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0);
PreparedClosure prepared(
Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification,
f.mapper
);
prepared.prepare(f.state(rho, h), u, f.workspace);
::mfem::Vector result;
SECTION("Changed domain attributes") {
f.mesh.GetElement(0)->SetAttribute(3);
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
}
SECTION("Mesh refinement") {
f.mesh.UniformRefinement();
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
}
SECTION("Updated node geometry") {
f.mesh.NodesUpdated();
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
prepared.prepare(f.state(rho, h), u, f.workspace);
CHECK_NOTHROW(prepared.residual(f.workspace, result));
}
SECTION("Rebound compactification") {
Fixture other;
f.compactification.SetSpace(&other.enthalpy_space);
CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error);
}
}
TEST_CASE("Equation Field Restriction Excludes Only Vacuum-Only Coefficients", tags::unit & tags::equations) {
Fixture f;
auto full = constant(f.density_space.GetVSize(), 0.0);
::mfem::Array<int> dofs;
std::vector<bool> stellar_dofs(full.Size(), false);
for (int element = 0; element < f.mesh.GetNE(); ++element) {
if (f.mesh.GetAttribute(element) == 3) {
continue;
}
f.density_space.GetElementDofs(element, dofs);
for (const int dof : dofs) {
stellar_dofs[dof] = true;
}
}
for (int dof = 0; dof < full.Size(); ++dof) {
full[dof] = stellar_dofs[dof] ? 0.5 : std::numeric_limits<double>::quiet_NaN();
}
const auto selected = std::count(stellar_dofs.begin(), stellar_dofs.end(), true);
REQUIRE(selected > 0);
REQUIRE(selected < full.Size());
CHECK(f.density_binding.size() == selected);
const auto reduced = f.density_binding.gather(full);
CHECK_NOTHROW(f.density_binding.validate_values(reduced));
const auto expanded = f.density_binding.scatter(reduced);
CHECK(expanded.CheckFinite() == 0);
CHECK(reduced.Min() == 0.5);
}
TEST_CASE("Equation Bindings Reject Unsupported Meshes And Element Indices", tags::unit & tags::equations) {
Fixture f;
CHECK_THROWS_AS(f.density_binding.supports(-1), std::out_of_range);
CHECK_THROWS_AS(f.density_binding.supports(f.mesh.GetNE()), std::out_of_range);
::mfem::Vector empty, output;
CHECK_THROWS_AS(f.density_binding.element_values(0, empty, output), std::invalid_argument);
auto flat_mesh = ::mfem::Mesh::MakeCartesian2D(2, 2, ::mfem::Element::QUADRILATERAL);
::mfem::L2_FECollection flat_collection(2, 2);
::mfem::FiniteElementSpace flat_space(&flat_mesh, &flat_collection);
CHECK_THROWS_AS((discretization::backend::mfem::TermBinding<blocks::density::mass, Schema>{flat_space}), std::invalid_argument);
auto nonconforming_mesh = make_mesh(::mfem::Element::HEXAHEDRON);
nonconforming_mesh.EnsureNCMesh();
::mfem::FiniteElementSpace nonconforming_space(&nonconforming_mesh, &f.density_collection);
CHECK_NOTHROW((discretization::backend::mfem::TermBinding<blocks::density::mass, Schema>{nonconforming_space}));
}
namespace {
/** @brief Compare primal/dual coordinate access with independent MFEM extraction and assembly. */
void check_element_coordinates(const discretization::backend::mfem::DofMap& binding) {
const auto reduced = deterministic(binding.size(), 0.31);
const auto full = binding.scatter(reduced);
const auto elements = binding.validated_elements();
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
::mfem::Array<int> dofs;
::mfem::DofTransformation transformation;
binding.space().GetElementVDofs(element, dofs, transformation);
::mfem::Vector expected, actual;
full.GetSubVector(dofs, expected);
if (transformation.GetDofTransformation() != nullptr) {
transformation.InvTransformPrimal(expected);
}
elements.element_values(element, reduced, actual);
CHECK(relative_error(actual, expected) < 1.0e-13);
const auto coordinates = elements.coordinates(element);
REQUIRE(coordinates.size() == dofs.Size());
for (int local = 0; local < dofs.Size(); ++local) {
const int full_index = dofs[local] < 0 ? -1 - dofs[local] : dofs[local];
const int index = binding.reduced_dof(full_index).value_or(-1);
CHECK(coordinates.reduced_dof(local) == index);
if (index >= 0) {
CHECK(coordinates.local_dof(index) == local);
::mfem::Vector unit = constant(binding.size(), 0.0), direction;
unit[index] = 1.0;
coordinates.element_values(unit, expected);
coordinates.unit_direction(local, direction);
CHECK(relative_error(direction, expected) < 1.0e-13);
}
}
const auto dual = deterministic(dofs.Size(), 0.78);
::mfem::Vector expected_dual(dual), actual_dual(dual);
auto expected_full = constant(binding.full_size(), 0.0);
auto assembled = constant(binding.full_size(), 0.0);
if (transformation.GetDofTransformation() != nullptr) {
transformation.TransformDual(expected_dual);
}
expected_full.AddElementVector(dofs, expected_dual);
elements.add_element(element, actual_dual, assembled);
CHECK(relative_error(assembled, expected_full) < 1.0e-13);
CHECK(full * assembled == Catch::Approx(actual * dual).margin(1.0e-12));
actual_dual = dual;
coordinates.orient_dual(actual_dual);
for (int local = 0; local < dofs.Size(); ++local) {
const double expected_sign = dofs[local] < 0 ? -1.0 : 1.0;
CHECK(actual_dual[local] == Catch::Approx(expected_sign * expected_dual[local]).margin(1.0e-14));
}
}
}
}
TEST_CASE("Validated Element Traversals Preserve Native MFEM Coordinates", tags::unit & tags::equations) {
for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) {
Fixture fixture(geometry, ordering);
check_element_coordinates(fixture.density_binding);
check_element_coordinates(fixture.enthalpy_binding);
check_element_coordinates(fixture.displacement_binding);
}
}
}
TEST_CASE("Validated Element Traversals Preserve Global Scalar Support And Guards", tags::unit & tags::equations) {
Fixture fixture;
const auto& binding = fixture.constant_binding;
const auto elements = binding.validated_elements();
const auto reduced = constant(1, 2.0);
for (int element = 0; element < fixture.mesh.GetNE(); ++element) {
::mfem::Vector local;
elements.element_values(element, reduced, local);
CHECK(local[0] == (binding.supports(element) ? 2.0 : 0.0));
auto assembled = constant(1, 0.0);
auto dual = constant(1, 3.0);
elements.add_element(element, dual, assembled);
CHECK(assembled[0] == (binding.supports(element) ? 3.0 : 0.0));
}
::mfem::Vector output, empty;
CHECK_THROWS_AS(elements.element_values(-1, reduced, output), std::out_of_range);
CHECK_THROWS_AS(elements.element_values(0, empty, output), std::invalid_argument);
CHECK_THROWS_AS(elements.add_element(0, empty, output), std::invalid_argument);
fixture.mesh.GetElement(0)->SetAttribute(3);
CHECK_THROWS_AS(binding.validated_elements(), std::logic_error);
CHECK_THROWS_AS(binding.element_values(1, reduced, output), std::logic_error);
CHECK_THROWS_AS(elements.element_values(0, reduced, output), std::logic_error);
}
TEST_CASE("Validated Element Traversals Reject Changed Mesh Revisions", tags::unit & tags::equations) {
Fixture fixture;
const auto elements = fixture.enthalpy_binding.validated_elements();
const auto values = fixture.enthalpy();
fixture.mesh.UniformRefinement();
::mfem::Vector output;
CHECK_THROWS_AS(elements.element_values(0, values, output), std::logic_error);
CHECK_THROWS_AS(fixture.enthalpy_binding.validated_elements(), std::logic_error);
}
} // namespace serif::tests::equations

View File

@@ -0,0 +1,93 @@
#include <cmath>
#include <numbers>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/deformation/mesh.hpp"
#include "serif/backend/mfem/model/surface_potential.hpp"
#include "serif/backend/mfem/discretization/term_binding.hpp"
#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = serif::model::backend::mfem;
using Potential = serif::discretization::blocks::gravity::potential;
/** @brief Compare owned surface matrices with a coupled finite difference of the physical moments. */
void check_surface(const int surface_order, const bool nodal) {
serif::tests::deformation::PairedMesh mesh(0, 0.1, surface_order);
::mfem::H1_FECollection potential_collection(Potential::FunctionalSpace::order, 3);
::mfem::FiniteElementSpace potential_space(mesh.generated.mesh.get(), &potential_collection);
serif::discretization::backend::mfem::TermBinding<Potential, serif::tests::deformation::Schema> potential_binding(potential_space);
const auto reference = deformation::backend::mfem::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute);
REQUIRE(reference.has_value());
const auto embedding = [&] {
if (nodal) {
return deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions);
}
return deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 1}, reference->positions);
}();
REQUIRE(embedding.has_value());
const deformation::backend::mfem::SurfaceDiscretization discretization{
.scalar_space = mesh.scalar_space, .surface_attribute = mesh.surface_attribute};
const serif::equations::SurfacePotentialBalance equation{{1.0, 2.0, 3.0}, {0.1, -0.2, 0.3}};
std::vector<double> shape(embedding->input_size()), displacement(embedding->output_size());
::mfem::Vector potential(potential_binding.size()), potential_direction(potential.Size()), shape_direction(static_cast<int>(shape.size()));
for (int index = 0; index < potential.Size(); ++index) {
potential[index] = -1.0 + 0.02 * std::sin(index + 0.3);
potential_direction[index] = std::cos(index + 0.2);
}
for (int index = 0; index < shape_direction.Size(); ++index) {
shape[index] = 0.01 * std::sin(index + 0.7);
shape_direction[index] = 0.03 * std::cos(index + 0.4);
}
REQUIRE(embedding->apply(shape, displacement).has_value());
const auto accepted = backend::assemble_surface_potential(equation, discretization, *reference, *embedding,
displacement, potential_binding, potential, -1.2, 0.4);
::mfem::Vector action(shape_direction.Size()), temporary(action.Size());
accepted.potential->Mult(potential_direction, action);
accepted.shape->Mult(shape_direction, temporary);
action += temporary;
for (int row = 0; row < action.Size(); ++row) {
action[row] += 0.2 * (*accepted.level)(row, 0) - 0.3 * (*accepted.angular_velocity)(row, 0);
}
constexpr double step = 2.0e-6;
auto plus_potential = potential, minus_potential = potential;
plus_potential.Add(step, potential_direction);
minus_potential.Add(-step, potential_direction);
auto plus_shape = shape, minus_shape = shape;
for (int index = 0; index < shape_direction.Size(); ++index) {
plus_shape[index] += step * shape_direction[index];
minus_shape[index] -= step * shape_direction[index];
}
REQUIRE(embedding->apply(plus_shape, displacement).has_value());
const auto plus = backend::assemble_surface_potential(equation, discretization, *reference, *embedding,
displacement, potential_binding, plus_potential, -1.2 + step * 0.2, 0.4 - step * 0.3);
REQUIRE(embedding->apply(minus_shape, displacement).has_value());
const auto minus = backend::assemble_surface_potential(equation, discretization, *reference, *embedding,
displacement, potential_binding, minus_potential, -1.2 - step * 0.2, 0.4 + step * 0.3);
for (int row = 0; row < action.Size(); ++row) {
CHECK(std::abs(action[row] - (plus.residual[row] - minus.residual[row]) / (2.0 * step)) < 2.0e-9);
}
// Constant potential and zero rotation give a known normalized moment, independent of surface area.
potential = -0.8;
const auto constant = backend::assemble_surface_potential(equation, discretization, *reference, *embedding,
displacement, potential_binding, potential, -1.2, 0.0);
double moment = 0.0;
if (nodal) {
for (const double value : constant.residual) { moment += value; }
CHECK(std::abs(moment - 0.4) < 3.0e-12);
} else {
CHECK(std::abs(constant.residual.front() - 0.4 / std::sqrt(4.0 * std::numbers::pi)) < 3.0e-12);
}
constant.shape->Mult(shape_direction, temporary);
CHECK(temporary.Norml2() == 0.0);
}
}
TEST_CASE("Isobar potential moments preserve constant mode and differentiate pullback geometry", "[model][surface_potential][unit]") {
for (const int surface_order : {2, 3}) {
check_surface(surface_order, false);
check_surface(surface_order, true);
}
}

View File

@@ -0,0 +1,248 @@
#include <cmath>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "serif/backend/mfem/discretization/basis_evaluation.hpp"
namespace
{
using namespace serif::discretization::backend::mfem;
/// Compare prepared tensor or mapped actions against independently sampled dense matrices.
void check_actions(const BasisEvaluation& tensor, const BasisEvaluation& dense, const double tolerance) {
::mfem::Vector dofs(tensor.width()), dual(tensor.height()), values, reference, assembled, reference_transpose;
for (int i = 0; i < dofs.Size(); ++i) {
dofs[i] = std::sin(0.53 * (i + 1));
}
for (int i = 0; i < dual.Size(); ++i) {
dual[i] = std::cos(0.31 * (i + 1));
}
BasisWorkspace workspace;
for (int repeat = 0; repeat < 2; ++repeat) {
if (repeat) {
dofs = 0.0;
dual = 0.0;
}
tensor.evaluate(dofs, values, workspace);
dense.evaluate(dofs, reference, workspace);
::mfem::Vector error = values;
error -= reference;
CHECK(error.Normlinf() <= tolerance * std::max(1.0, reference.Normlinf()));
tensor.integrate(dual, assembled, workspace);
dense.integrate(dual, reference_transpose, workspace);
error = assembled;
error -= reference_transpose;
CHECK(error.Norml2() <= tolerance * std::max(1.0, reference_transpose.Norml2()));
CHECK(std::abs(values * dual - dofs * assembled) <= tolerance * std::max(1.0, std::abs(values * dual)));
if (repeat) {
CHECK(values.Norml2() == 0.0);
CHECK(assembled.Norml2() == 0.0);
}
}
}
/// Compare independent native MFEM basis matrices with the factored contractions and their transposes.
void check_basis(const ::mfem::FiniteElement& element, const ::mfem::IntegrationRule& rule, ReferenceOperation operation, int copies, double tolerance) {
TensorBasisCache cache;
BasisEvaluation tensor, dense;
tensor.sample(element, rule, operation, copies, cache, ContractionMode::automatic);
dense.sample(element, rule, operation, copies, cache, ContractionMode::dense);
REQUIRE(tensor.uses_tensor());
REQUIRE_FALSE(dense.uses_tensor());
check_actions(tensor, dense, tolerance);
}
}
TEST_CASE("Scalar and vector H1 tensor values gradients and transposes retain MeanField accuracy", "[equations][tensor_basis][unit]")
{
// MeanField tests/fem/reference_tables.cpp: retain its orders, bases and thresholds.
for (const int order : {1, 2, 4})
{
for (const int basis : {::mfem::BasisType::GaussLobatto, ::mfem::BasisType::ClosedUniform})
{
::mfem::H1_FECollection h1(order, 3, basis);
const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 3);
for (const int copies : {1, 2, 3})
{
check_basis(element, rule, ReferenceOperation::value, copies, 2.0e-12);
check_basis(element, rule, ReferenceOperation::gradient, copies, 2.0e-11);
check_basis(element, rule, ReferenceOperation::value_and_gradient, copies, 2.0e-12);
}
}
}
for (const int order : {0, 1, 3})
{
for (const int basis : {::mfem::BasisType::GaussLegendre, ::mfem::BasisType::OpenUniform})
{
::mfem::L2_FECollection l2(order, 3, basis);
const auto& element = *l2.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 3);
for (const int copies : {1, 2, 3})
{
check_basis(element, rule, ReferenceOperation::value, copies, 2.0e-12);
check_basis(element, rule, ReferenceOperation::gradient, copies, 2.0e-11);
}
}
}
}
TEST_CASE("Tensor plans own their factors and reject non Cartesian rules without changing quadrature", "[equations][tensor_basis][unit]")
{
::mfem::H1_FECollection h1(3, 3);
const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const auto& original = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 9);
::mfem::IntegrationRule copy(original.GetNPoints());
for (int q = 0; q < copy.GetNPoints(); ++q)
{
copy.IntPoint(q) = original.IntPoint(q);
}
TensorBasisCache cache;
auto retained = cache.get(element, copy, ReferenceOperation::value, 1);
REQUIRE(retained);
CHECK(cache.get(element, original, ReferenceOperation::value, 1) == retained);
CHECK(cache.size() == 1);
copy.IntPoint(copy.GetNPoints() - 1).x += 0.017;
CHECK_FALSE(cache.get(element, copy, ReferenceOperation::value, 1));
CHECK(cache.size() == 2);
cache.clear();
CHECK(cache.size() == 0);
::mfem::Vector dofs(retained->dofs()), values;
dofs = 1.0;
TensorWorkspace workspace;
retained->apply(dofs, values, workspace);
for (int q = 0; q < values.Size(); ++q)
{
CHECK(std::abs(values[q] - 1.0) < 2.0e-12);
}
const auto& tetrahedron = *h1.FiniteElementForGeometry(::mfem::Geometry::TETRAHEDRON);
const auto& tetrahedron_rule = ::mfem::IntRules.Get(::mfem::Geometry::TETRAHEDRON, 9);
CHECK_FALSE(TensorBasis::make(tetrahedron, tetrahedron_rule, ReferenceOperation::value, 1));
CHECK_FALSE(TensorBasis::make(element, original, ReferenceOperation::value, 0));
BasisEvaluation fallback;
fallback.sample(element, copy, ReferenceOperation::value, 1, cache, ContractionMode::automatic);
CHECK_FALSE(fallback.uses_tensor());
}
TEST_CASE("Reused basis actions retain mappings through composition resampling and dense fallback", "[equations][tensor_basis][unit]") {
::mfem::H1_FECollection h1(3, 3);
const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 9);
TensorBasisCache cache;
BasisEvaluation reference, dense_reference;
reference.sample(element, rule, ReferenceOperation::value_and_gradient, 3, cache, ContractionMode::automatic);
dense_reference.sample(element, rule, ReferenceOperation::value_and_gradient, 3, cache, ContractionMode::dense);
BasisEvaluation tensor = reference;
BasisEvaluation dense = dense_reference;
BasisWorkspace workspace;
// Direct mapping of a sampled basis must disable its canonical shortcut, even without reset_from.
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto map = [point](const ::mfem::Vector& input, ::mfem::Vector& output) {
for (int component = 0; component < output.Size(); ++component) {
output[component] = (1.0 + 0.01 * point) * input[component] - 0.2 * input[(component + 1) % input.Size()];
}
};
tensor.map_point(point, reference, map, workspace);
dense.map_point(point, dense_reference, map, workspace);
}
check_actions(tensor, dense, 2.0e-12);
// A mapped reference remains usable when the next physical map changes component count.
reference = tensor;
dense_reference = dense;
tensor.reset_from(reference, 5);
dense.reset_from(dense_reference, 5);
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto map = [](const ::mfem::Vector& input, ::mfem::Vector& output) {
for (int component = 0; component < output.Size(); ++component) {
output[component] = 0.7 * input[component] + 0.4 * input[component + 5];
}
};
tensor.map_point(point, reference, map, workspace);
dense.map_point(point, dense_reference, map, workspace);
}
check_actions(tensor, dense, 2.0e-12);
// Reusing the same object for canonical, dense and constant bases must clear the earlier representation.
for (const ContractionMode mode : {ContractionMode::automatic, ContractionMode::dense, ContractionMode::automatic}) {
tensor.sample(element, rule, ReferenceOperation::gradient, 2, cache, mode);
dense.sample(element, rule, ReferenceOperation::gradient, 2, cache, ContractionMode::dense);
check_actions(tensor, dense, 2.0e-11);
}
tensor.set_constant(rule.GetNPoints());
dense.set_constant(rule.GetNPoints());
check_actions(tensor, dense, 2.0e-12);
}
TEST_CASE("Canonical mapped and dense basis actions preserve aliased inputs while resizing", "[equations][tensor_basis][unit]") {
::mfem::H1_FECollection h1(2, 3);
const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE);
TensorBasisCache cache;
BasisWorkspace workspace;
for (const int order : {5, 7}) {
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, order);
for (const auto mode : {ContractionMode::automatic, ContractionMode::dense}) {
CAPTURE(order, mode);
BasisEvaluation reference, dense_reference;
reference.sample(element, rule, ReferenceOperation::value, 1, cache, mode);
dense_reference.sample(element, rule, ReferenceOperation::value, 1, cache, ContractionMode::dense);
::mfem::Vector input(reference.width()), dual(reference.height()), expected, aliased;
for (int index = 0; index < input.Size(); ++index) {
input[index] = std::sin(0.53 * (index + 1));
}
for (int index = 0; index < dual.Size(); ++index) {
dual[index] = std::cos(0.31 * (index + 1));
}
for (const bool mapped : {false, true}) {
BasisEvaluation basis = reference;
BasisEvaluation dense = dense_reference;
if (mapped) {
for (int point = 0; point < rule.GetNPoints(); ++point) {
const auto map = [point](const ::mfem::Vector& source, ::mfem::Vector& destination) {
destination[0] = (1.0 + 0.03 * point) * source[0];
};
basis.map_point(point, reference, map, workspace);
dense.map_point(point, dense_reference, map, workspace);
}
}
dense.evaluate(input, expected, workspace);
aliased = input;
basis.evaluate(aliased, aliased, workspace);
aliased -= expected;
CHECK(aliased.Normlinf() <= 2.0e-12);
dense.integrate(dual, expected, workspace);
aliased = dual;
basis.integrate(aliased, aliased, workspace);
aliased -= expected;
CHECK(aliased.Normlinf() <= 2.0e-12);
}
}
}
}
TEST_CASE("Implicit canonical identities preserve untouched points and support in place maps", "[equations][tensor_basis][unit]") {
::mfem::H1_FECollection h1(2, 3);
const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE);
const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7);
TensorBasisCache cache;
BasisWorkspace workspace;
BasisEvaluation tensor, dense;
// More than twelve components ensures mapping scratch does not restrict generality.
tensor.sample(element, rule, ReferenceOperation::value_and_gradient, 4, cache, ContractionMode::automatic);
dense.sample(element, rule, ReferenceOperation::value_and_gradient, 4, cache, ContractionMode::dense);
REQUIRE(tensor.uses_tensor());
const auto map = [](const ::mfem::Vector& input, ::mfem::Vector& output) {
for (int component = 0; component < output.Size(); ++component) {
output[component] = 0.8 * input[component] + 0.3 * input[(component + 1) % input.Size()];
}
};
// A first map materializes only the destination representation; later maps compose with it.
for (const int point : {3, 0, 3}) {
tensor.map_point(point, tensor, map, workspace);
dense.map_point(point, dense, map, workspace);
check_actions(tensor, dense, 2.0e-11);
}
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <memory>
#include <vector>
#include <stroid/stroid.h>
#include "mfem/fem/fespace.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/backend/mfem/geometry/deformation/radial_interpolation.hpp"
namespace serif::tests::deformation {
namespace geometry = serif::geometry::deformation;
namespace backend = geometry::backend::mfem;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
/** @brief Preserve paired STROID reference meshes and the old shell-radius fixture. */
[[nodiscard]] inline stroid::StroidMesh make_mesh(int refinement = 0, double flattening = 0.0, int geometry_order = 2, double infinity_radius = 4.0) {
stroid::config::MeshConfig config;
config.refinement_levels = refinement;
config.order = geometry_order;
config.include_external_domain = true;
config.r_core = 0.25;
config.r_star = 1.0;
config.r_infinity = infinity_radius;
config.flattening = flattening;
config.optimization_methods = stroid::config::OptimizationMethods{false, true};
return stroid::GenerateMesh(config);
}
/** @brief Keep both scalar and vector spaces on the same nodal collection and paired geometry. */
struct PairedMesh {
stroid::StroidMesh generated;
::mfem::H1_FECollection collection;
::mfem::FiniteElementSpace scalar_space;
::mfem::FiniteElementSpace vector_space;
std::vector<int> stellar_elements;
std::vector<int> vacuum_elements;
static constexpr int surface_attribute = Schema::boundary_id<discretization::domain::StellarSurfaceBoundary>();
static constexpr int infinity_attribute = Schema::boundary_id<discretization::domain::InfinitySurfaceBoundary>();
/** @brief Construct mesh data only for independent backend regression fixtures. */
explicit PairedMesh(int refinement = 0, double flattening = 0.0, int displacement_order = 2, int geometry_order = 2, double infinity_radius = 4.0)
: generated(make_mesh(refinement, flattening, geometry_order, infinity_radius)), collection(displacement_order, 3), scalar_space(generated.mesh.get(), &collection), vector_space(generated.mesh.get(), &collection, 3, ::mfem::Ordering::byNODES), stellar_elements(generated.mesh->GetNE()), vacuum_elements(generated.mesh->GetNE()) {
for (int element = 0; element < generated.mesh->GetNE(); ++element) {
const bool vacuum = generated.mesh->GetAttribute(element) == Schema::domain_id<discretization::domain::VacuumDomain>();
stellar_elements[element] = !vacuum;
vacuum_elements[element] = vacuum;
}
}
/** @brief Exercise the public backend compilation entry point without a second test implementation. */
[[nodiscard]] std::expected<geometry::RadialInterpolation, geometry::DeformationFailure> interpolation() {
return backend::build_radial_interpolation(scalar_space, vector_space, *generated.reference_mesh, stellar_elements, vacuum_elements, surface_attribute, infinity_attribute);
}
};
}

View File

@@ -6,7 +6,7 @@
#include <vector> #include <vector>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <mfem.hpp> #include "mfem/mesh/mesh.hpp"
#include <stroid/stroid.h> #include <stroid/stroid.h>
#include "serif/discretization/domain/ids/boundary.hpp" #include "serif/discretization/domain/ids/boundary.hpp"
@@ -16,7 +16,7 @@
#include "serif/discretization/domain/relation/lists/relation_list.hpp" #include "serif/discretization/domain/relation/lists/relation_list.hpp"
#include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/relations.hpp"
#include "serif/discretization/domain/schema/domain_schema.hpp" #include "serif/discretization/domain/schema/domain_schema.hpp"
#include "serif/discretization/domain/schema/validation/all.hpp" #include "serif/backend/mfem/discretization/schema.hpp"
#include "serif/discretization/domain/types.hpp" #include "serif/discretization/domain/types.hpp"
namespace domain_test_utils { namespace domain_test_utils {
@@ -281,7 +281,7 @@ namespace domain_test_utils {
template <typename SchemaT> template <typename SchemaT>
void check_schema_is_valid(const mfem::Mesh &mesh) { void check_schema_is_valid(const mfem::Mesh &mesh) {
const auto validation = domain::schema::validation::validate_schema<SchemaT>(mesh); const auto validation = serif::backend::mfem::discretization::validate_schema<SchemaT>(mesh);
CHECK(validation.relationResults.size() == SchemaT::relation_count); CHECK(validation.relationResults.size() == SchemaT::relation_count);

View File

@@ -0,0 +1,41 @@
#pragma once
#include <cmath>
#include "serif/dimensions/partial.hpp"
#include "serif/dimensions/type_alias.hpp"
#include "serif/eos/relations/relations.hpp"
namespace serif::tests::equations {
/** @brief A nonpolytropic barotrope P = rho^2 + rho^4 with no enthalpy relation or polytropic parameters. */
struct MixedPowerBarotrope final {
using Relations = eos::relations::RelationCatalog<eos::relations::PressureFromDensity,
eos::relations::DensityFromPressure, eos::relations::PressurePotentialFromDensity>;
double gauge = 0.0;
/** @brief Evaluate the independently specified pressure law at the tested nonnegative densities. */
[[nodiscard]] dimensions::PressureValue evaluate(eos::relations::PressureFromDensity, const dimensions::DensityValue density) const {
const double squared_density = density.value() * density.value();
return dimensions::PressureValue{squared_density + squared_density * squared_density};
}
/** @brief Invert the pressure law using the rationalized positive root. */
[[nodiscard]] dimensions::DensityValue evaluate(eos::relations::DensityFromPressure, const dimensions::PressureValue pressure) const {
return dimensions::DensityValue{std::sqrt(2.0 * pressure.value() / (1.0 + std::sqrt(1.0 + 4.0 * pressure.value())))};
}
/** @brief Integrate P_rho/rho and expose an arbitrary additive constant. */
[[nodiscard]] dimensions::BarotropicPressurePotentialValue evaluate(
eos::relations::PressurePotentialFromDensity, const dimensions::DensityValue density) const {
return dimensions::BarotropicPressurePotentialValue{2.0 * density.value() +
(4.0 / 3.0) * density.value() * density.value() * density.value() + gauge};
}
/** @brief Supply the only thermodynamic derivative required by the density equation. */
[[nodiscard]] dimensions::PartialDerivative<dimensions::Pressure, dimensions::MassDensity> partial_derivative(
eos::relations::PressureFromDensity, dimensions::WithRespectTo<dimensions::MassDensity>, const dimensions::DensityValue density) const {
return dimensions::PartialDerivative<dimensions::Pressure, dimensions::MassDensity>{
2.0 * density.value() + 4.0 * density.value() * density.value() * density.value()};
}
};
}

View File

@@ -0,0 +1,78 @@
#pragma once
#include <functional>
#include <mfem.hpp>
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/backend/mfem/operators/jacobian_operator.hpp"
#include "serif/tests/equations/volume_equation_fixture.hpp"
namespace serif::tests::equations {
namespace backend = serif::operators::backend::mfem;
namespace blocks = discretization::blocks;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
using Closure = ::serif::tests::equations::BarotropicClosure<eos::models::Polytrope>;
using PreparedClosure = VolumeEquationFixture<Closure, Schema>;
::mfem::Mesh make_mesh(::mfem::Element::Type geometry);
::mfem::Vector constant(int size, double value);
::mfem::Vector deterministic(int size, double phase);
::mfem::Vector packed(
const ::mfem::Vector& density,
const ::mfem::Vector& enthalpy,
const ::mfem::Vector& displacement
);
double relative_error(const ::mfem::Vector& actual, const ::mfem::Vector& expected);
struct Fixture {
::mfem::Mesh mesh;
::mfem::H1_FECollection density_collection{blocks::density::mass::FunctionalSpace::order, 3};
::mfem::H1_FECollection enthalpy_collection{::serif::tests::equations::Enthalpy::FunctionalSpace::order, 3};
::mfem::H1_FECollection potential_collection{blocks::gravity::potential::FunctionalSpace::order, 3};
::mfem::FiniteElementSpace density_space;
::mfem::FiniteElementSpace enthalpy_space;
::mfem::FiniteElementSpace potential_space;
::mfem::FiniteElementSpace displacement_space;
::mfem::GridFunction compactification;
discretization::backend::mfem::TermBinding<blocks::density::mass, Schema> density_binding;
discretization::backend::mfem::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy_binding;
discretization::backend::mfem::TermBinding<blocks::displacement::vector, Schema> displacement_binding;
discretization::backend::mfem::TermBinding<blocks::barotropic_constant::mass_normalization, Schema> constant_binding{mesh};
discretization::backend::mfem::TermBinding<blocks::rotation::angular_velocity, Schema> angular_velocity_binding{mesh};
mapping::DomainMapper mapper;
backend::Workspace workspace;
explicit Fixture(
::mfem::Element::Type geometry = ::mfem::Element::HEXAHEDRON,
::mfem::Ordering::Type ordering = ::mfem::Ordering::byNODES
);
/// Reuse the registered spaces and bindings on an explicitly supplied conforming mesh.
explicit Fixture(::mfem::Mesh mesh, ::mfem::Ordering::Type ordering = ::mfem::Ordering::byNODES);
[[nodiscard]] ::mfem::Vector density(double phase = 0.0);
[[nodiscard]] ::mfem::Vector enthalpy(double phase = 0.0);
[[nodiscard]] ::mfem::Vector displacement(double scale);
[[nodiscard]] ::mfem::Vector project(
const discretization::backend::mfem::DofMap& field,
std::function<double(const ::mfem::Vector&)> function
);
[[nodiscard]] auto state(const ::mfem::Vector& density, const ::mfem::Vector& enthalpy) const {
return backend::make_term_coefficients(
backend::bind(density_binding, density), backend::bind(enthalpy_binding, enthalpy)
);
}
};
// Independent, direct-quadrature port of MeanField's manual closure kernels.
// Uses the analytic polytropic formula, not the new equation evaluator or bases.
::mfem::Vector manual_closure(
Fixture& fixture,
const eos::models::Polytrope& eos,
const ::mfem::Vector& density,
const ::mfem::Vector& enthalpy,
const ::mfem::Vector& displacement,
const ::mfem::Vector* density_direction = nullptr,
const ::mfem::Vector* enthalpy_direction = nullptr,
const ::mfem::Vector* displacement_direction = nullptr
);
} // namespace serif::tests::equations

View File

@@ -0,0 +1,106 @@
#pragma once
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/equations/fixture.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/backend/mfem/mapping/evaluator.hpp"
// Shared fixture and independent mapping reference for potential, density and geometry backend tests.
namespace serif::tests::equations::gravity {
using Potential = blocks::gravity::potential;
using Density = blocks::density::mass;
using DensityValue = discretization::forms::Operand<Density>;
using PotentialRow = serif::equations::PrimalPoissonBalance;
using VectorValue = discretization::forms::VectorValue;
constexpr int reference_order = 12;
constexpr double four_pi_G = 2.3;
/// Use the same explicitly chosen rule in the backend and independent assembled forms.
inline discretization::quadrature::Policy reference_policy() {
discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = reference_order});
return discretization::quadrature::Policy{rules};
}
struct GravityFixture : Fixture {
discretization::backend::mfem::TermBinding<Potential, Schema> potential_binding{potential_space};
using Fixture::Fixture;
/// Bind distinct potential and density coefficient vectors.
auto state(const ::mfem::Vector& potential, const ::mfem::Vector& density) const {
return backend::make_term_coefficients(backend::bind(potential_binding, potential), backend::bind(density_binding, density));
}
/// Use a quadratic potential representable in the registered H1 space.
::mfem::Vector potential() {
return project(potential_binding, [](const ::mfem::Vector& x) { return -0.5 + 0.1 * x[0] * x[1] - 0.07 * x[2] * x[2]; });
}
/// Supply a valid nonconstant exterior coordinate without a mesh-generator dependency.
void set_compactification() {
::mfem::FunctionCoefficient coordinate([](const ::mfem::Vector& x) { return 0.2 + 0.15 * x[0]; });
compactification.ProjectCoefficient(coordinate);
}
};
// Evaluate the shared mapping module independently of the operator backend.
struct ReferenceGeometry
{
GravityFixture& fixture;
const mapping::DomainMapper& mapper;
const ::mfem::Vector& displacement;
/**
* @brief Freeze the supported displacement once and reuse the production element mapping cache.
* @note This reference represents one fixed numerical state. Construct another instance after changing displacement or compactification coefficients.
*/
ReferenceGeometry(GravityFixture& fixture, const mapping::DomainMapper& mapper, const ::mfem::Vector& displacement) :
fixture(fixture),
mapper(mapper),
displacement(displacement),
m_displacement(&fixture.displacement_space),
m_evaluator(mapper, m_displacement, fixture.compactification)
{
m_displacement = fixture.displacement_binding.scatter(displacement);
}
/** @brief The evaluator borrows its owned grid function, so its reference object keeps a stable address. */
ReferenceGeometry(const ReferenceGeometry&) = delete;
ReferenceGeometry& operator=(const ReferenceGeometry&) = delete;
ReferenceGeometry(ReferenceGeometry&&) = delete;
ReferenceGeometry& operator=(ReferenceGeometry&&) = delete;
/** @brief Evaluate only the shared mapping module for an independent MFEM quadrature coefficient. */
void evaluate(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::VolumeMappingContext& context)
{
REQUIRE(m_evaluator.evaluate_volume(transformation, point, context) == mapping::MappingStatus::VALID);
}
/** @brief Expose compactified-infinity status to coefficients with a known boundary limit. */
[[nodiscard]] mapping::MappingStatus try_evaluate_point(::mfem::ElementTransformation& transformation,
const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context)
{
return m_evaluator.evaluate_point(transformation, point, context);
}
/** @brief Map finite interpolation nodes without requiring a positive quadrature weight. */
void evaluate_point(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context)
{
REQUIRE(try_evaluate_point(transformation, point, context) == mapping::MappingStatus::VALID);
}
private:
::mfem::GridFunction m_displacement;
mapping::GridFunctionMappingEvaluator m_evaluator;
};
struct ReferenceVolume : ::mfem::Coefficient {
ReferenceGeometry& geometry;
/// Borrow the geometry shared with the vector mass coefficient.
explicit ReferenceVolume(ReferenceGeometry& geometry) : geometry(geometry) {}
/// The source's pulled-back weight is j; MFEM supplies the mesh Jacobian.
double Eval(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point) override {
mapping::VolumeMappingContext context;
geometry.evaluate(transformation, point, context);
return context.mapping.mapping_determinate;
}
};
} // namespace serif::tests::equations::gravity

View File

@@ -0,0 +1,64 @@
#pragma once
#include <array>
#include <functional>
#include <limits>
#include <memory>
#include "serif/tests/equations/gravity_fixture.hpp"
namespace serif::tests::equations::gravity_port {
using namespace gravity;
using Vector = ::mfem::Vector;
struct CompactFixture : GravityFixture {
/// Keep the vacuum branch active in all inexpensive Cartesian regression cases.
CompactFixture() {
set_compactification();
}
};
struct Coefficients {
Vector potential, density, displacement;
};
/// Return independently owned zero coefficients of every required size.
Coefficients zeros(const GravityFixture& fixture);
/// Preserve MeanField's fixed-geometry base/direction coefficient sequences.
Coefficients state(GravityFixture& fixture, bool deformed = true);
/// Density (column 0) or potential (other columns) direction, using the original amplitudes and phases.
Coefficients direction(GravityFixture& fixture, int column);
/// Preserve the original componentwise sinusoidal coefficient sequence.
Vector sequence(int size, double scale, double phase);
/// Preserve gravity_prepared_test_utils::make_deterministic_vector exactly.
Vector prepared_sequence(int size, double phase);
/// Preserve gravity_field.cpp::make_test_vector exactly.
Vector operator_sequence(int size, double phase);
/// Preserve MeanField's displacement used by prepared operator comparisons.
Vector prepared_displacement(GravityFixture& fixture, double scale);
/// Project a vector coefficient through MFEM, preserving each binding's representation and ordering.
Vector project_vector(const discretization::backend::mfem::DofMap& binding, std::function<void(const Vector&, Vector&)> function);
/// Affine displacement for folding candidates and independent prepared states.
Vector affine_displacement(GravityFixture& fixture, double scale);
/// Preserve the nonlinear geometry used by the old stateless-kernel tests.
Vector kernel_displacement(GravityFixture& fixture);
/// Preserve both old geometry-direction polynomials, including mixed components.
Vector geometry_direction(GravityFixture& fixture, int index = 0);
/// Preserve both old polynomial source densities.
Vector kernel_density(GravityFixture& fixture, bool secondary = false);
/// Select native coefficients active in the star, or their vacuum-only complement.
Vector domain_density(GravityFixture& fixture, bool stellar);
/// Curved Stroid geometry with core, envelope, and nonconstant Kelvin exterior coordinate.
std::unique_ptr<GravityFixture> stellar_fixture(int refinement_levels = 0, int geometry_order = 2);
/// Add scaled coefficient directions without aliasing the base state.
Coefficients shifted(const Coefficients& base, const Coefficients& delta, double scale);
/// Reference-relative error from the original operator/kernel tests, with their original floor.
double relative(const Vector& actual, const Vector& expected, double floor = 1.0e-14);
/// Symmetric relative difference used by the old residual identity tests.
double symmetric_relative(const Vector& first, const Vector& second);
/// Prepared-test relative error uses machine epsilon, not an enlarged denominator.
double prepared_relative(const Vector& actual, const Vector& expected);
/// Scalar pairing error, preserving the original prepared-test denominator.
double pairing_relative(double actual, double expected);
/// Return a linear combination with the original summation order.
Vector combination(const Vector& first, double a, const Vector& second, double b);
} // namespace serif::tests::equations::gravity_port

View File

@@ -0,0 +1,105 @@
#pragma once
/**
* @file test_equations.hpp
* @brief Test-only fields and equations that exercise the shared volume infrastructure.
*
* SERiF's production formulation carries density, potential, shape and global scalars. These definitions add a
* second scalar H1 field and a small closure equation on it so that operator, cache and graph tests keep
* an independent two-field vehicle with an analytic reference. They are not a physical formulation.
*/
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <string_view>
#include <utility>
#include "serif/discretization/blocks/fields.hpp"
#include "serif/eos/evaluation.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/eos/models/polytropic.hpp"
#include "serif/equations/contributions.hpp"
#include "serif/equations/point.hpp"
#include "serif/utils/error/errors.hpp"
namespace serif::tests::equations {
/** @brief A third-order scalar H1 test field supported on the stellar domains. */
struct enthalpy final : discretization::blocks::field {
static constexpr std::string_view name = meta::type_name<enthalpy>();
struct specific final : discretization::blocks::term<enthalpy> {
static constexpr std::string_view name = meta::type_name<specific>();
using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace<discretization::functional_space::H1, 3, 0>;
using Support = discretization::domain::StellarDomains;
using Quantity = dimensions::SpecificEnthalpy;
static constexpr std::string_view symbol = "h";
struct value final : discretization::blocks::value_block_base {};
struct residual final : discretization::blocks::residual_block_base {};
};
using Terms = utils::types::TypeList<specific>;
};
using Enthalpy = enthalpy::specific;
/** @brief The volume test equation rho - rho_EOS(h) = 0 on the stellar domains, with its analytic tangent. */
template <eos::models::EOSModel EOST>
requires eos::models::EOSSupportingRelation<EOST, eos::relations::DensityFromSpecificEnthalpy> &&
eos::models::EOSSupportingPartialDerivative<EOST, eos::relations::DensityFromSpecificEnthalpy, dimensions::SpecificEnthalpy>
class BarotropicClosure final {
public:
using Test = discretization::blocks::density::mass;
using ResidualQuantity = dimensions::MassDensity;
using Enthalpy = equations::Enthalpy;
using Operands = utils::types::TypeList<discretization::forms::Operand<Test>, discretization::forms::Operand<Enthalpy>>;
static constexpr std::string_view name = "barotropic closure";
/** @brief Store the EOS and the positive exponent used only to choose quadrature. */
BarotropicClosure(EOST equation_of_state, const double enthalpy_power) : m_eos(std::move(equation_of_state)), m_enthalpy_power(enthalpy_power) {
using namespace utils::errors;
serif_ensure<SERiFErrorCode::INVALID_QUADRATURE_ORDER, std::invalid_argument>(std::isfinite(enthalpy_power) && enthalpy_power > 0.0,
"The density quadrature power must be positive and finite.");
}
/** @brief Use the polytropic index as the quadrature exponent. */
explicit BarotropicClosure(EOST equation_of_state) requires std::same_as<EOST, eos::models::Polytrope>
: BarotropicClosure(equation_of_state, equation_of_state.polytropic_index()) {}
/** @brief Evaluate rho - rho_EOS(h) and its derivatives. */
[[nodiscard]] serif::equations::PointLinearization<Operands> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
using namespace utils::errors;
const double density = point.fields.template get<Test>();
const double enthalpy = point.fields.template get<Enthalpy>();
serif_ensure<SERiFErrorCode::EOS_NONFINITE_INPUT, eos::EOSEvaluationError>(std::isfinite(density) && std::isfinite(enthalpy), "Closure requires finite density and enthalpy.");
serif_ensure<SERiFErrorCode::EOS_OUTSIDE_DOMAIN, eos::EOSEvaluationError>(enthalpy >= 0.0, "Stellar closure requires non-negative enthalpy.");
const dimensions::SpecificEnthalpyValue input{enthalpy};
const double eos_density = eos::evaluate<dimensions::MassDensity>(m_eos, input).value();
const double density_derivative = eos::partial_derivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>(m_eos, input).value();
serif_ensure<SERiFErrorCode::EOS_NONFINITE_RESULT, eos::EOSEvaluationError>(std::isfinite(eos_density) && std::isfinite(density_derivative), "Barotropic closure received a non-finite EOS result.");
serif::equations::PointLinearization<Operands> result;
result.residual = density - eos_density;
result.field_derivatives.template get<Test>() = 1.0;
result.field_derivatives.template get<Enthalpy>() = -density_derivative;
return result;
}
/** @brief Raise the enthalpy operand's quadrature by (power - 1) times its order. */
template <typename OperandT>
[[nodiscard]] int additional_order(OperandT, int) const {
if constexpr (std::same_as<typename OperandT::term_type, Enthalpy>) {
return static_cast<int>(std::ceil(std::max(0.0, m_enthalpy_power - 1.0) * Enthalpy::FunctionalSpace::order));
}
return 0;
}
/** @brief Inspect the EOS defining this closure. */
[[nodiscard]] const EOST& equation_of_state() const noexcept { return m_eos; }
/** @brief Inspect the declared quadrature power. */
[[nodiscard]] double enthalpy_power() const noexcept { return m_enthalpy_power; }
private:
EOST m_eos;
double m_enthalpy_power;
};
} // namespace serif::tests::equations

View File

@@ -0,0 +1,193 @@
#pragma once
#include "serif/utils/error/errors.hpp"
#include <optional>
#include <stdexcept>
#include <utility>
#include "serif/backend/mfem/operators/volume_computations.hpp"
#include "serif/backend/mfem/operators/volume_application.hpp"
namespace serif::tests::equations {
namespace backend = serif::operators::backend::mfem;
/// Derive the coefficient context used by an equation's unique input terms.
template <typename ColumnsT, typename SchemaT>
struct EquationCoefficients;
template <typename... Operands, typename SchemaT>
struct EquationCoefficients<utils::types::TypeList<Operands...>, SchemaT> {
using Type = decltype(backend::make_term_coefficients(
backend::bind(
std::declval<const discretization::backend::mfem::TermBinding<typename Operands::term_type, SchemaT>&>(),
std::declval<const ::mfem::Vector&>()
)...
));
};
/// Keep legacy numerical comparisons concise while production uses dependency caches directly.
template <operators::VolumeEquation EquationT, discretization::domain::schema::IsSchema SchemaT>
class VolumeEquationFixture {
public:
using Plan = operators::EquationPlan<EquationT>;
using Discretization = backend::VolumeDiscretization<typename Plan::Test, SchemaT>;
using RowBinding = discretization::backend::mfem::TermBinding<typename Plan::Test, SchemaT>;
using DisplacementBinding = discretization::backend::mfem::TermBinding<discretization::blocks::displacement::vector, SchemaT>;
using Coefficients = EquationCoefficients<typename Plan::Columns, SchemaT>::Type;
using Inputs = decltype(backend::make_volume_inputs<EquationT>(
std::declval<const Discretization&>(), std::declval<const Coefficients&>(), std::declval<const ::mfem::Vector&>()
));
using Cache = decltype(backend::make_volume_equation_cache(
std::declval<EquationT>(), std::declval<Discretization>(), std::declval<const Inputs&>(), backend::VolumeOptions{}
));
/// Retain the fixed test configuration until the first numerical input is supplied.
VolumeEquationFixture(
EquationT equation,
const RowBinding& residual_binding,
const DisplacementBinding& displacement_binding,
const ::mfem::GridFunction& compactification,
const mapping::DomainMapper& mapper,
discretization::quadrature::Policy quadrature_policy = discretization::quadrature::Policy{{}},
backend::ContractionMode contraction_mode = backend::ContractionMode::automatic
) :
m_equation(std::move(equation)),
m_discretization{
.residual_binding = residual_binding,
.displacement_binding = displacement_binding,
.compactification = compactification,
.mapper = mapper
},
m_options{
.quadrature_policy = std::move(quadrature_policy),
.contraction_mode = contraction_mode
} {}
/// Exercise the production validation and graph execution with the old test call shape.
template <typename CoefficientsT>
std::expected<void, backend::PreparationRejection> try_prepare(
const CoefficientsT& coefficients,
const ::mfem::Vector& displacement,
backend::Workspace&,
backend::PreparationMode mode = backend::PreparationMode::rebuild
) {
try {
if (!m_cache) {
const auto inputs = backend::make_volume_inputs<EquationT>(m_discretization, coefficients, displacement);
m_cache.emplace(backend::make_volume_equation_cache(std::move(m_equation), m_discretization, inputs, m_options));
}
} catch (const std::domain_error& error) {
return std::unexpected(backend::PreparationRejection{
.reason = backend::PreparationRejectionReason::NON_FINITE,
.message = error.what()
});
}
return backend::try_prepare_volume(*m_cache, m_discretization, coefficients, displacement, mode);
}
/// Turn a numerical rejection into the exception expected by the numerical fixtures.
template <typename CoefficientsT>
void prepare(
const CoefficientsT& coefficients,
const ::mfem::Vector& displacement,
backend::Workspace& workspace,
backend::PreparationMode mode = backend::PreparationMode::rebuild
) {
const auto result = try_prepare(coefficients, displacement, workspace, mode);
if (!result) {
serif::utils::errors::serif_error<std::domain_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("{}", result.error().message);
}
}
/// Access the real cache for MFEM-adapter tests.
Cache& cache() {
if (!m_cache) {
serif::utils::errors::serif_error<std::logic_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("The numerical fixture has not received its inputs.");
}
return *m_cache;
}
/// Read the same accepted result consumed by production functions.
const auto& data() const {
if (!m_cache) {
serif::utils::errors::serif_error<std::logic_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("The numerical fixture has not received its inputs.");
}
return m_cache->template get<backend::volume_keys::Linearization>();
}
/// Report readiness directly from the dependency cache.
bool is_prepared() const {
return m_cache && m_cache->ready();
}
/// Count successful preparations, including explicitly requested rebuilds.
std::size_t preparation_count() const {
return m_cache ? m_cache->preparation_count() : 0;
}
/// Read derived numerical work without owning a second preparation report.
auto preparation_report() const {
return backend::volume_work_report(*m_cache);
}
/// Discard cached results through the dependency module.
void invalidate() {
if (m_cache) {
m_cache->invalidate();
}
}
/// Return the supported row size before preparation is needed.
int height() const {
return m_discretization.residual_binding.size();
}
/// Return the accepted Jacobian column count.
int width() const {
return backend::volume_width(data());
}
/// Inspect which numerical path the production preparation selected.
std::size_t tensor_element_count() const {
return backend::tensor_element_count(data());
}
/// Inspect the selected quadrature order for a supported element.
int integration_order(int element) const {
return backend::integration_order(data(), element);
}
/// Fetch the residual already assembled by the graph.
void residual(backend::Workspace&, ::mfem::Vector& result) const {
backend::volume_residual(data(), result);
}
/// Delegate every Jacobian call to the production numerical kernel.
template <typename... Args>
void apply_jacobian(Args&&... args) const {
backend::apply_jacobian(data(), std::forward<Args>(args)...);
}
/// Delegate transpose application to the production numerical kernel.
void apply_jacobian_transpose(const ::mfem::Vector& dual, backend::Workspace& workspace, ::mfem::Vector& result) const {
backend::apply_jacobian_transpose(data(), dual, workspace, result);
}
private:
EquationT m_equation;
Discretization m_discretization;
backend::VolumeOptions m_options;
std::optional<Cache> m_cache;
};
template <operators::VolumeEquation EquationT, discretization::domain::schema::IsSchema SchemaT>
VolumeEquationFixture(
EquationT,
const discretization::backend::mfem::TermBinding<typename EquationT::Test, SchemaT>&,
const discretization::backend::mfem::TermBinding<discretization::blocks::displacement::vector, SchemaT>&,
const ::mfem::GridFunction&,
const mapping::DomainMapper&,
discretization::quadrature::Policy = discretization::quadrature::Policy{{}},
backend::ContractionMode = backend::ContractionMode::automatic
) -> VolumeEquationFixture<EquationT, SchemaT>;
} // namespace serif::tests::equations

View File

@@ -0,0 +1,38 @@
#pragma once
#include "mfem/linalg/densemat.hpp"
#include "mfem/linalg/vector.hpp"
#include "serif/backend/mfem/mapping/domain_mapper.hpp"
namespace serif::tests {
/**
* @brief Differentiate the volume map along one direction through the solver's direction-variation path.
* @note Samples the direction's value and canonical-element gradient at the point and forms the inverse mesh
* Jacobian from the prepared context, as the geometry derivatives do.
*/
[[nodiscard]] inline serif::mapping::MappingStatus evaluate_volume_variation(
const serif::mapping::DomainMapper& mapper,
const serif::mapping::ElementMappingData& element_data,
const serif::mapping::ElementDisplacementData& direction,
::mfem::ElementTransformation& transformation,
const ::mfem::IntegrationPoint& ip,
const serif::mapping::VolumeMappingContext& context,
serif::mapping::DMWorkspace& workspace,
serif::mapping::VolumeMappingVariation& variation,
const serif::mapping::CompactificationPointData* compactification_sample = nullptr
) {
const ::mfem::FiniteElement& element = direction.get_element();
::mfem::Vector shape(element.GetDof());
::mfem::DenseMatrix shape_gradient(element.GetDof(), element.GetDim());
element.CalcShape(ip, shape);
element.CalcDShape(ip, shape_gradient);
::mfem::Vector value(direction.get_dimension());
direction.get_dof_matrix().MultTranspose(shape, value);
::mfem::DenseMatrix gradient(direction.get_dimension(), element.GetDim());
::mfem::MultAtB(direction.get_dof_matrix(), shape_gradient, gradient);
::mfem::DenseMatrix inverse_mesh_jacobian(context.mapping.mapping_jacobian.Height());
::mfem::Mult(context.quadrature.J_element_to_physical_inverse, context.mapping.mapping_jacobian, inverse_mesh_jacobian);
return mapper.evaluate_volume_direction_variation(element_data, direction, transformation, ip, context, workspace, variation,
serif::mapping::DMReferenceDisplacementSample{value, gradient}, compactification_sample, inverse_mesh_jacobian);
}
}

View File

@@ -0,0 +1,20 @@
#pragma once
#include <stroid/stroid.h>
namespace serif::tests::nonconforming {
/** @brief Generate paired static meshes through STROID's public regional refinement controls. */
[[nodiscard]] inline stroid::StroidMesh mesh(int stellar_level = 2, int outer_level = 0, int order = 2) {
const stroid::config::MeshConfig options{
.refinement_levels = stellar_level,
.vacuum_refinement_levels = 0,
.vacuum_outer_refinement_levels = outer_level,
.order = order,
.include_external_domain = true,
.r_core = 0.25,
.r_star = 1.0,
.r_infinity = 4.0,
.optimization_methods = stroid::config::OptimizationMethods{.tmop = false, .smoothstep = true}};
return stroid::GenerateMesh(options);
}
}

View File

@@ -0,0 +1,455 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <charconv>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iterator>
#include <locale>
#include <limits>
#include <map>
#include <span>
#include <sstream>
#include <system_error>
#include <string>
#include <string_view>
#include <vector>
#include "serif/backend/mfem/model/deformation.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/utils/serialize/record.hpp"
namespace serif::tests::solver::archive {
/** @brief Read a complete nonnegative index without accepting signs, suffixes or overflow. */
inline std::size_t index_value(std::string_view text) {
std::size_t value{};
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(),
"Archive index '{}' is not a complete nonnegative integer.", text);
return value;
}
/** @brief Read the study's unquoted CSV cells without interpreting archived physical values as new results. */
inline std::vector<std::string> cells(const std::string& line) {
std::vector<std::string> result;
std::istringstream stream(line);
std::string cell;
while (std::getline(stream, cell, ',')) {
result.push_back(cell);
}
return result;
}
/** @brief Read the authoritative named physical prescriptions and provenance. */
inline std::map<std::string, std::string> metadata(const std::filesystem::path& path) {
std::ifstream input(path / "metadata.txt");
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(input.good(), "Cannot open archive metadata: {}", path.string());
std::map<std::string, std::string> result;
std::string line;
while (std::getline(input, line)) {
const auto separator = line.find('=');
if (separator != std::string::npos) {
const auto [entry, inserted] = result.emplace(line.substr(0, separator), line.substr(separator + 1));
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(inserted,
"Archive metadata repeats physical prescription '{}'.", entry->first);
}
}
return result;
}
/** @brief Read a complete finite coordinate vector without interpreting it as a converged solution. */
inline std::vector<double> read_coefficients(const std::filesystem::path& path, const std::size_t expected_count) {
using namespace utils::errors;
std::ifstream input(path);
std::string count;
input >> count;
serif_ensure<SERiFErrorCode::INVALID_DOF_COUNT>(input && index_value(count) == expected_count,
"Saved coordinates do not match the declared stellar layout: {}", path.string());
std::vector<double> values(expected_count);
for (double& value : values) {
input >> value;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(input && std::isfinite(value),
"Saved coordinates contain a missing or nonfinite physical coefficient: {}", path.string());
}
std::string trailing;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!(input >> trailing) && input.eof(),
"Saved coordinates contain trailing data or could not be read completely: {}", path.string());
return values;
}
/** @brief Atomically replace a complete finite coordinate file; incomplete writes leave the previous checkpoint intact. */
inline void save_coefficients(const std::filesystem::path& path, const std::span<const double> values) {
using namespace utils::errors;
auto temporary = path;
temporary += ".tmp";
std::ofstream output(temporary);
output << values.size() << '\n' << std::setprecision(17);
for (const double value : values) {
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(value), "Cannot save nonfinite physical coordinates.");
output << value << '\n';
}
output.close();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(output.good(), "Cannot write physical coordinates: {}", temporary.string());
std::error_code error;
std::filesystem::rename(temporary, path, error);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!error, "Cannot replace physical coordinates at {}: {}", path.string(), error.message());
}
/** @brief Identify the bounded driver's mesh bytes without adding a second checksum implementation. */
[[nodiscard]] inline std::string mesh_identity(const std::filesystem::path& path) {
using namespace utils::errors;
std::ifstream input(path, std::ios::binary);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(input.good(), "Cannot read checkpoint mesh identity: {}", path.string());
const std::string bytes{std::istreambuf_iterator<char>{input}, std::istreambuf_iterator<char>{}};
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!input.bad() && !bytes.empty(), "Cannot read complete checkpoint mesh: {}", path.string());
return utils::serialize::fingerprint(bytes);
}
/**
* @brief Describe the bounded driver's physical coefficient meanings, including their actual offsets.
* @note This contract is only for StellarResources with its H1 Gauss-Lobatto bases and real harmonics at the supplied
* maximum degree. It is not the library's general restart format. Bump the version if these conventions change.
*/
template <typename Layout>
[[nodiscard]] std::string coordinate_identity(const Layout& layout, std::string_view mesh, int harmonic_degree = 4) {
using Kind = discretization::blocks::RepresentedValueKind;
std::ostringstream identity;
identity.imbue(std::locale::classic());
identity << "coordinates=density_primal_h1_gll_v1;mesh=" << mesh << ";harmonic_degree=" << harmonic_degree
<< ";density_order=" << Density::FunctionalSpace::order << ";potential_order=" << Potential::FunctionalSpace::order
<< ";geometry_order=" << Displacement::FunctionalSpace::order;
const auto field = [&]<typename Term>(std::string_view name) {
identity << ';' << name << '=' << layout.template offset<Kind::value, Term>()
<< ':' << layout.template size<Kind::value, Term>();
};
field.template operator()<Density>("density");
field.template operator()<Potential>("potential");
field.template operator()<Constant>("surface_level");
field.template operator()<Omega>("angular_velocity");
field.template operator()<Shape>("shape");
return identity.str();
}
/** @brief Require exactly one matching preparation identity before importing positional coordinates. */
inline void require_preparation_identity(std::istream& input, std::string_view expected) {
using namespace utils::errors;
const std::string_view prefix = expected.substr(0, expected.find('=') + 1);
std::string line;
int matches = 0;
while (std::getline(input, line)) {
if (line.starts_with(prefix)) {
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(line == expected && ++matches == 1,
"Saved-state {} identity is incompatible or repeated; use a fresh cold solve.", prefix);
}
}
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(input.eof() && matches == 1,
"Saved-state recheck requires the current {} identity; unmarked or legacy coordinates require a fresh cold solve.", prefix);
}
/** @brief Rebuild the production geometry independently of the nonlinear solver's cache for physical measurements. */
template <typename Discretization, typename State>
inline auto deformation_cache(const Discretization& discretization, const State& state) {
namespace deformation = geometry::deformation;
const auto inputs = deformation::backend::mfem::make_deformation_inputs(discretization.deformation.model, discretization.deformation.reference,
state.template get<Shape>());
auto cache = dependencies::make_computation_cache<deformation::DeformationFailure>(
deformation::backend::mfem::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
const auto prepared = cache.try_prepare(inputs);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(prepared.has_value(),
"Cannot reconstruct accepted physical geometry: {}", prepared ? "" : prepared.error().error.message);
return cache;
}
/** @brief Compare the archived physical harmonic basis before treating its coefficients as a new initial guess. */
template <typename Discretization, typename State>
inline double verify_surface_basis(const std::filesystem::path& path, const Discretization& discretization, const State& state) {
auto cache = deformation_cache(discretization, state);
const auto& basis = cache.template get<geometry::deformation::keys::SurfaceMap>()->nodal_basis();
std::ifstream input(path / "surface_basis.csv");
// The J=0.1 archive has no historical basis. The caller must pass its documented sibling basis.
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(input.good(), "A matching archived harmonic basis is required.");
std::string line;
std::size_t index = 0;
double error = 0.0;
while (std::getline(input, line)) {
for (const auto& cell : cells(line)) {
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_DOF_COUNT>(index < basis.size(), "Archived harmonic basis is too large.");
std::size_t consumed{};
const double value = std::stod(cell, &consumed);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(value) && consumed == cell.size(),
"Archived harmonic basis contains a nonfinite or malformed coefficient.");
error = std::max(error, std::abs(value - basis[index++]));
}
}
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(index == basis.size() && error <= 128.0 * std::numeric_limits<double>::epsilon(),
"Archived harmonic ordering or physical normalization differs (maximum error {}).", error);
return error;
}
/** @brief Expand a named reduced field using its actual support map. */
template <typename Term, typename Discretization, typename State>
inline ::mfem::GridFunction field(const Discretization& discretization, const State& state) {
const auto& binding = discretization.template binding<Term>();
const auto values = state.template get<Term>();
::mfem::Vector reduced(static_cast<int>(values.size()));
std::copy(values.begin(), values.end(), reduced.GetData());
::mfem::GridFunction result(&binding.space());
result = binding.scatter(reduced);
return result;
}
/** @brief Reconstruct the production displacement once for independent volume and boundary measurements. */
template <typename Discretization, typename State>
inline ::mfem::GridFunction displacement_field(const Discretization& discretization, const State& state) {
auto cache = deformation_cache(discretization, state);
const auto& values = cache.template get<geometry::deformation::keys::VolumeDisplacement>();
::mfem::Vector nodal(static_cast<int>(values.size()));
std::copy(values.begin(), values.end(), nodal.GetData());
const auto& binding = discretization.template binding<Displacement>();
::mfem::Vector reduced(binding.size());
model::backend::mfem::displacement_projection(binding).action()->Mult(nodal, reduced);
::mfem::GridFunction displacement(&binding.space());
displacement = binding.scatter(reduced);
return displacement;
}
/** @brief Record why a bounded inverse search stopped without accepting an unlocated point. */
struct LocationDiagnostics {
std::string_view stop_reason = "iteration_limit";
mapping::MappingStatus rejected_mapping_status = mapping::MappingStatus::VALID;
int projected_trials = 0;
int backtracks = 0;
};
/**
* @brief Invert one physical element with bounded, residual-decreasing reference steps.
* @note Only search coordinates are projected onto the reference cube. The physical request,
* twelve Newton updates and both final acceptance tolerances are unchanged.
*/
inline bool locate_in_element(mapping::GridFunctionMappingEvaluator& mapping, ::mfem::ElementTransformation& transformation,
const ::mfem::Vector& target, ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context, double& error,
LocationDiagnostics& diagnostics) {
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(transformation.GetGeometryType() == ::mfem::Geometry::CUBE,
"The archived ESTER trace-distance convention requires hexahedral elements.");
diagnostics = {};
error = std::numeric_limits<double>::infinity();
const double tolerance = 2.0e-11 * std::max(1.0, target.Norml2());
const auto project = [&](::mfem::IntegrationPoint& trial) {
const double x = std::clamp(trial.x, 0.0, 1.0);
const double y = std::clamp(trial.y, 0.0, 1.0);
const double z = std::clamp(trial.z, 0.0, 1.0);
if (x != trial.x || y != trial.y || z != trial.z) {
++diagnostics.projected_trials;
}
trial.Set3(x, y, z);
};
project(point);
const auto initial_status = mapping.evaluate_point(transformation, point, context);
if (initial_status != mapping::MappingStatus::VALID) {
diagnostics.stop_reason = "invalid_initial_mapping";
diagnostics.rejected_mapping_status = initial_status;
return false;
}
::mfem::Vector residual(context.physical_position);
residual -= target;
error = residual.Norml2();
for (int iteration = 0; iteration < 12 && error > tolerance; ++iteration) {
transformation.SetIntPoint(&point);
::mfem::DenseMatrix jacobian(3);
::mfem::Mult(context.mapping_jacobian, transformation.Jacobian(), jacobian);
const double determinant = jacobian.Det();
if (!std::isfinite(determinant) || determinant == 0.0) {
diagnostics.stop_reason = "singular_jacobian";
return false;
}
::mfem::Vector correction(3);
::mfem::DenseMatrixInverse inverse(jacobian);
inverse.Mult(residual, correction);
if (!std::isfinite(correction[0]) || !std::isfinite(correction[1]) || !std::isfinite(correction[2])) {
diagnostics.stop_reason = "nonfinite_correction";
return false;
}
// An unrestricted Newton step can cross the Kelvin map's valid coordinate range.
// Keep the last valid candidate until a projected/backtracked trial reduces its error.
bool accepted = false;
double fraction = 1.0;
for (int trial_index = 0; trial_index < 16; ++trial_index, fraction *= 0.5) {
::mfem::IntegrationPoint trial;
trial.Set3(point.x - fraction * correction[0], point.y - fraction * correction[1],
point.z - fraction * correction[2]);
project(trial);
mapping::MappingPointContext trial_context;
const auto status = mapping.evaluate_point(transformation, trial, trial_context);
if (status == mapping::MappingStatus::VALID) {
::mfem::Vector trial_residual(trial_context.physical_position);
trial_residual -= target;
const double trial_error = trial_residual.Norml2();
if (std::isfinite(trial_error) && trial_error < error) {
point = trial;
context = trial_context;
residual = trial_residual;
error = trial_error;
accepted = true;
break;
}
} else {
diagnostics.rejected_mapping_status = status;
}
++diagnostics.backtracks;
}
if (!accepted) {
diagnostics.stop_reason = "no_decreasing_step";
return false;
}
}
const bool located = error <= tolerance &&
::mfem::Geometry::CheckPoint(transformation.GetGeometryType(), point, 2.0e-9);
diagnostics.stop_reason = located ? "converged" : "iteration_limit";
return located;
}
/**
* @brief Independently sample the new SERiF fields at the archived physical requests.
* @note Only point IDs, coordinates and reference-element locator hints are read from old samples.
* Every old field value is ignored. Newton tries the hint, its face neighbors and finally every remaining physical element;
* failed or ambiguous relocations are reported, never silently replaced by archived measurements.
* Enthalpy is an EOS diagnostic for this zero-pressure polytrope comparison, not an independently solved field.
* The only gravitational acceleration reported is the mapped gradient of the solved scalar potential.
*/
template <typename Discretization, typename State>
inline void sample(const std::filesystem::path& hints, const std::filesystem::path& output, const Discretization& discretization,
const State& state, double n, double K) {
auto displacement = displacement_field(discretization, state);
const auto& binding = discretization.template binding<Displacement>();
mapping::GridFunctionMappingEvaluator mapping(discretization.mapper, displacement, discretization.compactification);
auto rho = field<Density>(discretization, state);
auto phi = field<Potential>(discretization, state);
std::ifstream input(hints);
std::ofstream stream(output);
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(input.good() && stream.good(), "Cannot open physical sampler input or output.");
std::string line;
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
static_cast<bool>(std::getline(input, line)), "Physical sample requests have no header.");
const auto header = cells(line);
std::map<std::string, std::size_t> columns;
for (std::size_t index = 0; index < header.size(); ++index) {
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
columns.emplace(header[index], index).second, "Physical sample requests repeat a column.");
}
stream << "id,x,y,z,located,stellar_material,boundary_trace_possible,location_error,rho,h_eos,pressure_rho,phi,phi_gradient_x,phi_gradient_y,phi_gradient_z,bernoulli_defect,mapped_x,mapped_y,mapped_z,element,xi,eta,zeta,reference_face_distance,location_tolerance,locator_element_attempts,locator_best_element,locator_best_error,locator_best_stop,locator_best_rejected_mapping_status,locator_projected_trials,locator_backtracks\n" << std::setprecision(17);
const double omega = state.template get<Omega>()[0];
const double constant = state.template get<Constant>()[0];
while (std::getline(input, line)) {
const auto row = cells(line);
const auto number = [&](const char* name) { return std::stod(row.at(columns.at(name))); };
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
row.size() == header.size(), "Physical sample request has an incompatible column count.");
::mfem::Vector target(3);
target[0] = number("x"); target[1] = number("y"); target[2] = number("z");
int element = static_cast<int>(number("element"));
::mfem::IntegrationPoint point;
point.Set3(number("xi"), number("eta"), number("zeta"));
bool located = number("located") == 1.0 && element >= 0 && element < binding.mesh().GetNE();
mapping::MappingPointContext context;
double error = std::numeric_limits<double>::infinity();
int attempts = 0, projected_trials = 0, backtracks = 0, best_element = -1;
double best_error = std::numeric_limits<double>::infinity();
::mfem::IntegrationPoint best_point = point;
LocationDiagnostics best_diagnostics;
const auto try_element = [&](const int candidate) {
++attempts;
LocationDiagnostics diagnostics;
const bool found = locate_in_element(mapping, *binding.mesh().GetElementTransformation(candidate),
target, point, context, error, diagnostics);
projected_trials += diagnostics.projected_trials;
backtracks += diagnostics.backtracks;
if (best_element < 0 || error < best_error) {
best_element = candidate;
best_error = error;
best_point = point;
best_diagnostics = diagnostics;
}
return found;
};
if (located) {
located = try_element(element);
if (!located) {
// A Newton correction can move an unchanged physical request across an internal face.
// The archive's element is only a hint; evaluate the current field on the actual side.
::mfem::Array<int> neighbors;
binding.mesh().ElementToElementTable().GetRow(element, neighbors);
for (const int neighbor : neighbors) {
if (neighbor == element) {
continue;
}
point.Set3(0.5, 0.5, 0.5);
if (try_element(neighbor)) {
element = neighbor;
located = true;
break;
}
}
}
}
if (!located) {
// Requests near edges or vertices can cross more than one face after deformation.
// Search the same mesh without changing coordinates or either acceptance tolerance.
for (int candidate = 0; candidate < binding.mesh().GetNE(); ++candidate) {
point.Set3(0.5, 0.5, 0.5);
if (try_element(candidate)) {
element = candidate;
located = true;
break;
}
}
}
if (!located) {
// Report the closest valid search candidate, not the final unrelated element.
// Physical fields still remain undefined and element remains -1 in the output.
point = best_point;
error = best_error;
}
const bool material = located && discretization.template binding<Density>().supports(element);
const double nan = std::numeric_limits<double>::quiet_NaN();
const double face_distance = located ? std::min({point.x, 1.0 - point.x, point.y, 1.0 - point.y, point.z, 1.0 - point.z}) : nan;
const bool boundary_trace = located && face_distance <= 1.0e-8;
const double density = material ? rho.GetValue(element, point) : nan;
// Retain signed density in the output. An invalid EOS state remains visible, with undefined diagnostics.
const double enthalpy = material && density >= 0.0 ? (n + 1.0) * K * std::pow(density, 1.0 / n) : nan;
const double pressure = material && density >= 0.0 ? K * std::pow(density, 1.0 + 1.0 / n) : nan;
const double potential = located ? phi.GetValue(element, point) : nan;
::mfem::Vector physical_gradient(3);
physical_gradient = nan;
if (located) {
::mfem::Vector reference_gradient(3);
auto& transformation = *binding.mesh().GetElementTransformation(element);
transformation.SetIntPoint(&point);
phi.GetGradient(transformation, reference_gradient);
context.inverse_mapping_jacobian.MultTranspose(reference_gradient, physical_gradient);
}
double cylindrical_radius_squared = nan;
if (located) {
const auto& position = context.physical_position;
cylindrical_radius_squared = position[0] * position[0] + position[1] * position[1];
}
const double defect = enthalpy + potential - 0.5 * omega * omega * cylindrical_radius_squared - constant;
stream << row.at(columns.at("id")) << ',' << target[0] << ',' << target[1] << ',' << target[2] << ',' << located << ',' << material << ','
<< boundary_trace << ',' << error << ',' << density << ',' << enthalpy << ',' << pressure << ',' << potential << ','
<< physical_gradient[0] << ',' << physical_gradient[1] << ',' << physical_gradient[2] << ',' << defect;
for (int axis = 0; axis < 3; ++axis) {
stream << ',' << (located ? context.physical_position[axis] : nan);
}
stream << ',' << (located ? element : -1) << ',' << point.x << ',' << point.y << ',' << point.z << ',' << face_distance << ','
<< 2.0e-11 * std::max(1.0, target.Norml2()) << ',' << attempts << ',' << best_element << ','
<< best_error << ',' << best_diagnostics.stop_reason << ','
<< static_cast<int>(best_diagnostics.rejected_mapping_status) << ',' << projected_trials << ',' << backtracks << '\n';
}
stream.close();
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
input.eof() && stream.good(), "Physical sampling did not finish reading or writing every requested row.");
}
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include "serif/types.hpp"
#include "serif/backend/mfem/solver/linear/solve.hpp"
namespace serif::tests::solver {
/** @brief Share native linear execution across algebraic tests of the backend-independent driver. */
struct NativeProblem {
using Vector = serif::Vector;
/** @brief Exercise the real checked solver with each test's independently constructed operators. */
[[nodiscard]] auto solve_linear(const auto& prepared, const Vector& rhs, Vector& correction, const auto& options) {
return serif::solver::linear::backend::mfem::solve(options.linear_solver, prepared->system()->jacobian(),
prepared->preconditioner(), prepared->contract(), rhs, correction, options.method.linear_solve);
}
};
}

View File

@@ -0,0 +1,171 @@
#pragma once
#include <numbers>
#include "serif/tests/solver/archive.hpp"
namespace serif::tests::solver::archive {
/**
* @brief Measure solved constants and the actual curved boundary independently of the nonlinear solver.
* @note Archived records supply only boundary IDs and reference-rule positions. Coordinates, normals,
* area and enclosed volume are rebuilt from the accepted state through the production mapping.
* Surface volume uses the divergence theorem; it is not a density integral or a positivity certificate.
*/
template <typename Discretization, typename State>
void sample_summary(const std::filesystem::path& output, const Discretization& discretization, const State& state,
const std::filesystem::path& hints) {
using namespace utils::errors;
auto displacement = displacement_field(discretization, state);
mapping::GridFunctionMappingEvaluator mapping(discretization.mapper, displacement, discretization.compactification);
const auto& binding = discretization.template binding<Displacement>();
const auto& material = discretization.template binding<Density>();
auto& mesh = binding.mesh();
const auto& space = binding.space();
std::ifstream input(hints);
std::ofstream surface(output / "surface.csv");
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(input.good() && surface.good(), "Cannot open archived boundary requests or fresh boundary output.");
std::string line;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(static_cast<bool>(std::getline(input, line)), "Archived boundary requests have no header.");
const auto header = cells(line);
std::map<std::string, std::size_t> columns;
for (std::size_t index = 0; index < header.size(); ++index) {
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(columns.emplace(header[index], index).second, "Archived boundary requests repeat a column.");
}
surface << "kind,boundary_element,sample,reference_u,reference_v,reference_x,reference_y,reference_z,x,y,z,theta,phi,radius,normal_x,normal_y,normal_z,area_weight\n" << std::setprecision(17);
std::map<std::pair<int, std::string>, std::size_t> counts;
std::array<double, 2> area{}, volume{};
while (std::getline(input, line)) {
const auto row = cells(line);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(row.size() == header.size(), "Archived boundary request has an incompatible column count.");
const std::string& kind = row.at(columns.at("kind"));
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(kind == "node" || kind == "q14" || kind == "q18", "Unknown archived boundary sampling rule '{}'.", kind);
const std::size_t boundary_index = index_value(row.at(columns.at("boundary_element")));
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(boundary_index < static_cast<std::size_t>(space.GetNBE()), "Archived boundary element is outside the mesh.");
const int boundary = static_cast<int>(boundary_index);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(space.GetBdrAttribute(boundary) == discretization.deformation.reference.surface_attribute,
"Archived boundary request does not refer to the stellar surface.");
const auto& element = *space.GetBE(boundary);
const bool quadrature = kind != "node";
const int order = kind == "q14" ? 14 : 18;
const auto& rule = quadrature ? ::mfem::IntRules.Get(element.GetGeomType(), order) : element.GetNodes();
const std::size_t sample_index = index_value(row.at(columns.at("sample")));
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(sample_index < static_cast<std::size_t>(rule.GetNPoints()) && sample_index == counts[{boundary, kind}]++,
"Archived boundary rule samples are missing, duplicated or out of order.");
const auto& point = rule.IntPoint(static_cast<int>(sample_index));
const double u = std::stod(row.at(columns.at("reference_u")));
const double v = std::stod(row.at(columns.at("reference_v")));
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(u) && std::isfinite(v) &&
std::abs(point.x - u) <= 64.0 * std::numeric_limits<double>::epsilon() && std::abs(point.y - v) <= 64.0 * std::numeric_limits<double>::epsilon(),
"Archived boundary rule positions do not match the current finite-element rule.");
int face = 0, orientation = 0;
mesh.GetBdrElementFace(boundary, &face, &orientation);
auto* transformation = mesh.GetFaceElementTransformations(face);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(transformation != nullptr, "Cannot reconstruct an archived stellar interface.");
const bool first_is_material = transformation->Elem1No >= 0 && material.supports(transformation->Elem1No);
const bool second_is_material = transformation->Elem2No >= 0 && material.supports(transformation->Elem2No);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(first_is_material != second_is_material, "Archived stellar interface must have exactly one material side.");
const auto side = first_is_material ? mapping::FaceElementSide::ELEMENT_1 : mapping::FaceElementSide::ELEMENT_2;
auto face_point = ::mfem::Mesh::TransformBdrElementToFace(element.GetGeomType(), orientation, point);
face_point.weight = quadrature ? point.weight : 1.0;
mapping::FaceMappingContext context;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(mapping.evaluate_face(*transformation, side, face_point, context) == mapping::MappingStatus::VALID,
"The accepted stellar boundary has an invalid physical mapping.");
const auto& position = context.mapping.physical_position;
const double radius = position.Norml2();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(radius) && radius > 0.0 && !context.mapping.compactified,
"The material boundary has an invalid radius or unexpectedly uses vacuum compactification.");
::mfem::Vector normal(context.quadrature.normal);
if (normal * position < 0.0) {
normal *= -1.0;
}
const double weight = quadrature ? context.physical_surface_weight : std::numeric_limits<double>::quiet_NaN();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!quadrature || (std::isfinite(weight) && weight > 0.0), "The accepted boundary has a nonpositive physical area weight.");
const double theta = std::acos(std::clamp(position[2] / radius, -1.0, 1.0));
double phi = std::atan2(position[1], position[0]);
if (phi < 0.0) {
phi += 2.0 * std::numbers::pi;
}
surface << kind << ',' << boundary << ',' << sample_index << ',' << point.x << ',' << point.y;
for (int axis = 0; axis < 3; ++axis) {
surface << ',' << context.mapping.reference_position[axis];
}
for (int axis = 0; axis < 3; ++axis) {
surface << ',' << position[axis];
}
surface << ',' << theta << ',' << phi << ',' << radius;
for (int axis = 0; axis < 3; ++axis) {
surface << ',' << normal[axis];
}
surface << ',' << weight << '\n';
if (quadrature) {
const int index = order == 14 ? 0 : 1;
area[index] += weight;
volume[index] += weight * (normal * position) / 3.0;
}
}
std::size_t faces = 0;
for (int boundary = 0; boundary < space.GetNBE(); ++boundary) {
if (space.GetBdrAttribute(boundary) == discretization.deformation.reference.surface_attribute) {
++faces;
const auto& element = *space.GetBE(boundary);
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(counts[{boundary, "node"}] == static_cast<std::size_t>(element.GetNodes().GetNPoints()) &&
counts[{boundary, "q14"}] == static_cast<std::size_t>(::mfem::IntRules.Get(element.GetGeomType(), 14).GetNPoints()) &&
counts[{boundary, "q18"}] == static_cast<std::size_t>(::mfem::IntRules.Get(element.GetGeomType(), 18).GetNPoints()),
"Archived requests omit part of the current stellar boundary.");
}
}
const double omega = state.template get<Omega>()[0];
const double constant = state.template get<Constant>()[0];
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(input.eof() && surface.good() && faces > 0 && std::isfinite(omega) && std::isfinite(constant),
"Physical summary has incomplete boundary data or nonfinite solved constants.");
// Integrate the signed FE density itself, without clipping or replacing it by the EOS density.
// These are the original study's q14/q18 physical volume measurements.
const auto density = field<Density>(discretization, state);
std::ofstream bulk(output / "bulk.csv");
bulk << "quadrature,metric,value\n" << std::setprecision(17);
for (const int order : {14, 18}) {
double mass = 0.0, inertia = 0.0, material_volume = 0.0;
for (int element = 0; element < mesh.GetNE(); ++element) {
if (!material.supports(element)) {
continue;
}
auto& transformation = *mesh.GetElementTransformation(element);
const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), order);
for (int index = 0; index < rule.GetNPoints(); ++index) {
const auto& point = rule.IntPoint(index);
mapping::MappingPointContext context;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(mapping.evaluate_point(transformation, point, context) == mapping::MappingStatus::VALID,
"The accepted stellar material has an invalid physical mapping.");
transformation.SetIntPoint(&point);
const double weight = point.weight * transformation.Weight() * context.mapping_determinate;
const double rho = density.GetValue(element, point);
const auto& position = context.physical_position;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(weight) && weight > 0.0 && std::isfinite(rho),
"Physical bulk integration has nonfinite density or nonpositive volume weight.");
material_volume += weight;
mass += weight * rho;
inertia += weight * rho * (position[0] * position[0] + position[1] * position[1]);
}
}
bulk << order << ",mass," << mass << '\n'
<< order << ",moment_of_inertia_z," << inertia << '\n'
<< order << ",angular_momentum_z," << omega * inertia << '\n'
<< order << ",volume," << material_volume << '\n'
<< order << ",volume_radius," << std::cbrt(3.0 * material_volume / (4.0 * std::numbers::pi)) << '\n';
}
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(bulk.good(), "Cannot write the independently integrated physical bulk quantities.");
std::ofstream summary(output / "physical_summary.json");
summary << std::setprecision(17) << "{\n \"Omega\": " << omega << ",\n \"Us\": " << constant;
for (int index = 0; index < 2; ++index) {
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(volume[index]) && volume[index] > 0.0,
"The accepted stellar boundary encloses a nonpositive or nonfinite measured volume.");
const int order = index == 0 ? 14 : 18;
summary << ",\n \"surface_area_q" << order << "\": " << area[index]
<< ",\n \"surface_volume_q" << order << "\": " << volume[index]
<< ",\n \"surface_volume_radius_q" << order << "\": " << std::cbrt(3.0 * volume[index] / (4.0 * std::numbers::pi));
}
summary << "\n}\n";
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(summary.good(), "Cannot write the independent physical summary.");
}
}

View File

@@ -0,0 +1,88 @@
#pragma once
#include <memory>
#include <numbers>
#include <utility>
#include <stroid/stroid.h>
#include "mfem/fem/fe_coll.hpp"
#include "mfem/fem/fespace.hpp"
#include "serif/backend/mfem/discretization/term_binding.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/backend/mfem/mapping/compactification/kelvin.hpp"
#include "serif/backend/mfem/model/discretization.hpp"
#include "../../../../sandbox/polytropic_model.hpp"
namespace serif::tests::solver {
namespace blocks = discretization::blocks;
namespace dependencies = utils::dependencies;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using Constant = blocks::barotropic_constant::mass_normalization;
using Omega = blocks::rotation::angular_velocity;
using Shape = blocks::surface_deformation::coefficients;
using Displacement = blocks::displacement::vector;
/** @brief Own the scalar stellar fields and the shared scalar/vector geometry resources. */
struct StellarResources final {
stroid::StroidMesh mesh;
::mfem::H1_FECollection density_collection{Density::FunctionalSpace::order, 3};
::mfem::H1_FECollection potential_collection{Potential::FunctionalSpace::order, 3};
::mfem::H1_FECollection geometry_collection{Displacement::FunctionalSpace::order, 3};
::mfem::FiniteElementSpace density_space{mesh.mesh.get(), &density_collection};
::mfem::FiniteElementSpace geometry_space{mesh.mesh.get(), &geometry_collection};
::mfem::FiniteElementSpace potential_space{mesh.mesh.get(), &potential_collection};
::mfem::FiniteElementSpace displacement_space{mesh.mesh.get(), &geometry_collection, 3, ::mfem::Ordering::byVDIM};
::mfem::FiniteElementSpace reference_displacement{mesh.mesh.get(), &geometry_collection, 3, ::mfem::Ordering::byNODES};
discretization::backend::mfem::TermBinding<Density, Schema> density{density_space};
discretization::backend::mfem::TermBinding<Potential, Schema> potential{potential_space};
discretization::backend::mfem::TermBinding<Displacement, Schema> displacement{displacement_space};
discretization::backend::mfem::TermBinding<Constant, Schema> constant{*mesh.mesh};
discretization::backend::mfem::TermBinding<Omega, Schema> omega{*mesh.mesh};
mapping::DomainMapper mapper;
/** @brief Keep mesh and finite-element addresses stable for every bound physical system. */
explicit StellarResources(stroid::StroidMesh generated, double radius = 1.0, double infinity_radius = 4.0) :
mesh(std::move(generated)), mapper({.dimension = 3, .vacuum_element_attribute_ID = Schema::domain_id<discretization::domain::VacuumDomain>()},
std::make_unique<mapping::compactification::KelvinCompactification>(utils::user_options::KelvinCompactificationOptions{
.r_star_ref = radius, .r_inf_ref = infinity_radius})) {
}
};
/**
* @brief Generate a small spherical star with the same exterior and field conventions as the archived study.
* @note P6 geometry resolves this coarse mesh's reference surface within the radial seed's default tolerance.
*/
[[nodiscard]] inline std::shared_ptr<StellarResources> make_resources(double radius = 1.0, int refinement = 0, int geometry_order = 6) {
stroid::config::MeshConfig options;
options.refinement_levels = refinement;
options.order = geometry_order;
options.include_external_domain = true;
options.r_core = 0.25 * radius;
options.r_star = radius;
options.r_infinity = 4.0 * radius;
options.optimization_methods = stroid::config::OptimizationMethods{false, true};
return std::make_shared<StellarResources>(stroid::GenerateMesh(options), radius, 4.0 * radius);
}
/** @brief Describe density mechanics, primal gravity and the isobar shape at the prescribed mass and angular momentum. */
[[nodiscard]] inline auto make_model(double n = 1.0, double K = 2.0 / std::numbers::pi, double mass = 1.0, double angular_momentum = 0.0, double G = 1.0, double surface_pressure = 0.0) {
// Exercise the same physical declaration as the examples, including its deferred compatibility check.
return examples::make_polytropic_model(n, K, mass, angular_momentum, G, surface_pressure);
}
/** @brief Bind resources once while keeping harmonic shape choices separate from the physical equation list. */
[[nodiscard]] inline auto make_discretization(const std::shared_ptr<StellarResources>& resources, int surface_degree = 4) {
const auto bindings = dependencies::make_dependency_context(
dependencies::borrow<Density>(resources->density), dependencies::borrow<Potential>(resources->potential),
dependencies::borrow<Constant>(resources->constant), dependencies::borrow<Omega>(resources->omega),
dependencies::borrow<Displacement>(resources->displacement));
const auto reference = geometry::deformation::backend::mfem::deformation_discretization(
resources->geometry_space, resources->reference_displacement, *resources->mesh.reference_mesh);
const geometry::deformation::DomainDeformation<geometry::deformation::SphericalHarmonicSurface> deformation{
.surface = {.maximum_degree = surface_degree}, .stellar_interior = {}, .vacuum = {}};
return model::backend::mfem::with_deformation(model::backend::mfem::make_stellar_discretization(resources, bindings,
*resources->mesh.exterior_coordinate->values, resources->mapper), deformation, reference);
}
}

View File

@@ -0,0 +1,331 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <concepts>
#include <expected>
#include <exception>
#include <optional>
#include <string>
#include <vector>
#include "serif/backend/mfem/discretization/dof_selection.hpp"
#include "serif/backend/mfem/mapping/transformations.hpp"
#include "serif/backend/mfem/shared/parallel.hpp"
#include "serif/eos/evaluation.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/equations/rigid_rotation.hpp"
#include "serif/tests/solver/archive.hpp"
#include "serif/utils/misc/compensated_sum.hpp"
namespace serif::tests::solver {
/** @brief Independent physical integrals of one represented vacuum star at a specified quadrature order. */
struct VacuumVirialSample {
int order{};
double mass{}, volume{}, kinetic_energy{}, pressure_integral{}, potential_energy{}, gravity_work{};
/** @brief Evaluate the full energy virial using the infinity-zero potential gauge. */
[[nodiscard]] double energy_virial() const {
return (2.0 * kinetic_energy + potential_energy + 3.0 * pressure_integral) / std::abs(potential_energy);
}
/** @brief Keep the center-relative mechanical virial separate from gravitational energy consistency. */
[[nodiscard]] double force_virial() const {
return (2.0 * kinetic_energy + gravity_work + 3.0 * pressure_integral) / std::abs(potential_energy);
}
};
/** @brief Return physical qualification separately from the nonlinear solver's algebraic convergence. */
struct VacuumVirialAssessment {
std::array<VacuumVirialSample, 4> samples;
equations::RigidRotation::Vector center;
equations::RigidRotation::Vector angular_velocity;
int boundary_dofs{};
double quadrature_allowance{};
/** @brief Require the full virial and a separately bounded empirical integration allowance. */
[[nodiscard]] bool passes(const double tolerance = 1.0e-6, const double integration_tolerance = 1.0e-7) const {
const double error = std::abs(samples.back().energy_virial());
return std::isfinite(tolerance) && tolerance > 0.0 && std::isfinite(integration_tolerance) && integration_tolerance >= 0.0 &&
std::isfinite(error) && std::isfinite(quadrature_allowance) && quadrature_allowance >= 0.0 &&
quadrature_allowance <= integration_tolerance && error + quadrature_allowance <= tolerance;
}
};
namespace detail {
/** @brief One quadrature point's contributions, in the order measure_vacuum_virial adds them. */
struct VirialPointTerms {
double mass{}, volume{}, kinetic{}, pressure{}, binding{}, gravity_work{};
};
/** @brief One element's point terms, or why its first rejected point was rejected. */
struct ElementVirialTerms {
std::vector<VirialPointTerms> points;
std::string failure;
std::exception_ptr error;
};
/**
* @brief Integrate the vacuum virial with map_point(worker, transformation, point, context) mapping each point.
* @note Elements may be sampled concurrently (when concurrent is true, map_point must then be safe per worker),
* but every sum is added serially in element and point order, and the first rejected point in that order
* decides the error, so the result does not depend on the thread count.
*/
template <typename Binding, eos::models::EOSModel EOS, typename MapPoint>
[[nodiscard]] std::expected<VacuumVirialAssessment, std::string> measure_vacuum_virial(
const Binding& density_binding, const ::mfem::GridFunction& density, const ::mfem::GridFunction& potential,
const EOS& equation_of_state, const equations::RigidRotation& rotation, const bool concurrent, MapPoint&& map_point) {
using DensityTerm = discretization::blocks::density::mass;
static_assert(std::same_as<typename Binding::Term, DensityTerm>);
if (density.FESpace() != &density_binding.space() || potential.FESpace() == nullptr || potential.FESpace()->GetMesh() != &density_binding.mesh()) {
return std::unexpected("Vacuum virial fields must share their declared physical mesh and density binding.");
}
bool has_surface = false;
for (int face = 0; face < density_binding.mesh().GetNBE(); ++face) {
has_surface = has_surface || Binding::Schema::template boundary_id_matches<discretization::domain::StellarSurfaceBoundary>(density_binding.mesh().GetBdrAttribute(face));
}
if (!has_surface) { return std::unexpected("Vacuum virial requires a represented stellar-surface boundary."); }
const auto boundary = discretization::backend::mfem::select_boundary_dofs<discretization::domain::StellarSurfaceBoundary>(density_binding);
const auto reduced_density = density_binding.gather(density);
for (const int dof : boundary.reduced_dofs()) {
if (!std::isfinite(reduced_density[dof]) || reduced_density[dof] != 0.0) {
return std::unexpected("Vacuum virial requires an exactly zero complete H1 density trace.");
}
}
VacuumVirialAssessment assessment{.center = rotation.axis_origin(), .angular_velocity = rotation.angular_velocity_vector(), .boundary_dofs = boundary.size()};
constexpr std::array orders{14, 18, 24, 31};
std::vector<int> elements;
for (int element = 0; element < density_binding.mesh().GetNE(); ++element) {
if (density_binding.supports(element)) { elements.push_back(element); }
}
std::vector<ElementVirialTerms> element_terms(elements.size());
try {
const double surface_pressure = eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{0.0}).value();
if (!std::isfinite(surface_pressure) || surface_pressure != 0.0) {
return std::unexpected("Vacuum virial requires EOS pressure P(0)=0; finite-pressure surfaces are not supported.");
}
for (std::size_t quadrature = 0; quadrature < orders.size(); ++quadrature) {
// Sample: each element owns its terms and its own MFEM transformation.
const auto sample_element = [&](const int index, const int worker) {
ElementVirialTerms& terms = element_terms[static_cast<std::size_t>(index)];
terms = {};
try {
const int element = elements[static_cast<std::size_t>(index)];
::mfem::IsoparametricTransformation transformation;
utils::parallel::element_transformation(density_binding.mesh(), element, transformation);
::mfem::Vector reference_gradient(3), physical_gradient(3);
const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), orders[quadrature]);
// The solver's density: its reconstruction (SurfacePower t^n) where the binding has one, else the plain polynomial.
const auto* reconstruction = density_binding.reconstruction_element(element);
const auto& density_element = *density_binding.space().GetFE(element);
::mfem::Vector nodal_density, reconstructed_shape;
::mfem::DenseMatrix reconstructed_derivatives;
if (reconstruction != nullptr) {
density.GetElementDofValues(element, nodal_density);
reconstructed_shape.SetSize(density_element.GetDof());
reconstructed_derivatives.SetSize(density_element.GetDof(), 3);
}
const auto density_at = [&](const ::mfem::IntegrationPoint& point) {
if (reconstruction == nullptr) { return density.GetValue(element, point); }
reconstruction->sample(density_element, point, reconstructed_shape, reconstructed_derivatives);
return reconstructed_shape * nodal_density;
};
terms.points.reserve(static_cast<std::size_t>(rule.GetNPoints()));
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
const auto& point = rule.IntPoint(sample);
mapping::MappingPointContext context;
if (map_point(worker, transformation, point, context) != mapping::MappingStatus::VALID || context.compactified) {
terms.failure = "Vacuum virial encountered an invalid or compactified material map.";
return;
}
transformation.SetIntPoint(&point);
const double weight = point.weight * transformation.Weight() * context.mapping_determinate;
const double rho = density_at(point), phi = potential.GetValue(element, point);
if (!std::isfinite(weight) || weight <= 0.0 || !std::isfinite(rho) || rho < 0.0 || !std::isfinite(phi)) {
terms.failure = "Vacuum virial encountered negative/nonfinite density, nonfinite potential, or invalid volume weight.";
return;
}
const double thermodynamic_pressure = eos::evaluate<dimensions::Pressure>(equation_of_state, dimensions::DensityValue{rho}).value();
if (!std::isfinite(thermodynamic_pressure) || thermodynamic_pressure < 0.0) {
terms.failure = "Vacuum virial encountered negative or nonfinite EOS pressure.";
return;
}
potential.GetGradient(transformation, reference_gradient);
mapping::map_reference_gradient_to_physical(context, reference_gradient, physical_gradient);
const equations::RigidRotation::Vector position{
context.physical_position[0], context.physical_position[1], context.physical_position[2]};
double dilation = 0.0;
for (int axis = 0; axis < 3; ++axis) {
dilation += (position[axis] - assessment.center[axis]) * physical_gradient[axis];
}
const double rotational_potential = rotation.centrifugal_potential(position);
if (!std::isfinite(dilation) || !std::isfinite(rotational_potential)) {
terms.failure = "Vacuum virial encountered nonfinite physical gravity or rotation.";
return;
}
terms.points.push_back({.mass = weight * rho, .volume = weight, .kinetic = weight * rho * rotational_potential,
.pressure = weight * thermodynamic_pressure, .binding = 0.5 * weight * rho * phi, .gravity_work = -weight * rho * dilation});
}
} catch (...) {
terms.error = std::current_exception();
}
};
if (concurrent) {
utils::parallel::for_each_index(static_cast<int>(elements.size()), sample_element);
} else {
for (int index = 0; index < static_cast<int>(elements.size()); ++index) { sample_element(index, 0); }
}
// Sum: element and point order, stopping at the first rejected point.
utils::CompensatedSum mass, volume, kinetic, pressure, binding_energy, gravity_work;
for (const ElementVirialTerms& terms : element_terms) {
for (const VirialPointTerms& point : terms.points) {
mass.add(point.mass);
volume.add(point.volume);
kinetic.add(point.kinetic);
pressure.add(point.pressure);
binding_energy.add(point.binding);
gravity_work.add(point.gravity_work);
}
if (terms.error) { std::rethrow_exception(terms.error); }
if (!terms.failure.empty()) { return std::unexpected(terms.failure); }
}
assessment.samples[quadrature] = {.order = orders[quadrature], .mass = mass.value(), .volume = volume.value(),
.kinetic_energy = kinetic.value(), .pressure_integral = pressure.value(),
.potential_energy = binding_energy.value(), .gravity_work = gravity_work.value()};
const auto& current = assessment.samples[quadrature];
if (!std::isfinite(current.mass) || current.mass <= 0.0 || !std::isfinite(current.volume) || current.volume <= 0.0 ||
!std::isfinite(current.potential_energy) || current.potential_energy >= 0.0 ||
!std::isfinite(current.kinetic_energy) || !std::isfinite(current.pressure_integral) || !std::isfinite(current.gravity_work)) {
return std::unexpected("Vacuum virial requires finite integrals, positive mass/volume and negative nonzero binding energy.");
}
}
} catch (const eos::EOSEvaluationError& error) {
return std::unexpected(std::string{"EOS rejected a vacuum virial sample: "} + error.what());
}
// Consecutive quadrature changes are convergence evidence, not a rigorous error bound.
for (std::size_t index = assessment.samples.size() - 2; index < assessment.samples.size(); ++index) {
const auto& current = assessment.samples[index];
const auto& previous = assessment.samples[index - 1];
const double change = (2.0 * std::abs(current.kinetic_energy - previous.kinetic_energy) +
std::abs(current.potential_energy - previous.potential_energy) +
3.0 * std::abs(current.pressure_integral - previous.pressure_integral)) / std::abs(current.potential_energy);
assessment.quadrature_allowance = std::max(assessment.quadrature_allowance, change);
}
if (!std::isfinite(assessment.quadrature_allowance)) {
return std::unexpected("Vacuum virial quadrature comparison overflowed.");
}
return assessment;
}
/**
* @brief GridFunctionMappingEvaluator's element mapping, one per worker.
* @note Gathers element DOFs with a local DofTransformation, so concurrent workers never write the spaces' shared state.
*/
class WorkerMapping {
public:
WorkerMapping(const mapping::DomainMapper& mapper, const ::mfem::GridFunction& displacement, const ::mfem::GridFunction& compactification)
: m_mapper(mapper), m_displacement(displacement), m_compactification(compactification), m_workspace(mapper.get_dimension()) {}
[[nodiscard]] mapping::MappingStatus evaluate_point(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point,
mapping::MappingPointContext& context) {
load_element(transformation.ElementNo);
const mapping::ElementMappingData data{.displacement = *m_displacement_data, .compactification = *m_compactification_data};
return m_mapper.evaluate_point(data, transformation, point, m_workspace, context);
}
private:
void load_element(const int element) {
if (element == m_element) { return; }
const ::mfem::FiniteElementSpace& displacement_space = *m_displacement.FESpace();
const ::mfem::FiniteElementSpace& compactification_space = *m_compactification.FESpace();
::mfem::Array<int> displacement_dofs, compactification_dofs;
::mfem::DofTransformation displacement_transform, compactification_transform;
displacement_space.GetElementVDofs(element, displacement_dofs, displacement_transform);
compactification_space.GetElementDofs(element, compactification_dofs, compactification_transform);
m_displacement.GetSubVector(displacement_dofs, m_element_displacement);
m_compactification.GetSubVector(compactification_dofs, m_element_compactification);
if (displacement_transform.GetDofTransformation() != nullptr) {
displacement_transform.InvTransformPrimal(m_element_displacement);
}
m_displacement_data.emplace(mapping::element_displacement_data_from_element_vdofs(*displacement_space.GetFE(element), m_element_displacement));
m_compactification_data.emplace(*compactification_space.GetFE(element), m_element_compactification);
m_element = element;
}
const mapping::DomainMapper& m_mapper;
const ::mfem::GridFunction& m_displacement;
const ::mfem::GridFunction& m_compactification;
mapping::DMWorkspace m_workspace;
::mfem::Vector m_element_displacement, m_element_compactification;
std::optional<mapping::ElementDisplacementData> m_displacement_data;
std::optional<mapping::ElementCompactificationData> m_compactification_data;
int m_element{-1};
};
}
/**
* @brief Measure a zero-density, zero-pressure vacuum boundary without clipping the represented fields.
* @note Density is the solver's represented density: on elements where the binding reconstructs it (SurfacePower
* t^n beside the surface) the reconstruction is sampled, elsewhere the plain finite-element polynomial.
* @note The caller supplies physical angular velocity and axis origin through RigidRotation. W_grad uses
* x minus that origin, consistent with the centrifugal dilation. Its translation dependence in an
* approximate state remains diagnostic. This low-level integrator assumes that the caller has certified
* the isolated infinity-zero Phi gauge; vacuum_virial performs that check for a complete stellar state.
* Only pressure evaluation is required from the EOS. This validation-tool boundary converts the EOS's
* existing domain exception into an invalid assessment; it does not change library exception handling.
*/
template <typename Binding, eos::models::EOSModel EOS>
requires eos::models::EOSSupportingRelation<EOS, eos::relations::PressureFromDensity>
[[nodiscard]] std::expected<VacuumVirialAssessment, std::string> measure_vacuum_virial(
const Binding& density_binding, const ::mfem::GridFunction& density, const ::mfem::GridFunction& potential,
mapping::GridFunctionMappingEvaluator& geometry, const EOS& equation_of_state, const equations::RigidRotation& rotation) {
// One evaluator is not thread-safe, so elements are sampled serially.
return detail::measure_vacuum_virial(density_binding, density, potential, equation_of_state, rotation, false,
[&](int, ::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) {
return geometry.evaluate_point(transformation, point, context);
});
}
/** @brief Certify the complete infinity trace before interpreting potential energy as isolated gravitational energy. */
template <typename Binding>
[[nodiscard]] bool has_infinity_zero_gauge(const Binding& potential_binding, const ::mfem::GridFunction& potential) {
static_assert(std::same_as<typename Binding::Term, Potential>);
if (potential.FESpace() != &potential_binding.space()) { return false; }
bool has_infinity = false;
for (int face = 0; face < potential_binding.mesh().GetNBE(); ++face) {
has_infinity = has_infinity || Binding::Schema::template boundary_id_matches<discretization::domain::InfinitySurfaceBoundary>(potential_binding.mesh().GetBdrAttribute(face));
}
if (!has_infinity) { return false; }
const auto boundary = discretization::backend::mfem::select_boundary_dofs<discretization::domain::InfinitySurfaceBoundary>(potential_binding);
const auto values = potential_binding.gather(potential);
for (const int dof : boundary.reduced_dofs()) {
if (!std::isfinite(values[dof]) || values[dof] != 0.0) { return false; }
}
return true;
}
/** @brief Certify the potential gauge and reconstruct production physical fields for a complete vacuum state. */
template <typename Discretization, typename State, eos::models::EOSModel EOS>
requires eos::models::EOSSupportingRelation<EOS, eos::relations::PressureFromDensity>
[[nodiscard]] std::expected<VacuumVirialAssessment, std::string> vacuum_virial(
const Discretization& discretization, const State& state, const EOS& equation_of_state, const equations::RigidRotation& rotation) {
const auto potential = archive::field<Potential>(discretization, state);
if (!has_infinity_zero_gauge(discretization.template binding<Potential>(), potential)) {
return std::unexpected("Vacuum virial requires an exactly zero complete H1 potential trace at compactified infinity.");
}
auto displacement = archive::displacement_field(discretization, state);
const auto density = archive::field<Density>(discretization, state);
// Same mapping as GridFunctionMappingEvaluator, one per worker, so elements are sampled concurrently.
std::vector<detail::WorkerMapping> workers;
workers.reserve(static_cast<std::size_t>(std::max(1, utils::parallel::worker_count())));
for (int worker = 0; worker < std::max(1, utils::parallel::worker_count()); ++worker) {
workers.emplace_back(discretization.mapper, displacement, discretization.compactification);
}
return detail::measure_vacuum_virial(discretization.template binding<Density>(), density, potential, equation_of_state, rotation, true,
[&](const int worker, ::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) {
return workers[static_cast<std::size_t>(worker)].evaluate_point(transformation, point, context);
});
}
}

View File

@@ -0,0 +1,50 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <iomanip>
#include "serif/tests/solver/virial.hpp"
namespace serif::tests::solver {
/**
* @brief Preserve raw integrals and the vacuum/gauge qualifications independently of algebraic convergence.
* @note Pass the complete-state vacuum_virial result, which certifies the potential gauge before measuring.
*/
inline void write_vacuum_virial(const std::filesystem::path& output,
const std::expected<VacuumVirialAssessment, std::string>& assessment, const bool algebraically_converged) {
const bool physically_accepted = algebraically_converged && assessment && assessment->passes();
std::ofstream rows(output / "virial.csv"), status(output / "virial_status.txt");
rows << std::setprecision(17)
<< "quadrature,mass,volume,T,pressure_integral,Wphi,Wgrad,energy_virial_signed,force_virial_signed,energy_work_signed,quadrature_allowance\n";
status << std::setprecision(17) << std::boolalpha
<< "scope=zero_density_zero_pressure_vacuum\n"
<< "algebraically_converged=" << algebraically_converged << '\n'
<< "assessment_valid=" << assessment.has_value() << '\n'
<< "physical_accepted=" << physically_accepted << '\n'
<< "virial_tolerance=1e-6\nintegration_allowance_limit=1e-7\n"
<< "integration_allowance_kind=maximum_of_last_two_quadrature_changes_not_rigorous_bound\n"
<< "accepted_state_meaning=algebraic_checkpoint\n";
if (assessment) {
status << "stellar_density_trace=complete_exact_zero\nEOS_pressure_at_zero_density=0\n"
<< "infinity_potential_trace=complete_exact_zero\n"
<< "surface_pressure_term=0\nstellar_boundary_dofs=" << assessment->boundary_dofs << '\n'
<< "virial_center=" << assessment->center[0] << ',' << assessment->center[1] << ',' << assessment->center[2] << '\n'
<< "angular_velocity=" << assessment->angular_velocity[0] << ',' << assessment->angular_velocity[1] << ',' << assessment->angular_velocity[2] << '\n'
<< "quadrature_allowance=" << assessment->quadrature_allowance << '\n';
for (const auto& sample : assessment->samples) {
rows << sample.order << ',' << sample.mass << ',' << sample.volume << ',' << sample.kinetic_energy
<< ',' << sample.pressure_integral << ',' << sample.potential_energy << ',' << sample.gravity_work
<< ',' << sample.energy_virial() << ',' << sample.force_virial()
<< ',' << (sample.potential_energy - sample.gravity_work) / std::abs(sample.potential_energy)
<< ',' << assessment->quadrature_allowance << '\n';
}
} else {
status << "vacuum_and_gauge_certification=unavailable\nassessment_failure=" << assessment.error() << '\n';
}
rows.close();
status.close();
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(rows.good() && status.good(),
"Cannot preserve the vacuum virial measurements and physical acceptance decision.");
}
}

View File

@@ -0,0 +1,147 @@
#pragma once
#include <cmath>
#include <limits>
#include <vector>
#include <mfem/fem/coefficient.hpp>
#include <mfem/fem/gridfunc.hpp>
#include <mfem/mesh/mesh.hpp>
#include "serif/backend/mfem/discretization/dof_selection.hpp"
#include "serif/backend/mfem/discretization/term_binding.hpp"
#include "serif/discretization/domain/schema/schemas.hpp"
#include "serif/backend/mfem/discretization/term_coefficients.hpp"
#include "serif/tests/surface/models.hpp"
namespace serif::tests::surface::boundary
{
namespace finite_elements = discretization::backend::mfem;
namespace coefficients = operators::backend::mfem;
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
using Vector = ::mfem::Vector;
/** @brief Give different physical fields independent continuous polynomial orders. */
template <typename QuantityT, int Order>
struct ContinuousScalar : ScalarTerm<QuantityT>
{
using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace<discretization::functional_space::H1, Order, 0>;
};
using BoundaryDensity = ContinuousScalar<quantities::MassDensity, 3>;
using QuadraticComposition = ContinuousScalar<ElectronFraction, 2>;
/** @brief A spatially uniform temperature still contributes to every selected pressure row. */
struct UniformTemperature : blocks::term<Material>, blocks::global_scalar
{
using Quantity = quantities::Temperature;
using Support = discretization::domain::StellarDomains;
};
using CompositionState = dependencies::Bindings<
dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>,
dependencies::Binding<quantities::Entropy, Entropy>,
dependencies::Binding<ElectronFraction, QuadraticComposition>>;
using CompositionFormulation = physics::PressureSurfaceFormulation<GeneralEOS, CompositionState>;
template <typename TemperatureT = Temperature>
using ContinuousGasState = dependencies::Bindings<
dependencies::Binding<quantities::MassDensity, BoundaryDensity>,
dependencies::Binding<quantities::Temperature, TemperatureT>>;
template <typename TemperatureT = Temperature>
using DensityFormulation = physics::PressureSurfaceFormulation<GasEOS, ContinuousGasState<TemperatureT>>;
template <typename TemperatureT = Temperature>
using PressureFormulation = physics::DirectPressureSurfaceFormulation<GasEOS, ContinuousGasState<TemperatureT>, quantities::MassDensity, PressureFromDensityAndTemperature>;
/** @brief Tag only x=0 so row replacement must preserve the other boundary and interior nodes. */
[[nodiscard]] inline ::mfem::Mesh make_boundary_mesh()
{
::mfem::Mesh mesh = ::mfem::Mesh::MakeCartesian3D(2, 2, 1, ::mfem::Element::HEXAHEDRON);
Vector center(3);
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary)
{
::mfem::ElementTransformation& transformation = *mesh.GetBdrElementTransformation(boundary);
transformation.Transform(::mfem::Geometries.GetCenter(transformation.GetGeometryType()), center);
mesh.GetBdrElement(boundary)->SetAttribute(std::abs(center[0]) < 1.0e-14 ? 1 : 2);
}
mesh.SetAttributes();
mesh.SetCurvature(3);
return mesh;
}
/** @brief Retain only the spaces and bindings needed by surface sampling. */
struct Fixture
{
::mfem::Mesh mesh = make_boundary_mesh();
::mfem::H1_FECollection cubic_collection{3, 3};
::mfem::H1_FECollection linear_collection{1, 3};
::mfem::H1_FECollection quadratic_collection{2, 3};
::mfem::FiniteElementSpace cubic_space{&mesh, &cubic_collection};
::mfem::FiniteElementSpace linear_space{&mesh, &linear_collection};
::mfem::FiniteElementSpace quadratic_space{&mesh, &quadratic_collection};
finite_elements::TermBinding<Enthalpy, Schema> enthalpy{cubic_space};
finite_elements::TermBinding<BoundaryDensity, Schema> density{cubic_space};
finite_elements::TermBinding<Entropy, Schema> entropy{linear_space};
finite_elements::TermBinding<Temperature, Schema> temperature{linear_space};
finite_elements::TermBinding<QuadraticComposition, Schema> composition{quadratic_space};
finite_elements::TermBinding<UniformTemperature, Schema> uniform_temperature{mesh};
};
/** @brief Nonpolynomial coefficient sequences expose accidental truncation and nodal-index reuse across orders. */
[[nodiscard]] inline Vector sequence(const int size, const double offset, const double phase)
{
Vector values(size);
for (int index = 0; index < size; ++index)
{
values[index] = offset + 0.09 * std::sin(0.37 * index + phase);
}
return values;
}
/** @brief Evaluate the original MFEM field at row-space nodes, without using compiled boundary stencils. */
[[nodiscard]] inline Vector reference_samples(const finite_elements::DofMap& row_binding, const finite_elements::DofSelection& selected, const finite_elements::DofMap& input_binding, const Vector& values)
{
Vector samples(selected.size());
samples = std::numeric_limits<double>::quiet_NaN();
std::vector<bool> sampled(selected.size(), false);
::mfem::GridFunction field(&input_binding.space());
field = input_binding.scatter(values);
::mfem::Array<int> dofs;
for (int element = 0; element < row_binding.space().GetNE(); ++element)
{
row_binding.space().GetElementDofs(element, dofs);
const ::mfem::IntegrationRule& nodes = row_binding.space().GetFE(element)->GetNodes();
for (int local = 0; local < dofs.Size(); ++local)
{
const auto reduced = row_binding.reduced_dof(dofs[local]);
if (!reduced)
{
continue;
}
for (int row = 0; row < selected.size(); ++row)
{
if (selected.reduced_dofs()[row] == *reduced && !sampled[row])
{
samples[row] = field.GetValue(element, nodes.IntPoint(local));
sampled[row] = true;
}
}
}
}
return samples;
}
/** @brief Locate the constrained carrier rows using MFEM's independent boundary marker. */
[[nodiscard]] inline Vector boundary_mask(const finite_elements::DofMap& binding)
{
::mfem::Array<int> attributes{1, 0}, marker;
binding.space().GetEssentialVDofs(attributes, marker);
Vector full(marker.Size());
for (int index = 0; index < marker.Size(); ++index)
{
full[index] = marker[index] != 0;
}
return binding.gather(full);
}
} // namespace serif::tests::surface::boundary

View File

@@ -0,0 +1,42 @@
#pragma once
#include "serif/tests/surface/models.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace serif::tests::surface {
/// Track physical EOS parameters, not the observational evaluation counters used by these tests.
[[nodiscard]] inline auto general_context(GeneralEOS& model, const double pressure, std::vector<double> enthalpy, std::vector<double> entropy, std::vector<double> composition) {
return dependencies::make_dependency_context(
dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const GeneralEOS& eos) {
return eos.scale;
})),
physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}),
physics::boundary_values<Enthalpy>(std::move(enthalpy)),
physics::boundary_values<Entropy>(std::move(entropy)),
physics::boundary_values<Composition>(std::move(composition))
);
}
/// Rebuilding equal owned sample buffers should reuse the pressure-only response and residual.
[[nodiscard]] inline auto offset_context(OffsetEOS& model, const double pressure, std::vector<double> enthalpy) {
return dependencies::make_dependency_context(
dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const OffsetEOS& eos) {
return eos.offset;
})),
physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}),
physics::boundary_values<Enthalpy>(std::move(enthalpy))
);
}
/// This ideal-gas fixture has no mutable physical parameters; source identity still distinguishes model objects.
[[nodiscard]] inline auto gas_context(GasEOS& model, const double pressure, std::vector<double> density, std::vector<double> temperature) {
return dependencies::make_dependency_context(
dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const GasEOS&) {
return 0;
})),
physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}),
physics::boundary_values<Density>(std::move(density)),
physics::boundary_values<Temperature>(std::move(temperature))
);
}
}

View File

@@ -0,0 +1,148 @@
#pragma once
#include <limits>
#include "serif/tests/equations/test_equations.hpp"
#include "serif/eos/exceptions.hpp"
#include "serif/geometry/surface/surface.hpp"
#include "serif/utils/error/errors.hpp"
namespace serif::tests::surface {
namespace physics = geometry::surface;
namespace dependencies = utils::dependencies;
namespace quantities = dimensions;
namespace blocks = discretization::blocks;
using Enthalpy = ::serif::tests::equations::Enthalpy;
using Density = blocks::density::mass;
struct ElectronFraction : quantities::ThermodynamicQuantity {
};
struct Material : blocks::field {
};
template <typename QuantityT>
struct ScalarTerm : blocks::term<Material> {
using Quantity = QuantityT;
using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace<discretization::functional_space::H1, 1, 0>;
using Support = discretization::domain::StellarDomains;
struct value : blocks::value_block_base {
};
struct residual : blocks::residual_block_base {
};
};
using Entropy = ScalarTerm<quantities::Entropy>;
using Composition = ScalarTerm<ElectronFraction>;
using Temperature = ScalarTerm<quantities::Temperature>;
using BarotropicState = dependencies::Bindings<dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>>;
using GeneralState = dependencies::Bindings<
dependencies::Binding<quantities::Entropy, Entropy>,
dependencies::Binding<ElectronFraction, Composition>,
dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>
>;
using GasState = dependencies::Bindings<
dependencies::Binding<quantities::MassDensity, Density>,
dependencies::Binding<quantities::Temperature, Temperature>
>;
using EnthalpyFromPressureAndComposition = eos::relations::Relation<quantities::SpecificEnthalpy, quantities::Pressure, quantities::Entropy, ElectronFraction>;
using DensityFromPressureAndTemperature = eos::relations::Relation<quantities::MassDensity, quantities::Pressure, quantities::Temperature>;
using PressureFromDensityAndTemperature = eos::relations::Relation<quantities::Pressure, quantities::MassDensity, quantities::Temperature>;
/** @brief The affine composition-dependent EOS from MeanField's constant_surface_compilation.cpp. */
struct GeneralEOS {
using Relations = eos::relations::RelationCatalog<EnthalpyFromPressureAndComposition>;
double scale = 1.0;
mutable int evaluations = 0;
mutable int partials = 0;
/// Preserve MeanField's independent 2P + 3s + 5Ye reference expression.
[[nodiscard]] quantities::SpecificEnthalpyValue evaluate(EnthalpyFromPressureAndComposition, const quantities::PressureValue pressure, const quantities::EntropyValue entropy, const quantities::QuantityValue<ElectronFraction> composition) const {
++evaluations;
return quantities::SpecificEnthalpyValue{scale * (2.0 * pressure.value() + 3.0 * entropy.value() + 5.0 * composition.value())};
}
/// The entropy partial is independent of all numerical state.
[[nodiscard]] quantities::PartialDerivative<quantities::SpecificEnthalpy, quantities::Entropy> partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo<quantities::Entropy>, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue<ElectronFraction>) const {
++partials;
return quantities::PartialDerivative<quantities::SpecificEnthalpy, quantities::Entropy>{scale * 3.0};
}
/// The electron-fraction partial exercises a second independent coupling.
[[nodiscard]] quantities::PartialDerivative<quantities::SpecificEnthalpy, ElectronFraction> partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo<ElectronFraction>, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue<ElectronFraction>) const {
++partials;
return quantities::PartialDerivative<quantities::SpecificEnthalpy, ElectronFraction>{scale * 5.0};
}
};
/** @brief Both ideal-gas relations are available, but the choice of residual remains explicit and contains no enthalpy. */
struct GasEOS {
using Relations = eos::relations::RelationCatalog<DensityFromPressureAndTemperature, PressureFromDensityAndTemperature>;
mutable int evaluations = 0;
/// A nonzero temperature is required to use the explicit density relation.
[[nodiscard]] quantities::DensityValue evaluate(DensityFromPressureAndTemperature, const quantities::PressureValue pressure, const quantities::TemperatureValue temperature) const {
utils::errors::serif_ensure<utils::errors::SERiFErrorCode::EOS_OUTSIDE_DOMAIN, eos::EOSEvaluationError>(temperature.value() > 0.0, "The ideal-gas test EOS requires positive temperature, received {}.", temperature.value());
++evaluations;
return quantities::DensityValue{pressure.value() / temperature.value()};
}
/// Supply an independent forward relation, rather than deriving it from the surface implementation.
[[nodiscard]] quantities::PressureValue evaluate(PressureFromDensityAndTemperature, const quantities::DensityValue density, const quantities::TemperatureValue temperature) const {
++evaluations;
return quantities::PressureValue{density.value() * temperature.value()};
}
/// The inverse relation has a state-dependent temperature partial.
[[nodiscard]] quantities::PartialDerivative<quantities::MassDensity, quantities::Temperature> partial_derivative(DensityFromPressureAndTemperature, quantities::WithRespectTo<quantities::Temperature>, const quantities::PressureValue pressure, const quantities::TemperatureValue temperature) const {
return quantities::PartialDerivative<quantities::MassDensity, quantities::Temperature>{-pressure.value() / (temperature.value() * temperature.value())};
}
/// At fixed temperature, pressure varies linearly with density.
[[nodiscard]] quantities::PartialDerivative<quantities::Pressure, quantities::MassDensity> partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo<quantities::MassDensity>, quantities::DensityValue, const quantities::TemperatureValue temperature) const {
return quantities::PartialDerivative<quantities::Pressure, quantities::MassDensity>{temperature.value()};
}
/// At fixed density, pressure varies linearly with temperature.
[[nodiscard]] quantities::PartialDerivative<quantities::Pressure, quantities::Temperature> partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo<quantities::Temperature>, const quantities::DensityValue density, quantities::TemperatureValue) const {
return quantities::PartialDerivative<quantities::Pressure, quantities::Temperature>{density.value()};
}
};
/** @brief A pressure-only EOS whose zero-pressure enthalpy is deliberately nonzero. */
struct OffsetEOS {
using Relations = eos::relations::RelationCatalog<eos::relations::SpecificEnthalpyFromPressure>;
double offset = 0.3;
mutable int evaluations = 0;
/// Count only physical evaluations, excluding this observational counter from source tracking.
[[nodiscard]] quantities::SpecificEnthalpyValue evaluate(eos::relations::SpecificEnthalpyFromPressure, const quantities::PressureValue pressure) const {
++evaluations;
return quantities::SpecificEnthalpyValue{offset + 2.0 * pressure.value()};
}
};
using GeneralFormulation = physics::PressureSurfaceFormulation<GeneralEOS, GeneralState>;
using GasFormulation = physics::PressureSurfaceFormulation<GasEOS, GasState>;
using DirectGasFormulation = physics::DirectPressureSurfaceFormulation<GasEOS, GasState, quantities::MassDensity, PressureFromDensityAndTemperature>;
/// Gather the three MeanField state quantities into the standard equation-point representation.
[[nodiscard]] inline physics::SurfacePoint<GeneralFormulation> general_point(const double enthalpy, const double entropy, const double composition) {
physics::SurfacePoint<GeneralFormulation> point{};
point.fields.get<Enthalpy>() = enthalpy;
point.fields.get<Entropy>() = entropy;
point.fields.get<Composition>() = composition;
return point;
}
/// Carrier-resolved and direct-pressure gas formulations share these two scalar operands.
[[nodiscard]] inline physics::SurfacePoint<GasFormulation> gas_point(const double density, const double temperature) {
physics::SurfacePoint<GasFormulation> point{};
point.fields.get<Density>() = density;
point.fields.get<Temperature>() = temperature;
return point;
}
}

View File

@@ -1,11 +1,6 @@
#pragma once #pragma once
/* // Shared compile-time tags for the SERiF tests. Sub-tags include their parent tag.
* Header form of the tag machinery that currently lives in
* test_helpers.cppm. Only the tags used by the discretization/domain
* tests are carried over; once test_helpers is itself de-moduled this
* file should be deleted and the project wide header included instead.
*/
#include <algorithm> #include <algorithm>
#include <array> #include <array>
@@ -13,20 +8,15 @@
#include <catch2/internal/catch_stringref.hpp> #include <catch2/internal/catch_stringref.hpp>
template <std::size_t N> struct Tag { template <std::size_t N>
struct Tag {
std::array<char, N> chars{}; std::array<char, N> chars{};
// ReSharper disable once CppNonExplicitConvertingConstructor // ReSharper disable once CppNonExplicitConvertingConstructor
consteval Tag( consteval Tag(std::array<char, N> arr) : chars(arr) {}
std::array<
char,
N> arr
)
: chars(arr) {
}
// ReSharper disable once CppNonExplicitConversionOperator // ReSharper disable once CppNonExplicitConversionOperator
constexpr operator const char *() const { constexpr operator const char*() const {
return chars.data(); return chars.data();
} }
@@ -35,7 +25,8 @@ template <std::size_t N> struct Tag {
return Catch::StringRef(chars.data(), N - 1); return Catch::StringRef(chars.data(), N - 1);
} }
template <std::size_t M> consteval Tag<N + M - 1> operator&(const Tag<M> &other) const { template <std::size_t M>
consteval Tag<N + M - 1> operator&(const Tag<M>& other) const {
std::array<char, N + M - 1> res{}; std::array<char, N + M - 1> res{};
std::ranges::copy(chars.begin(), chars.end() - 1, res.begin()); std::ranges::copy(chars.begin(), chars.end() - 1, res.begin());
std::ranges::copy(other.chars, res.begin() + (N - 1)); std::ranges::copy(other.chars, res.begin() + (N - 1));
@@ -43,29 +34,34 @@ template <std::size_t N> struct Tag {
} }
}; };
template <std::size_t N> consteval auto make_tag(const char (&str)[N]) { template <std::size_t N>
consteval auto make_tag(const char (&str)[N]) {
std::array<char, N + 2> res{}; std::array<char, N + 2> res{};
res[0] = '['; res[0] = '[';
std::ranges::copy(str, str + N - 1, res.begin() + 1); std::ranges::copy(str, str + N - 1, res.begin() + 1);
res[N] = ']'; res[N] = ']';
res[N + 1] = '\0'; res[N + 1] = '\0';
return Tag<N + 2>{res}; return Tag<N + 2>{res};
} }
template < template <std::size_t N, std::size_t M>
std::size_t N, consteval auto sub_tag(const Tag<N>& parent, const char (&str)[M]) {
std::size_t M>
consteval auto sub_tag(
const Tag<N> &parent,
const char (&str)[M]
) {
return parent & make_tag(str); return parent & make_tag(str);
} }
namespace tags { namespace tags {
inline constexpr auto unit = make_tag("unit"); inline constexpr auto unit = make_tag("unit");
inline constexpr auto mesh = make_tag("mesh"); inline constexpr auto mesh = make_tag("mesh");
inline constexpr auto integration = make_tag("integration"); inline constexpr auto integration = make_tag("integration");
inline constexpr auto utils = make_tag("utils"); inline constexpr auto utils = make_tag("utils");
inline constexpr auto domain = sub_tag(mesh, "domain"); inline constexpr auto domain = sub_tag(mesh, "domain");
inline constexpr auto convergence = make_tag("convergence");
inline constexpr auto mapping = make_tag("mapping");
inline constexpr auto transformations = sub_tag(mapping, "transformations");
inline constexpr auto kelvin = sub_tag(mapping, "kelvin");
inline constexpr auto geometry_cache = sub_tag(mapping, "geometry-cache");
inline constexpr auto volume_cache = sub_tag(mapping, "volume-cache");
inline constexpr auto eos = make_tag("eos");
inline constexpr auto equations = make_tag("equations");
inline constexpr auto quadrature = make_tag("quadrature");
} // namespace tags } // namespace tags

View File

@@ -0,0 +1,773 @@
// Ported from MeanField tests/mapping/compactification/kelvin.cpp.
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <stdexcept>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <mfem.hpp>
#include "serif/backend/mfem/mapping/compactification/kelvin.hpp"
#include "serif/tests/test_tags.hpp"
namespace mapping = serif::mapping;
using Catch::Matchers::WithinAbs;
namespace {
constexpr int dimension = 3;
mfem::Vector make_vector(const double x, const double y, const double z) {
mfem::Vector vector(dimension);
vector(0) = x;
vector(1) = y;
vector(2) = z;
return vector;
}
mfem::DenseMatrix make_identity() {
mfem::DenseMatrix matrix(dimension);
matrix = 0.0;
for (int i = 0; i < dimension; ++i) {
matrix(i, i) = 1.0;
}
return matrix;
}
void check_vector(const mfem::Vector& actual, const mfem::Vector& expected, const double tolerance) {
REQUIRE(actual.Size() == expected.Size());
for (int i = 0; i < actual.Size(); ++i) {
CHECK_THAT(actual(i), WithinAbs(expected(i), tolerance));
}
}
void check_matrix(const mfem::DenseMatrix& actual, const mfem::DenseMatrix& expected, const double tolerance) {
REQUIRE(actual.Height() == expected.Height());
REQUIRE(actual.Width() == expected.Width());
for (int i = 0; i < actual.Height(); ++i) {
for (int j = 0; j < actual.Width(); ++j) {
CHECK_THAT(actual(i, j), WithinAbs(expected(i, j), tolerance));
}
}
}
struct AnalyticFactors {
double computational_radius;
double scale;
double scale_derivative;
};
AnalyticFactors compute_analytic_factors(const double r_star, const double r_inf, const double coordinate) {
const double radial_extent = r_inf - r_star;
const double computational_radius = r_star + coordinate * radial_extent;
const double scale = r_star / (computational_radius * (1.0 - coordinate));
const double scale_derivative = scale * (1.0 / (1.0 - coordinate) - radial_extent / computational_radius);
return {.computational_radius = computational_radius, .scale = scale, .scale_derivative = scale_derivative};
}
mapping::MappingStatus evaluate_affine_map(
const mapping::compactification::ExteriorDomainMap& exterior_map,
const mfem::Vector& reference_position,
const mfem::DenseMatrix& affine_jacobian,
const mfem::Vector& offset,
const double compactification_coordinate,
const mfem::Vector& compactification_coordinate_gradient,
mapping::compactification::ExteriorMapResult& result
) {
mfem::Vector displaced_position(dimension);
affine_jacobian.Mult(reference_position, displaced_position);
displaced_position += offset;
const mapping::compactification::ExteriorMapInput input{
.reference_position = reference_position,
.displaced_position = displaced_position,
.displacement_jacobian = affine_jacobian,
.compactification_coordinate_gradient = compactification_coordinate_gradient,
.compactification_coordinate = compactification_coordinate
};
return exterior_map.evaluate(input, result);
}
} // namespace
TEST_CASE(
"Kelvin Prepared Jacobian Factors Match Full Variation",
tags::unit & tags::kelvin
) {
mapping::compactification::KelvinCompactification kelvin({.r_star_ref = 1.3, .r_inf_ref = 3.1});
const auto position = make_vector(1.1, 0.2, -0.1);
const auto coordinate_gradient = make_vector(0.2, 0.03, -0.01);
auto jacobian = make_identity();
jacobian(0, 1) = 0.07;
const auto direction = make_vector(-0.02, 0.07, 0.04);
auto direction_jacobian = make_identity();
direction_jacobian *= 0.03;
direction_jacobian(1, 2) = -0.05;
for (const double coordinate : {0.0, 0.4, 0.93, 0.999}) {
const mapping::compactification::ExteriorMapInput input{
position, position, jacobian, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult base;
REQUIRE(kelvin.evaluate(input, base) == mapping::MappingStatus::VALID);
mapping::compactification::ExteriorMapVariation variation;
REQUIRE(
kelvin.evaluate_variation(input, base, {direction, direction_jacobian}, variation) ==
mapping::MappingStatus::VALID
);
double scale = 0.0;
mfem::Vector scale_gradient;
REQUIRE(
kelvin.evaluate_jacobian_variation_factors(coordinate, coordinate_gradient, scale, scale_gradient) ==
mapping::MappingStatus::VALID
);
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
const double expected = variation.mapping_jacobian_variation(row, column);
CHECK_THAT(
scale * direction_jacobian(row, column) + direction(row) * scale_gradient(column),
WithinAbs(expected, 1.0e-12 * std::max(1.0, std::abs(expected)))
);
}
}
}
double scale = 0.0;
mfem::Vector gradient;
CHECK(
kelvin.evaluate_jacobian_variation_factors(1.0, coordinate_gradient, scale, gradient) ==
mapping::MappingStatus::AT_COMPACTIFIED_INFINITY
);
CHECK(
kelvin.evaluate_jacobian_variation_factors(-0.1, coordinate_gradient, scale, gradient) ==
mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN
);
auto invalid = coordinate_gradient;
invalid(1) = std::numeric_limits<double>::quiet_NaN();
CHECK(
kelvin.evaluate_jacobian_variation_factors(0.4, invalid, scale, gradient) ==
mapping::MappingStatus::NON_FINITE_MAPPING_INPUT
);
}
TEST_CASE(
"Kelvin Compactification Validates Its Configuration",
tags::unit & tags::kelvin
) {
CHECK_NOTHROW(mapping::compactification::KelvinCompactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}));
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification({.r_star_ref = 0.0, .r_inf_ref = 4.0}), std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification({.r_star_ref = -1.0, .r_inf_ref = 4.0}), std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification({.r_star_ref = 2.0, .r_inf_ref = 2.0}), std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification({.r_star_ref = 3.0, .r_inf_ref = 2.0}), std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification(
{.r_star_ref = 1.0, .r_inf_ref = std::numeric_limits<double>::infinity()}
),
std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification(
{.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = -1.0e-12}
),
std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification(
{.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = 1.0}
),
std::invalid_argument
);
CHECK_THROWS_AS(
mapping::compactification::KelvinCompactification(
{.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = std::numeric_limits<double>::quiet_NaN()}
),
std::invalid_argument
);
}
TEST_CASE(
"Kelvin Compactification Reports Its Configuration",
tags::unit & tags::kelvin
) {
constexpr double coordinate_tolerance = 3.0e-11;
mapping::compactification::KelvinCompactification compactification(
{.r_star_ref = 1.25, .r_inf_ref = 5.5, .coordinate_tolerance = coordinate_tolerance}
);
CHECK(compactification.get_name() == "KelvinCompactification");
CHECK_THAT(compactification.get_reference_stellar_radius(), WithinAbs(1.25, 0.0));
CHECK_THAT(compactification.get_reference_infinity_radius(), WithinAbs(5.5, 0.0));
CHECK_THAT(compactification.get_coordinate_tolerance(), WithinAbs(coordinate_tolerance, 0.0));
}
TEST_CASE(
"Kelvin Compactification Uses The Exterior Coordinate Rather Than "
"Euclidean Radius",
tags::unit & tags::kelvin
) {
constexpr double tolerance = 0.0;
constexpr double coordinate = 0.37;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
const mfem::DenseMatrix displacement_jacobian = make_identity();
mfem::Vector displaced_position = make_vector(1.4, -0.2, 0.3);
mfem::Vector coordinate_gradient = make_vector(0.2, -0.1, 0.05);
mfem::Vector reference_a = make_vector(0.2, 0.1, -0.1);
mfem::Vector reference_b = make_vector(12.0, -7.0, 4.0);
const mapping::compactification::ExteriorMapInput input_a{
reference_a, displaced_position, displacement_jacobian, coordinate_gradient, coordinate
};
const mapping::compactification::ExteriorMapInput input_b{
reference_b, displaced_position, displacement_jacobian, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result_a;
mapping::compactification::ExteriorMapResult result_b;
REQUIRE(compactification.evaluate(input_a, result_a) == mapping::MappingStatus::VALID);
REQUIRE(compactification.evaluate(input_b, result_b) == mapping::MappingStatus::VALID);
check_vector(result_a.physical_position, result_b.physical_position, tolerance);
check_matrix(result_a.mapping_jacobian, result_b.mapping_jacobian, tolerance);
}
TEST_CASE(
"Kelvin Compactification Matches Its Analytic Radial Map",
tags::unit & tags::kelvin
) {
constexpr double r_star = 1.0;
constexpr double r_inf = 4.0;
constexpr double radial_extent = r_inf - r_star;
constexpr double tolerance = 2.0e-12;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = r_star, .r_inf_ref = r_inf});
const mfem::DenseMatrix identity = make_identity();
mfem::Vector coordinate_gradient = make_vector(1.0 / radial_extent, 0.0, 0.0);
for (const double computational_radius : std::array{1.0, 1.25, 2.0, 3.0, 3.75}) {
CAPTURE(computational_radius);
const double coordinate = (computational_radius - r_star) / radial_extent;
mfem::Vector reference_position = make_vector(computational_radius, 0.0, 0.0);
mfem::Vector displaced_position(reference_position);
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, identity, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
const double expected_radius = r_star * radial_extent / (r_inf - computational_radius);
const double expected_radial_derivative = r_star * radial_extent / std::pow(r_inf - computational_radius, 2.0);
const double expected_tangential_scale = expected_radius / computational_radius;
mfem::Vector expected_position = make_vector(expected_radius, 0.0, 0.0);
mfem::DenseMatrix expected_jacobian(dimension);
expected_jacobian = 0.0;
expected_jacobian(0, 0) = expected_radial_derivative;
expected_jacobian(1, 1) = expected_tangential_scale;
expected_jacobian(2, 2) = expected_tangential_scale;
check_vector(result.physical_position, expected_position, tolerance);
check_matrix(result.mapping_jacobian, expected_jacobian, tolerance);
CHECK(result.mapping_jacobian.Det() > 0.0);
}
}
TEST_CASE(
"Kelvin Compactification Matches Its Full Cartesian Formula",
tags::unit & tags::kelvin
) {
constexpr double r_star = 1.0;
constexpr double r_inf = 4.0;
constexpr double coordinate = 0.42;
constexpr double tolerance = 1.0e-12;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = r_star, .r_inf_ref = r_inf});
mfem::Vector reference_position = make_vector(0.8, 0.4, -0.2);
mfem::Vector displaced_position = make_vector(1.1, 0.5, -0.1);
mfem::Vector coordinate_gradient = make_vector(0.20, -0.10, 0.05);
mfem::DenseMatrix displacement_jacobian(dimension);
displacement_jacobian(0, 0) = 1.10;
displacement_jacobian(0, 1) = 0.05;
displacement_jacobian(0, 2) = 0.00;
displacement_jacobian(1, 0) = -0.02;
displacement_jacobian(1, 1) = 0.95;
displacement_jacobian(1, 2) = 0.03;
displacement_jacobian(2, 0) = 0.01;
displacement_jacobian(2, 1) = -0.04;
displacement_jacobian(2, 2) = 1.05;
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
const AnalyticFactors factors = compute_analytic_factors(r_star, r_inf, coordinate);
mfem::Vector expected_position(displaced_position);
expected_position *= factors.scale;
mfem::DenseMatrix expected_jacobian(displacement_jacobian);
expected_jacobian *= factors.scale;
for (int i = 0; i < dimension; ++i) {
for (int j = 0; j < dimension; ++j) {
expected_jacobian(i, j) += displaced_position(i) * factors.scale_derivative * coordinate_gradient(j);
}
}
check_vector(result.physical_position, expected_position, tolerance);
check_matrix(result.mapping_jacobian, expected_jacobian, tolerance);
}
TEST_CASE(
"Kelvin Compactification Jacobian Matches Coordinate Finite Differences",
tags::unit & tags::kelvin
) {
constexpr double base_coordinate = 0.38;
constexpr double difference_step = 1.0e-6;
constexpr double tolerance = 3.0e-9;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::DenseMatrix affine_jacobian(dimension);
affine_jacobian(0, 0) = 1.05;
affine_jacobian(0, 1) = 0.02;
affine_jacobian(0, 2) = 0.00;
affine_jacobian(1, 0) = -0.01;
affine_jacobian(1, 1) = 0.98;
affine_jacobian(1, 2) = 0.03;
affine_jacobian(2, 0) = 0.02;
affine_jacobian(2, 1) = 0.00;
affine_jacobian(2, 2) = 1.02;
mfem::Vector offset = make_vector(0.04, -0.03, 0.02);
mfem::Vector reference_position = make_vector(1.4, 0.3, -0.2);
mfem::Vector coordinate_gradient = make_vector(0.11, -0.07, 0.05);
mapping::compactification::ExteriorMapResult base_result;
REQUIRE(
evaluate_affine_map(
compactification, reference_position, affine_jacobian, offset, base_coordinate, coordinate_gradient,
base_result
) == mapping::MappingStatus::VALID
);
for (int coordinate = 0; coordinate < dimension; ++coordinate) {
mfem::Vector reference_plus(reference_position);
mfem::Vector reference_minus(reference_position);
reference_plus(coordinate) += difference_step;
reference_minus(coordinate) -= difference_step;
const double compactification_plus = base_coordinate + difference_step * coordinate_gradient(coordinate);
const double compactification_minus = base_coordinate - difference_step * coordinate_gradient(coordinate);
mapping::compactification::ExteriorMapResult result_plus;
mapping::compactification::ExteriorMapResult result_minus;
REQUIRE(
evaluate_affine_map(
compactification, reference_plus, affine_jacobian, offset, compactification_plus, coordinate_gradient,
result_plus
) == mapping::MappingStatus::VALID
);
REQUIRE(
evaluate_affine_map(
compactification, reference_minus, affine_jacobian, offset, compactification_minus, coordinate_gradient,
result_minus
) == mapping::MappingStatus::VALID
);
for (int component = 0; component < dimension; ++component) {
const double finite_difference =
(result_plus.physical_position(component) - result_minus.physical_position(component)) /
(2.0 * difference_step);
CHECK_THAT(finite_difference, WithinAbs(base_result.mapping_jacobian(component, coordinate), tolerance));
}
}
}
TEST_CASE(
"Kelvin Compactification Variation Matches State Finite Differences",
tags::unit & tags::kelvin
) {
constexpr double coordinate = 0.46;
constexpr double difference_step = 1.0e-6;
constexpr double tolerance = 2.0e-10;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::Vector reference_position = make_vector(1.3, -0.2, 0.4);
mfem::Vector displaced_position = make_vector(1.4, -0.1, 0.35);
mfem::Vector coordinate_gradient = make_vector(0.12, -0.04, 0.08);
mfem::Vector position_direction = make_vector(0.03, -0.05, 0.02);
mfem::DenseMatrix displacement_jacobian = make_identity();
displacement_jacobian(0, 1) = 0.04;
displacement_jacobian(1, 2) = -0.03;
displacement_jacobian(2, 0) = 0.02;
mfem::DenseMatrix jacobian_direction(dimension);
jacobian_direction(0, 0) = 0.02;
jacobian_direction(0, 1) = -0.01;
jacobian_direction(0, 2) = 0.03;
jacobian_direction(1, 0) = 0.01;
jacobian_direction(1, 1) = -0.02;
jacobian_direction(1, 2) = 0.00;
jacobian_direction(2, 0) = -0.01;
jacobian_direction(2, 1) = 0.02;
jacobian_direction(2, 2) = 0.01;
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate
};
const mapping::compactification::ExteriorMapDirection direction{position_direction, jacobian_direction};
mapping::compactification::ExteriorMapResult base_result;
mapping::compactification::ExteriorMapVariation variation;
REQUIRE(compactification.evaluate(input, base_result) == mapping::MappingStatus::VALID);
REQUIRE(
compactification.evaluate_variation(input, base_result, direction, variation) == mapping::MappingStatus::VALID
);
mfem::Vector displaced_plus(displaced_position);
mfem::Vector displaced_minus(displaced_position);
displaced_plus.Add(difference_step, position_direction);
displaced_minus.Add(-difference_step, position_direction);
mfem::DenseMatrix jacobian_plus(displacement_jacobian);
mfem::DenseMatrix jacobian_minus(displacement_jacobian);
jacobian_plus.Add(difference_step, jacobian_direction);
jacobian_minus.Add(-difference_step, jacobian_direction);
const mapping::compactification::ExteriorMapInput input_plus{
reference_position, displaced_plus, jacobian_plus, coordinate_gradient, coordinate
};
const mapping::compactification::ExteriorMapInput input_minus{
reference_position, displaced_minus, jacobian_minus, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result_plus;
mapping::compactification::ExteriorMapResult result_minus;
REQUIRE(compactification.evaluate(input_plus, result_plus) == mapping::MappingStatus::VALID);
REQUIRE(compactification.evaluate(input_minus, result_minus) == mapping::MappingStatus::VALID);
for (int i = 0; i < dimension; ++i) {
const double position_finite_difference =
(result_plus.physical_position(i) - result_minus.physical_position(i)) / (2.0 * difference_step);
CHECK_THAT(position_finite_difference, WithinAbs(variation.physical_position_variation(i), tolerance));
for (int j = 0; j < dimension; ++j) {
const double jacobian_finite_difference =
(result_plus.mapping_jacobian(i, j) - result_minus.mapping_jacobian(i, j)) / (2.0 * difference_step);
CHECK_THAT(jacobian_finite_difference, WithinAbs(variation.mapping_jacobian_variation(i, j), tolerance));
}
}
}
TEST_CASE(
"Kelvin Compactification Preserves Rotational Covariance",
tags::unit & tags::kelvin
) {
constexpr double coordinate = 0.31;
constexpr double tolerance = 1.0e-12;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::Vector reference_position = make_vector(1.3, 0.4, -0.2);
mfem::Vector displaced_position = make_vector(1.4, 0.2, -0.1);
mfem::Vector coordinate_gradient = make_vector(0.16, -0.08, 0.03);
mfem::DenseMatrix displacement_jacobian = make_identity();
displacement_jacobian(0, 1) = 0.05;
displacement_jacobian(1, 0) = -0.02;
displacement_jacobian(2, 1) = 0.03;
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
mfem::DenseMatrix rotation(dimension);
rotation = 0.0;
rotation(0, 1) = -1.0;
rotation(1, 0) = 1.0;
rotation(2, 2) = 1.0;
mfem::Vector rotated_reference(dimension);
mfem::Vector rotated_displaced(dimension);
mfem::Vector rotated_coordinate_gradient(dimension);
rotation.Mult(reference_position, rotated_reference);
rotation.Mult(displaced_position, rotated_displaced);
rotation.Mult(coordinate_gradient, rotated_coordinate_gradient);
mfem::DenseMatrix temporary(dimension);
mfem::DenseMatrix rotated_displacement_jacobian(dimension);
mfem::Mult(rotation, displacement_jacobian, temporary);
mfem::MultABt(temporary, rotation, rotated_displacement_jacobian);
const mapping::compactification::ExteriorMapInput rotated_input{
rotated_reference, rotated_displaced, rotated_displacement_jacobian, rotated_coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult rotated_result;
REQUIRE(compactification.evaluate(rotated_input, rotated_result) == mapping::MappingStatus::VALID);
mfem::Vector expected_position(dimension);
rotation.Mult(result.physical_position, expected_position);
mfem::DenseMatrix expected_jacobian(dimension);
mfem::Mult(rotation, result.mapping_jacobian, temporary);
mfem::MultABt(temporary, rotation, expected_jacobian);
check_vector(rotated_result.physical_position, expected_position, tolerance);
check_matrix(rotated_result.mapping_jacobian, expected_jacobian, tolerance);
}
TEST_CASE(
"Kelvin Compactification Is Continuous At The Mesh Defined Stellar Surface",
tags::unit & tags::kelvin
) {
constexpr double tolerance = 1.0e-14;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::Vector reference_position = make_vector(-0.5260553366425769, 0.5260553366425769, -0.6553163792879153);
mfem::Vector displaced_position = make_vector(-0.55, 0.51, -0.63);
mfem::Vector coordinate_gradient = make_vector(-0.18, 0.18, -0.22);
const mfem::DenseMatrix displacement_jacobian = make_identity();
REQUIRE(reference_position.Norml2() < 1.0);
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, displacement_jacobian, coordinate_gradient, 0.0
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
check_vector(result.physical_position, displaced_position, tolerance);
CHECK(result.mapping_jacobian.Det() > 0.0);
}
TEST_CASE(
"Kelvin Compactification Has Correct Infinity And Coordinate Bound "
"Behavior",
tags::unit & tags::kelvin
) {
constexpr double r_star = 1.0;
constexpr double r_inf = 4.0;
constexpr double radial_extent = r_inf - r_star;
constexpr double coordinate_tolerance = 1.0e-12;
constexpr double tolerance = 2.0e-11;
mapping::compactification::KelvinCompactification compactification(
{.r_star_ref = r_star, .r_inf_ref = r_inf, .coordinate_tolerance = coordinate_tolerance}
);
const mfem::DenseMatrix identity = make_identity();
mfem::Vector coordinate_gradient = make_vector(1.0 / radial_extent, 0.0, 0.0);
for (const double coordinate : std::array{0.0, 0.25, 0.75, 0.95, 0.99, 0.999}) {
CAPTURE(coordinate);
const double computational_radius = r_star + coordinate * radial_extent;
mfem::Vector reference_position = make_vector(computational_radius, 0.0, 0.0);
const mapping::compactification::ExteriorMapInput input{
reference_position, reference_position, identity, coordinate_gradient, coordinate
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
CHECK_THAT(result.physical_position.Norml2() * (1.0 - coordinate), WithinAbs(r_star, tolerance));
}
mfem::Vector reference_position = make_vector(r_inf, 0.0, 0.0);
mapping::compactification::ExteriorMapResult result;
CHECK(
compactification.evaluate(
{reference_position, reference_position, identity, coordinate_gradient, 1.0}, result
) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY
);
CHECK(
compactification.evaluate(
{reference_position, reference_position, identity, coordinate_gradient, 1.0 - 0.5 * coordinate_tolerance},
result
) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY
);
CHECK(
compactification.evaluate(
{reference_position, reference_position, identity, coordinate_gradient, 1.0 + 0.5 * coordinate_tolerance},
result
) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY
);
CHECK(
compactification.evaluate(
{reference_position, reference_position, identity, coordinate_gradient, 1.0 + 2.0 * coordinate_tolerance},
result
) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN
);
CHECK(
compactification.evaluate(
{reference_position, reference_position, identity, coordinate_gradient, -2.0 * coordinate_tolerance}, result
) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN
);
mfem::Vector surface_position = make_vector(0.97, 0.0, 0.0);
CHECK(
compactification.evaluate(
{surface_position, surface_position, identity, coordinate_gradient, -0.5 * coordinate_tolerance}, result
) == mapping::MappingStatus::VALID
);
check_vector(result.physical_position, surface_position, tolerance);
}
TEST_CASE(
"Kelvin Compactification Rejects Invalid Inputs And Inverted Maps",
tags::unit & tags::kelvin
) {
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::Vector reference_position = make_vector(2.0, 0.0, 0.0);
mfem::Vector displaced_position(reference_position);
mfem::Vector coordinate_gradient = make_vector(1.0 / 3.0, 0.0, 0.0);
mfem::Vector zero_gradient = make_vector(0.0, 0.0, 0.0);
const mfem::DenseMatrix identity = make_identity();
mapping::compactification::ExteriorMapResult result;
mfem::Vector wrong_dimension(2);
wrong_dimension = 1.0;
CHECK(
compactification.evaluate(
{wrong_dimension, displaced_position, identity, coordinate_gradient, 1.0 / 3.0}, result
) == mapping::MappingStatus::INVALID_DIMENSION
);
CHECK(
compactification.evaluate(
{reference_position, displaced_position, identity, wrong_dimension, 1.0 / 3.0}, result
) == mapping::MappingStatus::INVALID_DIMENSION
);
mfem::Vector non_finite_position(reference_position);
non_finite_position(1) = std::numeric_limits<double>::quiet_NaN();
CHECK(
compactification.evaluate(
{non_finite_position, displaced_position, identity, coordinate_gradient, 1.0 / 3.0}, result
) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT
);
mfem::Vector non_finite_gradient(coordinate_gradient);
non_finite_gradient(2) = std::numeric_limits<double>::infinity();
CHECK(
compactification.evaluate(
{reference_position, displaced_position, identity, non_finite_gradient, 1.0 / 3.0}, result
) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT
);
CHECK(
compactification.evaluate(
{reference_position, displaced_position, identity, coordinate_gradient,
std::numeric_limits<double>::quiet_NaN()},
result
) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT
);
mfem::DenseMatrix singular_displacement_jacobian(dimension);
singular_displacement_jacobian = 0.0;
CHECK(
compactification.evaluate(
{reference_position, displaced_position, singular_displacement_jacobian, zero_gradient, 1.0 / 3.0}, result
) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE
);
mfem::DenseMatrix inverted_displacement_jacobian = make_identity();
inverted_displacement_jacobian(0, 0) = -1.0;
CHECK(
compactification.evaluate(
{reference_position, displaced_position, inverted_displacement_jacobian, zero_gradient, 1.0 / 3.0}, result
) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE
);
}
TEST_CASE(
"Kelvin Compactification Variation Rejects Invalid Inputs",
tags::unit & tags::kelvin
) {
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
mfem::Vector reference_position = make_vector(2.0, 0.0, 0.0);
mfem::Vector displaced_position(reference_position);
mfem::Vector coordinate_gradient = make_vector(1.0 / 3.0, 0.0, 0.0);
const mfem::DenseMatrix identity = make_identity();
const mapping::compactification::ExteriorMapInput input{
reference_position, displaced_position, identity, coordinate_gradient, 1.0 / 3.0
};
mapping::compactification::ExteriorMapResult result;
REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID);
mfem::Vector valid_position_direction = make_vector(0.01, -0.02, 0.03);
const mfem::DenseMatrix valid_jacobian_direction = make_identity();
mapping::compactification::ExteriorMapVariation variation;
mfem::Vector wrong_dimension(2);
wrong_dimension = 0.0;
CHECK(
compactification.evaluate_variation(input, result, {wrong_dimension, valid_jacobian_direction}, variation) ==
mapping::MappingStatus::INVALID_DIMENSION
);
mfem::DenseMatrix wrong_jacobian_dimension(2);
wrong_jacobian_dimension = 0.0;
CHECK(
compactification.evaluate_variation(
input, result, {valid_position_direction, wrong_jacobian_dimension}, variation
) == mapping::MappingStatus::INVALID_DIMENSION
);
mfem::Vector non_finite_direction(valid_position_direction);
non_finite_direction(0) = std::numeric_limits<double>::quiet_NaN();
CHECK(
compactification.evaluate_variation(
input, result, {non_finite_direction, valid_jacobian_direction}, variation
) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT
);
}
TEST_CASE(
"Kelvin Compactification Evaluations Are Independent",
tags::unit & tags::kelvin
) {
constexpr double tolerance = 0.0;
mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0});
const mfem::DenseMatrix identity = make_identity();
mfem::Vector gradient_a = make_vector(0.12, 0.03, -0.02);
mfem::Vector gradient_b = make_vector(-0.04, 0.15, 0.01);
mfem::Vector reference_a = make_vector(1.5, 0.2, 0.1);
mfem::Vector reference_b = make_vector(2.5, -0.3, 0.4);
const mapping::compactification::ExteriorMapInput input_a{reference_a, reference_a, identity, gradient_a, 0.25};
const mapping::compactification::ExteriorMapInput input_b{reference_b, reference_b, identity, gradient_b, 0.70};
mapping::compactification::ExteriorMapResult first_a;
mapping::compactification::ExteriorMapResult result_b;
mapping::compactification::ExteriorMapResult second_a;
REQUIRE(compactification.evaluate(input_a, first_a) == mapping::MappingStatus::VALID);
REQUIRE(compactification.evaluate(input_b, result_b) == mapping::MappingStatus::VALID);
REQUIRE(compactification.evaluate(input_a, second_a) == mapping::MappingStatus::VALID);
check_vector(first_a.physical_position, second_a.physical_position, tolerance);
check_matrix(first_a.mapping_jacobian, second_a.mapping_jacobian, tolerance);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
mapping_test_sources = files(
'compactification/kelvin.cpp',
'domain_mapper.cpp',
'volume_cache.cpp',
)

View File

@@ -0,0 +1,171 @@
// Ported from MeanField tests/mapping/prepared_cache.cpp.
#include <stdexcept>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include "serif/backend/mfem/mapping/volume_cache.hpp"
#include "serif/tests/test_tags.hpp"
namespace {
using serif::mapping::VolumeMappingContext;
void fill_vector(mfem::Vector& vector, const int dimension, const double offset) {
vector.SetSize(dimension);
for (int i = 0; i < dimension; ++i) {
vector(i) = offset + i;
}
}
void fill_matrix(mfem::DenseMatrix& matrix, const int dimension, const double offset) {
matrix.SetSize(dimension);
for (int j = 0; j < dimension; ++j) {
for (int i = 0; i < dimension; ++i) {
matrix(i, j) = offset + 10 * j + i;
}
}
}
VolumeMappingContext make_context(const int dimension, const double offset, const bool compactified) {
VolumeMappingContext context;
fill_vector(context.mapping.reference_position, dimension, offset + 1);
fill_vector(context.mapping.displaced_position, dimension, offset + 2);
fill_vector(context.mapping.physical_position, dimension, offset + 3);
fill_matrix(context.mapping.displacement_jacobian, dimension, offset + 4);
fill_matrix(context.mapping.mapping_jacobian, dimension, offset + 5);
fill_matrix(context.mapping.inverse_mapping_jacobian, dimension, offset + 6);
fill_matrix(context.quadrature.J_element_to_physical_inverse, dimension, offset + 7);
context.mapping.mapping_determinate = offset + 8;
context.mapping.compactified = compactified;
context.quadrature.mapping_determinate = offset + 9;
context.quadrature.physical_integration_weight = offset + 10;
return context;
}
void check_vector(const mfem::Vector& actual, const mfem::Vector& expected) {
REQUIRE(actual.Size() == expected.Size());
for (int i = 0; i < expected.Size(); ++i) {
CHECK(actual(i) == expected(i));
}
}
void check_matrix(const mfem::DenseMatrix& actual, const mfem::DenseMatrix& expected) {
REQUIRE(actual.Height() == expected.Height());
REQUIRE(actual.Width() == expected.Width());
for (int j = 0; j < expected.Width(); ++j) {
for (int i = 0; i < expected.Height(); ++i) {
CHECK(actual(i, j) == expected(i, j));
}
}
}
void check_context(const VolumeMappingContext& actual, const VolumeMappingContext& expected) {
check_vector(actual.mapping.reference_position, expected.mapping.reference_position);
check_vector(actual.mapping.displaced_position, expected.mapping.displaced_position);
check_vector(actual.mapping.physical_position, expected.mapping.physical_position);
check_matrix(actual.mapping.displacement_jacobian, expected.mapping.displacement_jacobian);
check_matrix(actual.mapping.mapping_jacobian, expected.mapping.mapping_jacobian);
check_matrix(actual.mapping.inverse_mapping_jacobian, expected.mapping.inverse_mapping_jacobian);
check_matrix(
actual.quadrature.J_element_to_physical_inverse, expected.quadrature.J_element_to_physical_inverse
);
CHECK(actual.mapping.mapping_determinate == expected.mapping.mapping_determinate);
CHECK(actual.mapping.compactified == expected.mapping.compactified);
CHECK(actual.quadrature.mapping_determinate == expected.quadrature.mapping_determinate);
CHECK(actual.quadrature.physical_integration_weight == expected.quadrature.physical_integration_weight);
}
} // namespace
TEST_CASE(
"Flat Volume Mapping Cache Preserves Every Context Field",
tags::unit & tags::volume_cache
) {
for (const int dimension : {1, 2, 3}) {
CAPTURE(dimension);
serif::mapping::VolumeMappingCache cache;
cache.set_size(2, dimension);
CHECK(cache.get_point_count() == 2);
CHECK(cache.get_dimension() == dimension);
auto first = make_context(dimension, 0.125, false);
auto second = make_context(dimension, -30.25, true);
cache.store(0, first);
cache.store(1, second);
VolumeMappingContext workspace;
cache.load(1, workspace);
check_context(workspace, second);
const double* inverse_buffer = workspace.quadrature.J_element_to_physical_inverse.HostRead();
const double* position_buffer = workspace.mapping.physical_position.HostRead();
cache.load(0, workspace);
check_context(workspace, first);
CHECK(workspace.quadrature.J_element_to_physical_inverse.HostRead() == inverse_buffer);
CHECK(workspace.mapping.physical_position.HostRead() == position_buffer);
mfem::DenseMatrix inverse;
cache.load_inverse_jacobian(1, inverse);
check_matrix(inverse, second.quadrature.J_element_to_physical_inverse);
const auto copy = cache;
cache.store(1, first);
copy.load(1, workspace);
check_context(workspace, second);
cache.load(1, workspace);
check_context(workspace, first);
cache.set_size(1, 3);
auto resized = make_context(3, 13.5, true);
cache.store(0, resized);
cache.load(0, workspace);
check_context(workspace, resized);
}
}
TEST_CASE(
"Flat Volume Mapping Cache Rejects Invalid Indices And Dimensions",
tags::unit & tags::volume_cache
) {
serif::mapping::VolumeMappingCache cache;
VolumeMappingContext workspace;
CHECK_THROWS_AS(cache.set_size(-1, 3), std::logic_error);
CHECK_THROWS_AS(cache.set_size(1, 0), std::logic_error);
CHECK_THROWS_AS(cache.set_size(1, 4), std::logic_error);
cache.set_size(1, 3);
CHECK_THROWS_AS(cache.load(-1, workspace), std::logic_error);
CHECK_THROWS_AS(cache.load(1, workspace), std::logic_error);
auto wrong_dimension = make_context(2, 0.0, false);
CHECK_THROWS_AS(cache.store(0, wrong_dimension), std::logic_error);
cache.set_size(0, 2);
CHECK(cache.get_point_count() == 0);
CHECK_THROWS_AS(cache.load(0, workspace), std::logic_error);
}
TEST_CASE(
"Flat Volume Mapping Cache Rejects Malformed Packed Fields",
tags::unit & tags::volume_cache
) {
serif::mapping::VolumeMappingCache cache;
cache.set_size(1, 3);
auto context = make_context(3, 0.0, false);
for (auto* vector :
{&context.mapping.reference_position, &context.mapping.displaced_position,
&context.mapping.physical_position}) {
for (const int size : {2, 4}) {
vector->SetSize(size);
CHECK_THROWS_AS(cache.store(0, context), std::logic_error);
}
vector->SetSize(3);
*vector = 0.0;
}
for (auto* matrix :
{&context.mapping.displacement_jacobian, &context.mapping.mapping_jacobian,
&context.mapping.inverse_mapping_jacobian, &context.quadrature.J_element_to_physical_inverse}) {
matrix->SetSize(4, 4);
CHECK_THROWS_AS(cache.store(0, context), std::logic_error);
matrix->SetSize(3, 2);
CHECK_THROWS_AS(cache.store(0, context), std::logic_error);
matrix->SetSize(3, 3);
*matrix = 0.0;
}
CHECK_NOTHROW(cache.store(0, context));
}

View File

@@ -1,22 +1,39 @@
if get_option('build_tests') if get_option('build_tests')
# subdir('discritization') test('mfem_build_policy', python_build,
# args: files('../tools/test_mfem_build_policy.py'), suite: 'build', timeout: 60)
# test_include_dir = include_directories('include') catch2_dep = dependency('catch2-with-main', version: '>=3.0', fallback: ['catch2', 'catch2_with_main_dep'], default_options: ['tests=false', 'install=false'])
#
# catch2_dep = dependency('Catch2', required:true)
# Stroid exchanges MFEM and standard-library objects with the sandboxes.
# Always build it against the parent's MFEM and C++ ABI, never an unrelated
# installed archive (even when pkg-config reports one).
stroid_project = subproject('stroid', default_options: [
'build_tests=false',
'build_tools=false',
'build_python=false',
'pkg_config=false',
])
stroid_dep = stroid_project.get_variable('stroid_dep')
#
# test_sources = discretization_test_sources
# executable('serif_tests', test_sources, dependencies: [serif_dep, catch2_dep, stroid_dep], include_directories: test_include_dir)
subdir('sandbox') subdir('discritization')
subdir('mapping')
subdir('eos')
subdir('quadrature')
subdir('equations')
subdir('surface')
subdir('deformation')
subdir('utils')
subdir('normalization')
subdir('preconditioner')
subdir('model')
subdir('solver')
subdir('serialize')
test_include_dir = include_directories('include')
test_sources = discretization_test_sources + mapping_test_sources + eos_test_sources + quadrature_test_sources + equation_test_sources + surface_test_sources + deformation_test_sources + normalization_test_sources
serif_tests = executable(
'serif_tests',
test_sources,
dependencies: [serif_dep, catch2_dep, stroid_dep],
include_directories: test_include_dir,
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
)
test('serif_unit_tests', serif_tests, args: ['[unit]~[vector_gradient]'], suite: 'unit', timeout: 120)
# These exhaustive dense/tensor comparisons have their own budget without reducing the ordinary unit checks.
test('serif_vector_gradient_tests', serif_tests, args: ['[unit][vector_gradient]'], suite: 'unit', timeout: 300, is_parallel: false)
test('serif_integration_tests', serif_tests, args: ['[integration]~[nonconforming]~[serial_backend]'], suite: 'integration', timeout: 300)
test('serif_serial_backend_tests', serif_tests, args: ['[integration][serial_backend]~[nonconforming]'], suite: 'integration', timeout: 600)
# Keep native nonconforming studies together without shortening the existing tests' budgets.
test('serif_nonconforming_tests', serif_tests, args: ['[integration][nonconforming]'], suite: 'integration', timeout: 1200, is_parallel: false)
subdir('sandbox')
endif endif

View File

@@ -0,0 +1,285 @@
#include <algorithm>
#include <memory>
#include <string_view>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/tests/equations/fixture.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace execution = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace equations = serif::equations;
namespace surface = serif::geometry::surface;
namespace quantities = serif::dimensions;
namespace tests = serif::tests::equations;
namespace gas = serif::tests::surface;
namespace boundary = gas::boundary;
namespace blocks = serif::discretization::blocks;
using Displacement = blocks::displacement::vector;
using Kind = blocks::RepresentedValueKind;
using Vector = mfem::Vector;
struct Material : blocks::field {
static constexpr std::string_view name = "test thermodynamic fields";
};
/** @brief Add the runtime names needed by volume quadrature to the existing boundary test terms. */
template <typename Quantity>
struct ScalarField : boundary::ContinuousScalar<Quantity, 3> {
using field_type = Material;
static constexpr std::string_view name = "test scalar thermodynamic field";
};
using Density = ScalarField<quantities::MassDensity>;
using Temperature = ScalarField<quantities::Temperature>;
using Pressure = ScalarField<quantities::Pressure>;
/** @brief Distinguish a uniform thermodynamic state from a global constraint with coincident units. */
struct UniformTemperature : boundary::UniformTemperature {
using field_type = Material;
static constexpr std::string_view name = "prescribed uniform temperature";
static constexpr bool thermodynamic_state = true;
};
/** @brief Keep the interior equation independent of EOS selection so the boundary must supply its own relation. */
template <typename Field>
struct FieldEquation : equations::Contribution<serif::discretization::forms::Operand<Field>> {
using Value = serif::discretization::forms::Operand<Field>;
using Operands = serif::utils::types::TypeList<Value>;
using ResidualQuantity = typename Field::Quantity;
static constexpr std::string_view name = "independent scalar test equation";
/** @brief A linear weak equation gives a nonzero interior that the surface must replace. */
[[nodiscard]] equations::PointLinearization<Operands> evaluate(const equations::EquationPoint<Operands>& point) const {
equations::PointLinearization<Operands> result;
result.residual = point.fields.template get<Field>();
result.field_derivatives.template get<Field>() = 1.0;
return result;
}
/** @brief The scalar coefficient introduces no additional quadrature degree. */
[[nodiscard]] int additional_order(Value, int) const {
return 0;
}
};
/** @brief Supply row ownership only when two different interior rows could carry a pressure residual. */
struct DensityEquationWithSurface : FieldEquation<Density> {
using IsobaricSurfaceField = Density;
};
/** @brief Expose the forward ideal-gas relation without advertising any inverse. */
struct ForwardGasEOS : gas::GasEOS {
using Relations = serif::eos::relations::RelationCatalog<gas::PressureFromDensityAndTemperature>;
};
/** @brief A pressure field already represents the boundary quantity even without numerical EOS relations. */
struct SymbolicEOS {
};
struct Fixture : tests::Fixture {
serif::discretization::backend::mfem::TermBinding<Density, tests::Schema> density{enthalpy_space};
serif::discretization::backend::mfem::TermBinding<Temperature, tests::Schema> temperature{enthalpy_space};
serif::discretization::backend::mfem::TermBinding<Pressure, tests::Schema> pressure{enthalpy_space};
serif::discretization::backend::mfem::TermBinding<UniformTemperature, tests::Schema> uniform_temperature{mesh};
/** @brief Reuse established volume resources with an independently marked x=0 stellar surface. */
Fixture() : tests::Fixture(boundary::make_boundary_mesh()) {
}
};
/** @brief Retain only the physical bindings needed by a particular model. */
template <typename... Bindings>
[[nodiscard]] auto discretize(const std::shared_ptr<Fixture>& fixture, Bindings... bindings) {
return backend::make_stellar_discretization(fixture,
dependencies::make_dependency_context(std::move(bindings)..., dependencies::borrow<Displacement>(fixture->displacement_binding)),
fixture->compactification, fixture->mapper);
}
/** @brief Save a complete residual before another candidate replaces the graph's current result. */
[[nodiscard]] Vector residual(const backend::PhysicalSystem& system) {
Vector values(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), values.GetData());
return values;
}
/** @brief Exercise the complete assembled action, transpose, cache reuse and retained evaluation lifetime. */
template <typename Evaluator, typename Coefficients>
void check_linearization(Evaluator& evaluator, const Coefficients& coefficients, const Vector& displacement, Vector& state) {
auto evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
const auto linearization = *evaluation;
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
CHECK(*evaluation == linearization);
CHECK(evaluator.cache().report().computed_count() == 0);
const Vector original(state);
const Vector direction = tests::deterministic(state.Size(), 0.6);
const Vector dual = tests::deterministic(static_cast<int>(linearization->residual().size()), 1.1);
Vector action(dual.Size()), transpose(state.Size());
linearization->jacobian().action()->Mult(direction, action);
linearization->jacobian().action()->MultTranspose(dual, transpose);
CHECK((dual * action) == Catch::Approx(direction * transpose).margin(2.0e-12));
constexpr double step = 1.0e-5;
state.Add(step, direction);
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
Vector difference = residual(**evaluation);
state = original;
state.Add(-step, direction);
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
difference -= residual(**evaluation);
difference /= 2.0 * step;
for (int row = 0; row < action.Size(); ++row) {
CHECK(action[row] == Catch::Approx(difference[row]).margin(2.0e-9));
}
state = original;
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
Vector frozen(action.Size());
linearization->jacobian().action()->Mult(direction, frozen);
for (int row = 0; row < action.Size(); ++row) {
CHECK(frozen[row] == action[row]);
}
}
/** @brief Infer a density carrier while acquiring prescribed temperature solely from the supplied physical state. */
template <typename EOS, bool Inverse>
void check_prescribed_temperature() {
auto fixture = std::make_shared<Fixture>();
const auto discretization = discretize(fixture, dependencies::borrow<Density>(fixture->density), dependencies::borrow<UniformTemperature>(fixture->uniform_temperature));
Vector density = boundary::sequence(fixture->density.size(), 0.8, 0.3);
Vector temperature = tests::constant(1, 1.7);
const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0);
const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->density, density), execution::bind(fixture->uniform_temperature, temperature));
const auto star = model::make_stellar_model(EOS{}, model::EquilibriumEquations {
.interior_equations = equations::EquationRegistry{FieldEquation<Density>{}},
.shape_force = model::NoShapeForce{},
.surface_condition = surface::Isobaric{quantities::PressureValue{0.8}}
});
using Definition = typename decltype(star)::Equations;
static_assert(std::same_as<typename Definition::SurfaceCondition, surface::Isobaric>);
auto evaluator = backend::bind_stellar_model<dependencies::KeyList<UniformTemperature>>(star, discretization, coefficients, displacement);
static_assert(decltype(evaluator)::Plan::has_surface);
using Formulation = typename decltype(evaluator)::SurfaceFormulation;
STATIC_CHECK(std::same_as<typename Formulation::CarrierTerm, Density>);
STATIC_CHECK(Formulation::resolves_carrier == Inverse);
CHECK(evaluator.layout().template total_size<Kind::value>() == static_cast<std::size_t>(density.Size()));
STATIC_CHECK_FALSE(dependencies::ContainsKey<UniformTemperature, typename decltype(evaluator)::Plan::Values>);
const model::StellarState packed_state(evaluator.layout(), std::vector<double>(density.GetData(), density.GetData() + density.Size()));
const auto prescribed = execution::make_term_coefficients(execution::bind(fixture->uniform_temperature, temperature));
const auto initial_evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(initial_evaluation);
using InteriorResult = execution::volume_keys::Scoped<Density>::Linearization;
const Vector selected = boundary::boundary_mask(fixture->density);
for (double thermal : {1.7, 2.1}) {
const auto interior = evaluator.cache().template share<InteriorResult>();
temperature[0] = thermal;
const auto evaluation = evaluator.evaluate(packed_state, displacement, prescribed);
REQUIRE(evaluation);
CHECK(evaluator.cache().template share<InteriorResult>() == interior);
CHECK((*evaluation)->jacobian().action()->Width() == density.Size());
if (thermal == 1.7) {
CHECK(*evaluation == *initial_evaluation);
CHECK(evaluator.cache().report().computed_count() == 0);
} else {
CHECK(*evaluation != *initial_evaluation);
}
const auto external_evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(external_evaluation);
CHECK(*external_evaluation == *evaluation);
CHECK(evaluator.cache().report().computed_count() == 0);
int boundary_rows = 0;
for (int row = 0; row < density.Size(); ++row) {
if (selected[row] != 0.0) {
++boundary_rows;
const double expected = Inverse ? density[row] - 0.8 / thermal : density[row] * thermal - 0.8;
CHECK((*evaluation)->residual()[row] == Catch::Approx(expected).margin(2.0e-13));
}
}
REQUIRE(boundary_rows > 0);
REQUIRE(boundary_rows < density.Size());
check_linearization(evaluator, coefficients, displacement, density);
}
}
} // namespace
TEST_CASE("Isobaric infers a pressure residual from a forward-only non-enthalpy EOS", "[model][surface][integration]") {
check_prescribed_temperature<ForwardGasEOS, false>();
}
TEST_CASE("Isobaric prefers an available non-enthalpy inverse with prescribed boundary-only input", "[model][surface][integration]") {
check_prescribed_temperature<gas::GasEOS, true>();
}
TEST_CASE("Isobaric uses an existing pressure field without requiring EOS relations", "[model][surface][integration]") {
auto fixture = std::make_shared<Fixture>();
const auto discretization = discretize(fixture, dependencies::borrow<Pressure>(fixture->pressure));
Vector pressure = boundary::sequence(fixture->pressure.size(), 0.9, 0.1);
const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0);
const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->pressure, pressure));
const auto star = model::make_stellar_model(SymbolicEOS{}, model::EquilibriumEquations {
.interior_equations = equations::EquationRegistry{FieldEquation<Pressure>{}},
.shape_force = model::NoShapeForce{},
.surface_condition = surface::Isobaric{quantities::PressureValue{0.7}}
});
auto evaluator = backend::bind_stellar_model(star, discretization, coefficients, displacement);
using Formulation = typename decltype(evaluator)::SurfaceFormulation;
STATIC_CHECK(std::same_as<typename Formulation::CarrierTerm, Pressure>);
const auto evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
const Vector selected = boundary::boundary_mask(fixture->pressure);
int boundary_rows = 0;
for (int row = 0; row < pressure.Size(); ++row) {
if (selected[row] != 0.0) {
++boundary_rows;
CHECK((*evaluation)->residual()[row] == Catch::Approx(pressure[row] - 0.7).margin(2.0e-13));
}
}
REQUIRE(boundary_rows > 0);
check_linearization(evaluator, coefficients, displacement, pressure);
}
TEST_CASE("An equation can name its isobaric field when two continuous rows are eligible", "[model][surface][integration]") {
auto fixture = std::make_shared<Fixture>();
const auto discretization = discretize(fixture, dependencies::borrow<Temperature>(fixture->temperature), dependencies::borrow<Density>(fixture->density));
const int size = fixture->density.size();
Vector state(2 * size);
Vector temperature(state.GetData(), size), density(state.GetData() + size, size);
const Vector initial_temperature = boundary::sequence(size, 1.7, 0.2);
const Vector initial_density = boundary::sequence(size, 0.8, 0.4);
temperature = initial_temperature;
density = initial_density;
const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0);
const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->temperature, temperature), execution::bind(fixture->density, density));
const auto star = model::make_stellar_model(ForwardGasEOS{}, model::EquilibriumEquations {
.interior_equations = equations::EquationRegistry{FieldEquation<Temperature>{}, DensityEquationWithSurface{}},
.shape_force = model::NoShapeForce{},
.surface_condition = surface::Isobaric{quantities::PressureValue{0.8}}
});
auto evaluator = backend::bind_stellar_model(star, discretization, coefficients, displacement);
using Formulation = typename decltype(evaluator)::SurfaceFormulation;
STATIC_CHECK(std::same_as<typename Formulation::CarrierTerm, Density>);
CHECK(evaluator.layout().template offset<Kind::value, Temperature>() == 0);
CHECK(evaluator.layout().template offset<Kind::value, Density>() == static_cast<std::size_t>(size));
const auto evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
const int row_offset = static_cast<int>(evaluator.layout().template offset<Kind::residual, Density>());
const Vector selected = boundary::boundary_mask(fixture->density);
for (int row = 0; row < size; ++row) {
if (selected[row] != 0.0) {
CHECK((*evaluation)->residual()[row_offset + row] == Catch::Approx(density[row] * temperature[row] - 0.8).margin(2.0e-13));
}
}
check_linearization(evaluator, coefficients, displacement, state);
}

272
tests/model/description.cpp Normal file
View File

@@ -0,0 +1,272 @@
#include <cmath>
#include <limits>
#include <memory>
#include <string>
#include <type_traits>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/model/description.hpp"
#include "serif/model/eos_reference.hpp"
#include "serif/tests/equations/test_equations.hpp"
#include "serif/equations/registry.hpp"
#include "serif/geometry/surface/isobaric.hpp"
namespace
{
namespace model = serif::model;
namespace dimensions = serif::dimensions;
struct SymbolicEOS
{
double parameter;
};
struct MoveOnlyEOS
{
std::unique_ptr<double> parameter;
};
struct MoveOnlyConstraint
{
using ScalarDescription = model::ScalarConstraint<dimensions::Mass, dimensions::SpecificEnthalpy, dimensions::Mass,
"move.coordinate", "C", "move.residual", "R">;
using ModelDefinition = model::SpecificationDefinition<MoveOnlyConstraint, "MoveOnlyConstraint", model::SpecificationRole::invariant,
model::GeneratedStateKind::multiplier, ScalarDescription>;
std::unique_ptr<double> mass;
/** @brief Expose a dimensional target while preserving unique ownership. */
dimensions::MassValue target() const
{
return dimensions::MassValue{*mass};
}
};
struct MoveOnlyPhysicalEOS
{
using Relation = serif::eos::relations::DensityFromSpecificEnthalpy;
using Relations = serif::eos::relations::RelationCatalog<Relation>;
std::unique_ptr<double> coefficient;
/** @brief Supply an independent linear closure with a uniquely owned material parameter. */
dimensions::DensityValue evaluate(Relation, dimensions::SpecificEnthalpyValue enthalpy) const noexcept
{
return dimensions::DensityValue{*coefficient * enthalpy.value()};
}
/** @brief Supply the exact derivative while retaining the EOS relation contract. */
dimensions::PartialDerivative<dimensions::MassDensity, dimensions::SpecificEnthalpy> partial_derivative(Relation, dimensions::WithRespectTo<dimensions::SpecificEnthalpy>, dimensions::SpecificEnthalpyValue) const noexcept
{
return dimensions::PartialDerivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>{*coefficient};
}
};
/** @brief Preserve surface members through aggregate deduction and the substitution performed by a templated factory. */
template <typename EOS>
[[nodiscard]] auto model_with_surface(EOS eos, dimensions::PressureValue target)
{
return model::make_stellar_model(std::move(eos), model::EquilibriumEquations
{
.interior_equations = serif::equations::EquationRegistry{::serif::tests::equations::BarotropicClosure{serif::eos::models::Polytrope{2.0, 0.3}}},
.shape_force = model::NoShapeForce{},
.surface_condition = serif::geometry::surface::Isobaric{target}
});
}
}
TEST_CASE("Templated model factories retain the declared surface type and physical pressure", "[model][unit][surface]")
{
const auto star = model_with_surface(SymbolicEOS{3.0}, dimensions::PressureValue{0.8});
using Description = typename decltype(star)::Equations;
using Surface = serif::geometry::surface::Isobaric;
STATIC_CHECK(std::same_as<typename Description::SurfaceCondition, Surface>);
STATIC_CHECK(std::same_as<typename Description::ShapeForce, model::NoShapeForce>);
STATIC_CHECK(std::same_as<std::remove_cvref_t<decltype(star.equations().surface_condition)>, Surface>);
CHECK(star.equations().surface_condition.target_pressure().value() == 0.8);
const auto equations = model::resolve_equilibrium_equations(star);
STATIC_CHECK(std::same_as<typename decltype(equations)::SurfaceCondition, Surface>);
CHECK(&equations.surface_condition == &star.equations().surface_condition);
CHECK(equations.surface_condition.target_pressure().value() == 0.8);
}
TEST_CASE("Stellar descriptions retain typed physics in canonical order", "[model][unit]")
{
const auto star = model::make_stellar_model(SymbolicEOS{3.0}, model::EquilibriumEquations{17},
model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.0}},
model::FixedTotalMass{dimensions::MassValue{1.5}});
CHECK(star.equation_of_state().parameter == 3.0);
CHECK(star.equations().interior_equations == 17);
CHECK(star.specification<model::FixedTotalMass>().target().value() == 1.5);
CHECK(std::tuple_size_v<decltype(star.specifications_for_role<model::SpecificationRole::invariant>())> == 2);
const auto descriptors = star.specification_descriptors();
REQUIRE(descriptors.size() == 2);
CHECK(descriptors[0].name == "FixedTotalMass");
CHECK(descriptors[1].name == "FixedAngularMomentum");
std::vector<double> targets;
star.visit_specifications([&](const auto& specification)
{
if constexpr (requires { specification.target(); })
{
targets.push_back(specification.target().value());
}
else
{
targets.push_back(specification.parameter);
}
});
CHECK(targets == std::vector<double>{3.0, 1.5, 0.0});
}
TEST_CASE("Explicit equation definitions read one authoritative EOS and target at binding", "[model][unit]")
{
int resolutions = 0;
const auto definition = [&resolutions](const auto& physics)
{
++resolutions;
return model::EquilibriumEquations{physics.equation_of_state().parameter + physics.template specification<model::FixedTotalMass>().target().value()};
};
const auto star = model::make_stellar_model(SymbolicEOS{3.0}, definition, model::FixedTotalMass{dimensions::MassValue{2.0}});
CHECK(resolutions == 0);
const auto equations = model::resolve_equilibrium_equations(star);
CHECK(resolutions == 1);
CHECK(equations.interior_equations == 5.0);
const auto changed = star.transform_specifications([](const auto& physics)
{
using Physics = std::remove_cvref_t<decltype(physics)>;
if constexpr (std::same_as<Physics, model::FixedTotalMass>)
{
return Physics{dimensions::MassValue{7.0}};
}
else
{
return physics;
}
});
CHECK(model::resolve_equilibrium_equations(changed).interior_equations == 10.0);
CHECK(model::resolve_equilibrium_equations(star).interior_equations == 5.0);
}
TEST_CASE("Equation composition resolves member definitions and borrows move-only stored equations", "[model][unit]")
{
const auto definitions = model::EquilibriumEquations
{
.interior_equations = [](const auto& physics)
{
return std::make_unique<double>(physics.equation_of_state().parameter);
},
.shape_force = std::make_unique<int>(8)
};
const auto star = model::make_stellar_model(SymbolicEOS{4.0}, model::EquilibriumEquations
{
.interior_equations = definitions.interior_equations,
.shape_force = std::make_unique<int>(8)
});
const auto equations = model::resolve_equilibrium_equations(star);
STATIC_CHECK(std::is_reference_v<decltype(equations.shape_force)>);
STATIC_CHECK_FALSE(std::is_reference_v<decltype(equations.interior_equations)>);
CHECK(*equations.interior_equations == 4.0);
CHECK(&equations.shape_force == &star.equations().shape_force);
CHECK(*equations.shape_force == 8);
}
TEST_CASE("Move-only physical prescriptions survive construction and explicit reconstruction", "[model][unit]")
{
const auto star = model::make_stellar_model(MoveOnlyEOS{std::make_unique<double>(3.0)}, model::EquilibriumEquations{std::make_unique<int>(5)},
MoveOnlyConstraint{std::make_unique<double>(2.0)}, model::FixedTotalMass{dimensions::MassValue{7.0}});
CHECK(*star.equation_of_state().parameter == 3.0);
CHECK(star.specification<MoveOnlyConstraint>().target().value() == 2.0);
const auto changed = star.transform_specifications([](const auto& physics)
{
using Physics = std::remove_cvref_t<decltype(physics)>;
if constexpr (std::same_as<Physics, MoveOnlyEOS>)
{
return Physics{std::make_unique<double>(*physics.parameter)};
}
else if constexpr (std::same_as<Physics, MoveOnlyConstraint>)
{
return Physics{std::make_unique<double>(11.0)};
}
else
{
return physics;
}
}, model::EquilibriumEquations{std::make_unique<int>(9)});
CHECK(changed.specification<MoveOnlyConstraint>().target().value() == 11.0);
CHECK(star.specification<MoveOnlyConstraint>().target().value() == 2.0);
CHECK(*model::resolve_equilibrium_equations(changed).interior_equations == 9);
}
TEST_CASE("Fixed physical targets reject invalid numbers and retain independent coordinate units", "[model][unit][validation]")
{
for (double invalid : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()})
{
CHECK_THROWS_AS(model::FixedTotalMass{dimensions::MassValue{invalid}}, std::invalid_argument);
if (invalid != 0.0)
{
CHECK_THROWS_AS(model::FixedAngularMomentum{dimensions::AngularMomentumValue{invalid}}, std::invalid_argument);
}
}
CHECK_NOTHROW(model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.0}});
CHECK_THROWS_AS(model::FixedAngularMomentum(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{1.0}, .axis = {0.0, 0.0, 0.0}}), std::invalid_argument);
for (double invalid : {std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()})
{
for (std::size_t component = 0; component < 3; ++component)
{
model::FixedAngularMomentum::Parameters invalid_axis{.angular_momentum = dimensions::AngularMomentumValue{1.0}};
invalid_axis.axis[component] = invalid;
CHECK_THROWS_AS(model::FixedAngularMomentum{invalid_axis}, std::invalid_argument);
model::FixedAngularMomentum::Parameters invalid_center{.angular_momentum = dimensions::AngularMomentumValue{1.0}};
invalid_center.center[component] = invalid;
CHECK_THROWS_AS(model::FixedAngularMomentum{invalid_center}, std::invalid_argument);
}
}
const model::FixedAngularMomentum prescribed(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{2.0}, .axis = {3.0, 4.0, 0.0}, .center = {1.0, 2.0, 3.0}});
CHECK(prescribed.axis()[0] == Catch::Approx(0.6));
CHECK(prescribed.axis()[1] == Catch::Approx(0.8));
CHECK(prescribed.center() == std::array<double, 3>{1.0, 2.0, 3.0});
for (double scale : {std::numeric_limits<double>::max(), std::numeric_limits<double>::denorm_min()})
{
const model::FixedAngularMomentum extreme(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{0.0}, .axis = {scale, scale, 0.0}});
CHECK(extreme.axis()[0] == Catch::Approx(std::sqrt(0.5)));
CHECK(extreme.axis()[1] == Catch::Approx(std::sqrt(0.5)));
}
}
TEST_CASE("Typed EOS references retain owners and exact relation capabilities", "[model][unit][eos]")
{
auto star = model::make_stellar_model(MoveOnlyPhysicalEOS{std::make_unique<double>(2.0)}, model::EquilibriumEquations{1});
using Model = decltype(star);
std::shared_ptr<const Model> owner = std::make_shared<Model>(std::move(star));
std::weak_ptr<const Model> lifetime = owner;
const auto shared = model::share_equation_of_state(owner);
STATIC_CHECK(serif::eos::models::EOSSupportingPartialDerivative<decltype(shared), serif::eos::relations::DensityFromSpecificEnthalpy, dimensions::SpecificEnthalpy>);
CHECK(&shared.get() == &owner->equation_of_state());
owner.reset();
CHECK_FALSE(lifetime.expired());
CHECK(serif::eos::evaluate<dimensions::MassDensity>(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 6.0);
CHECK(serif::eos::partial_derivative<dimensions::MassDensity, dimensions::SpecificEnthalpy>(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 2.0);
STATIC_CHECK(noexcept(shared.evaluate(MoveOnlyPhysicalEOS::Relation{}, dimensions::SpecificEnthalpyValue{1.0})));
CHECK_THROWS_AS(model::EosReference<MoveOnlyPhysicalEOS>(std::shared_ptr<const MoveOnlyPhysicalEOS>{}), std::invalid_argument);
CHECK_THROWS_AS(model::share_equation_of_state(std::shared_ptr<const Model>{}), std::invalid_argument);
}
TEST_CASE("Programmer supplied physical equation recipes share the model EOS", "[model][unit][eos]")
{
const auto star = model::make_stellar_model(MoveOnlyPhysicalEOS{std::make_unique<double>(4.0)}, model::EquilibriumEquations
{
.interior_equations = [](const auto& physics)
{
return serif::equations::EquationRegistry{::serif::tests::equations::BarotropicClosure{model::borrow_eos(physics.equation_of_state()), 1.0}};
}
});
const auto equations = model::resolve_equilibrium_equations(star);
using Closure = ::serif::tests::equations::BarotropicClosure<model::EosReference<MoveOnlyPhysicalEOS>>;
serif::equations::EquationPoint<Closure::Operands> point;
point.fields.get<Closure::Test>() = 11.0;
point.fields.get<Closure::Enthalpy>() = 2.0;
const auto linearization = equations.interior_equations.get<Closure>().evaluate(point);
CHECK(linearization.residual == 3.0);
CHECK(linearization.field_derivatives.get<Closure::Enthalpy>() == -4.0);
}

View File

@@ -0,0 +1,463 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <span>
#include <tuple>
#include <variant>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/equations/angular_momentum.hpp"
#include "serif/equations/barotropic_density.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/equations/surface_potential.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/backend/mfem/model/discretization.hpp"
#include "serif/backend/mfem/model/graph.hpp"
#include "serif/tests/equations/gravity_fixture.hpp"
#include "serif/tests/deformation/mesh.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace execution = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace equations = serif::equations;
namespace surface = serif::geometry::surface;
namespace deformation = serif::geometry::deformation;
namespace blocks = serif::discretization::blocks;
namespace test = serif::tests::equations;
using Polytrope = serif::eos::models::Polytrope;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using Constant = blocks::barotropic_constant::mass_normalization;
using Omega = blocks::rotation::angular_velocity;
using Shape = blocks::surface_deformation::coefficients;
using Displacement = blocks::displacement::vector;
using Kind = blocks::RepresentedValueKind;
using Vector = mfem::Vector;
/** @brief Keep the physical composition explicit while exercising the public binding factory. */
template <bool SolveOmega>
[[nodiscard]] auto make_physical_model() {
const Polytrope eos{2.0, 0.3};
const auto rotation = []() {
if constexpr (SolveOmega) {
return std::tuple{equations::AngularMomentumConstraint{0.14}};
} else {
return std::tuple{};
}
}();
const auto interior = std::apply([](auto... equation) {
return equations::EquationRegistry{std::move(equation)...};
}, std::tuple_cat(std::tuple{equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}}, equations::PrimalPoissonBalance{2.3}, equations::MassNormalization{1.1}}, rotation));
const auto definition = model::EquilibriumEquations {
.interior_equations = interior,
.shape_force = equations::SurfacePotentialBalance{},
.surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}}
};
const auto invariants = [&]() {
if constexpr (SolveOmega) {
return std::tuple{model::FixedAngularMomentum{serif::dimensions::AngularMomentumValue{0.14}}};
} else {
return std::tuple{};
}
}();
return std::apply([&](const auto&... invariant) {
return model::make_stellar_model(eos, definition, model::FixedTotalMass{serif::dimensions::MassValue{1.1}}, invariant...);
}, invariants);
}
/** @brief Keep the fixed-mass model's surface and exterior resources under one lifetime owner. */
struct Fixture {
stroid::StroidMesh generated{serif::tests::deformation::make_mesh()};
test::gravity::GravityFixture fields{mfem::Mesh(*generated.mesh)};
mfem::FiniteElementSpace reference_displacement{&fields.mesh, &fields.enthalpy_collection, 3, mfem::Ordering::byNODES};
mfem::FiniteElementSpace compactification_space{&fields.mesh, generated.exterior_coordinate->space->FEColl()};
mfem::GridFunction compactification{&compactification_space};
Fixture() {
REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size());
compactification = *generated.exterior_coordinate->values;
}
};
/** @brief Borrow bindings and the existing radial mesh extension from their retained owner. */
[[nodiscard]] auto discretize(std::shared_ptr<Fixture> fixture) {
const auto& fields = fixture->fields;
const auto bindings = dependencies::make_dependency_context(
dependencies::borrow<Density>(fields.density_binding), dependencies::borrow<Potential>(fields.potential_binding),
dependencies::borrow<Constant>(fields.constant_binding), dependencies::borrow<Omega>(fields.angular_velocity_binding),
dependencies::borrow<Displacement>(fields.displacement_binding));
const auto reference = deformation::backend::mfem::deformation_discretization(
fields.enthalpy_space, fixture->reference_displacement, *fixture->generated.reference_mesh);
const deformation::DomainDeformation<deformation::SphericalHarmonicSurface> geometry{
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}
};
auto discretization = backend::with_deformation(
backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference);
return std::make_shared<const decltype(discretization)>(std::move(discretization));
}
/** @brief A persistent coefficient view follows the independent state ordering declared by the plan. */
template <typename Term, typename Layout>
[[nodiscard]] Vector field(Vector& state, const Layout& layout) {
return Vector(state.GetData() + layout.template offset<Kind::value, Term>(), static_cast<int>(layout.template size<Kind::value, Term>()));
}
/** @brief Compare every residual row so scalar constraints cannot disappear behind a volume norm. */
void check_close(const Vector& actual, const Vector& expected, const double tolerance) {
REQUIRE(actual.Size() == expected.Size());
for (int index = 0; index < actual.Size(); ++index) {
CHECK(actual[index] == Catch::Approx(expected[index]).margin(tolerance));
}
}
/** @brief Copy an immutable evaluated residual into independent storage for finite differences. */
[[nodiscard]] Vector residual(const backend::PhysicalSystem& system) {
Vector result(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), result.GetData());
return result;
}
/** @brief A rejected candidate must expose neither stale results nor a stale work report. */
template <typename Evaluator>
void check_unavailable(const Evaluator& evaluator) {
CHECK_FALSE(evaluator.cache().ready());
CHECK_FALSE(evaluator.cache().template contains<model::keys::PhysicalSystem>());
CHECK_THROWS_AS(evaluator.cache().template get<model::keys::PhysicalSystem>(), std::logic_error);
CHECK_THROWS_AS(evaluator.cache().template share<model::keys::PhysicalSystem>(), std::logic_error);
CHECK(evaluator.cache().report().computed_count() == 0);
CHECK(evaluator.cache().report().reused_count() == 0);
}
/** @brief Check physical assembly and candidate publication through the public stellar-model interface. */
template <bool SolveOmega>
void check_equilibrium_graph() {
std::weak_ptr<Fixture> resources;
std::shared_ptr<const backend::PhysicalSystem> retained;
Vector retained_direction, retained_action;
{
auto fixture = std::make_shared<Fixture>();
resources = fixture;
const auto discretization = discretize(fixture);
const auto physical = std::make_shared<const decltype(make_physical_model<SolveOmega>())>(make_physical_model<SolveOmega>());
using Description = std::remove_cvref_t<decltype(physical->equations())>;
using Prescribed = std::conditional_t<SolveOmega, dependencies::KeyList<>, dependencies::KeyList<Omega>>;
using Plan = model::EquilibriumPlan<Polytrope, Description, Prescribed>;
const std::array initial_shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002};
const auto layout = backend::equilibrium_layout<Plan>(*discretization, initial_shape);
REQUIRE(layout.template total_size<Kind::value>() == layout.template total_size<Kind::residual>());
STATIC_CHECK_FALSE(dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>);
const int size = static_cast<int>(layout.template total_size<Kind::value>());
Vector state(size);
Vector density = field<Density>(state, layout);
Vector potential = field<Potential>(state, layout);
Vector constant = field<Constant>(state, layout);
Vector omega = [&]() {
if constexpr (SolveOmega) {
return field<Omega>(state, layout);
} else {
return test::constant(1, 0.31);
}
}();
Vector shape = field<Shape>(state, layout);
REQUIRE(static_cast<std::size_t>(shape.Size()) == initial_shape.size());
std::copy(initial_shape.begin(), initial_shape.end(), shape.GetData());
const auto geometry = [&]() { return std::span<const double>{shape.GetData(), static_cast<std::size_t>(shape.Size())}; };
// Copy from lvalues: moving an MFEM vector would detach these views from the packed state.
const Vector initial_density = fixture->fields.density();
const Vector initial_potential = fixture->fields.potential();
density = initial_density;
potential = initial_potential;
constant = 0.72;
omega = 0.31;
const auto make_coefficients = [&](const auto& density_binding, const auto& omega_binding) {
return execution::make_term_coefficients(
execution::bind(density_binding, density), execution::bind(fixture->fields.potential_binding, potential),
execution::bind(fixture->fields.constant_binding, constant), execution::bind(omega_binding, omega));
};
const auto coefficients = make_coefficients(fixture->fields.density_binding, fixture->fields.angular_velocity_binding);
const auto bound = backend::bind_model_definition<Prescribed>(physical, discretization, coefficients);
REQUIRE(bound->boundary->selected_rows->size() > 0);
REQUIRE(bound->boundary->selected_rows->size() < density.Size());
auto evaluator = backend::bind_stellar_model<Prescribed>(*physical, *discretization, coefficients, geometry());
check_unavailable(evaluator);
const double initial_value = density[0];
density[0] = std::numeric_limits<double>::quiet_NaN();
REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry()));
check_unavailable(evaluator);
CHECK(evaluator.cache().preparation_count() == 0);
density[0] = initial_value;
auto evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
const auto initial_system = *evaluation;
const Vector original_state(state);
const Vector original_residual = residual(*initial_system);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
const Vector frozen_direction = test::deterministic(size, 0.8);
Vector frozen_action, frozen_transpose;
initial_system->jacobian().action()->Mult(frozen_direction, frozen_action);
initial_system->jacobian().action()->MultTranspose(frozen_direction, frozen_transpose);
// Changing input representation must not create a new physical candidate or invalidate reusable work.
const model::StellarState packed_state(layout, std::vector<double>(state.GetData(), state.GetData() + state.Size()));
const auto prescribed = [&]() {
if constexpr (SolveOmega) {
return dependencies::make_dependency_context();
} else {
return execution::make_term_coefficients(execution::bind(fixture->fields.angular_velocity_binding, omega));
}
}();
STATIC_CHECK(dependencies::ContainsKey<Omega, typename decltype(evaluator)::Plan::Values> == SolveOmega);
evaluation = evaluator.evaluate(packed_state, prescribed);
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
// Identical compile-time coordinates can still describe incompatible meshes or equation sizes.
for (const bool wrong_state_size : {true, false}) {
CAPTURE(SolveOmega, wrong_state_size);
auto incompatible_layout = layout;
if (wrong_state_size) {
++incompatible_layout.state_sizes[0];
} else {
++incompatible_layout.residual_sizes[0];
}
const model::StellarState incompatible_state(incompatible_layout);
const auto preparations = evaluator.cache().preparation_count();
CHECK_THROWS_WITH(evaluator.evaluate(incompatible_state, prescribed), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
check_unavailable(evaluator);
CHECK(evaluator.cache().preparation_count() == preparations);
check_close(residual(*initial_system), original_residual, 0.0);
Vector action, transpose;
initial_system->jacobian().action()->Mult(frozen_direction, action);
initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose);
check_close(action, frozen_action, 0.0);
check_close(transpose, frozen_transpose, 0.0);
evaluation = evaluator.evaluate(packed_state, prescribed);
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
}
const std::array fields{&density, &potential, &constant, &omega};
for (std::size_t field_index = 0; field_index < fields.size(); ++field_index) {
Vector& values = *fields[field_index];
const double original_value = values[0];
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
CAPTURE(SolveOmega, field_index, invalid);
const auto preparations = evaluator.cache().preparation_count();
values[0] = invalid;
const auto rejected = evaluator.evaluate(coefficients, geometry());
REQUIRE_FALSE(rejected);
REQUIRE(std::holds_alternative<execution::PreparationRejection>(rejected.error().error));
CHECK(std::get<execution::PreparationRejection>(rejected.error().error).reason == execution::PreparationRejectionReason::NON_FINITE);
check_unavailable(evaluator);
CHECK(evaluator.cache().preparation_count() == preparations);
check_close(residual(*initial_system), original_residual, 0.0);
Vector action, transpose;
initial_system->jacobian().action()->Mult(frozen_direction, action);
initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose);
check_close(action, frozen_action, 0.0);
check_close(transpose, frozen_transpose, 0.0);
values[0] = original_value;
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
}
}
// Geometry failures originate in mapping, after the input context has been built.
for (Vector* values : {&shape, static_cast<Vector*>(&fixture->compactification)}) {
INFO((values == &shape ? "Invalid surface shape" : "Invalid compactification"));
const Vector original_values(*values);
*values = std::numeric_limits<double>::quiet_NaN();
const auto preparations = evaluator.cache().preparation_count();
REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry()));
check_unavailable(evaluator);
CHECK(evaluator.cache().preparation_count() == preparations);
check_close(residual(*initial_system), original_residual, 0.0);
*values = original_values;
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(*evaluation == initial_system);
CHECK(evaluator.cache().report().computed_count() == 0);
}
// Invalid sizes remain caller errors, but they must still clear preparation availability.
Vector wrong_size(density.Size() + 1);
wrong_size = 1.0;
density.Swap(wrong_size);
CHECK_THROWS_AS(evaluator.evaluate(coefficients, geometry()), std::invalid_argument);
check_unavailable(evaluator);
check_close(residual(*initial_system), original_residual, 0.0);
density.Swap(wrong_size);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(evaluator.cache().report().computed_count() == 0);
// Identical meshes and coefficient counts do not give an external binding the owner's lifetime.
const serif::discretization::backend::mfem::TermBinding<Density, test::Schema> other_density(fixture->fields.density_space);
const serif::discretization::backend::mfem::TermBinding<Omega, test::Schema> other_omega(fixture->fields.mesh);
const auto foreign_density = make_coefficients(other_density, fixture->fields.angular_velocity_binding);
const auto foreign_omega = make_coefficients(fixture->fields.density_binding, other_omega);
for (const auto& foreign : {foreign_density, foreign_omega}) {
CHECK_THROWS_WITH(backend::bind_stellar_model<Prescribed>(*physical, *discretization, foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR"));
CHECK_THROWS_WITH(evaluator.evaluate(foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR"));
check_unavailable(evaluator);
check_close(residual(*initial_system), original_residual, 0.0);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
CHECK(evaluator.cache().report().computed_count() == 0);
}
const int density_row = static_cast<int>(layout.template offset<Kind::residual, Density>());
const double boundary_density = serif::eos::evaluate<serif::dimensions::MassDensity>(
physical->equation_of_state(), serif::dimensions::PressureValue{0.025}).value();
for (const int row : bound->boundary->selected_rows->reduced_dofs()) {
CHECK(initial_system->residual()[density_row + row] == Catch::Approx(density[row] - boundary_density).margin(2.0e-14));
}
const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(fixture->fields.potential_binding);
REQUIRE(infinity.size() > 0);
const int potential_row = static_cast<int>(layout.template offset<Kind::residual, Potential>());
for (const int row : infinity.reduced_dofs()) {
CHECK(initial_system->residual()[potential_row + row] == Catch::Approx(potential[row]).margin(2.0e-14));
}
const Vector dual = test::deterministic(size, 1.7);
Vector transpose(size);
initial_system->jacobian().action()->MultTranspose(dual, transpose);
for (int variation = 0; variation < (SolveOmega ? 4 : 3); ++variation) {
CAPTURE(variation);
Vector direction = test::deterministic(size, 0.4);
if (variation > 0) {
direction = 0.0;
std::size_t column = variation == 1 ? layout.template offset<Kind::value, Constant>() : layout.template offset<Kind::value, Shape>();
if constexpr (SolveOmega) {
if (variation == 3) {
column = layout.template offset<Kind::value, Omega>();
}
}
direction[static_cast<int>(column)] = 0.13;
}
Vector action(size);
initial_system->jacobian().action()->Mult(direction, action);
CHECK((action * dual) == Catch::Approx(direction * transpose).margin(2.0e-12));
// Tiny geometry differences lose digits in the exterior residual. Cancel the leading
// central-difference truncation term while keeping the original absolute error bound.
const auto centered_difference = [&](const double step) {
state = original_state;
state.Add(step, direction);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
Vector difference = residual(**evaluation);
state = original_state;
state.Add(-step, direction);
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
difference -= residual(**evaluation);
difference /= 2.0 * step;
return difference;
};
const double step = 1.0e-3;
const Vector coarse = centered_difference(step);
Vector difference = centered_difference(0.5 * step);
difference *= 4.0;
difference -= coarse;
difference /= 3.0;
check_close(action, difference, 2.0e-9);
state = original_state;
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
check_close(residual(**evaluation), original_residual, 2.0e-14);
Vector frozen_action(size);
initial_system->jacobian().action()->Mult(direction, frozen_action);
check_close(frozen_action, action, 0.0);
}
using PoissonResult = execution::volume_keys::Scoped<Potential>::Linearization;
using MomentumResult = execution::volume_keys::Scoped<Density>::Linearization;
const auto poisson = evaluator.cache().template share<PoissonResult>();
const auto momentum = evaluator.cache().template share<MomentumResult>();
omega[0] += 0.02;
if constexpr (SolveOmega) {
evaluation = evaluator.evaluate(coefficients, geometry());
} else {
evaluation = evaluator.evaluate(packed_state, prescribed);
}
REQUIRE(evaluation);
CHECK((*evaluation)->jacobian().action()->Width() == size);
if constexpr (!SolveOmega) {
const auto external_evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(external_evaluation);
CHECK(*external_evaluation == *evaluation);
CHECK(evaluator.cache().report().computed_count() == 0);
}
CHECK(evaluator.cache().template share<PoissonResult>() == poisson);
CHECK(evaluator.cache().template share<MomentumResult>() != momentum);
CHECK(*evaluation != initial_system);
check_close(residual(*initial_system), original_residual, 0.0);
const auto before_rejection = *evaluation;
const Vector before_rejection_residual = residual(*before_rejection);
Vector before_rejection_action, before_rejection_transpose;
before_rejection->jacobian().action()->Mult(frozen_direction, before_rejection_action);
before_rejection->jacobian().action()->MultTranspose(frozen_direction, before_rejection_transpose);
density = -1.0;
const auto rejected = evaluator.evaluate(coefficients, geometry());
REQUIRE_FALSE(rejected);
REQUIRE(std::holds_alternative<execution::PreparationRejection>(rejected.error().error));
CHECK(std::get<execution::PreparationRejection>(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION);
check_unavailable(evaluator);
check_close(residual(*before_rejection), before_rejection_residual, 0.0);
Vector rejected_action, rejected_transpose;
before_rejection->jacobian().action()->Mult(frozen_direction, rejected_action);
before_rejection->jacobian().action()->MultTranspose(frozen_direction, rejected_transpose);
check_close(rejected_action, before_rejection_action, 0.0);
check_close(rejected_transpose, before_rejection_transpose, 0.0);
check_close(residual(*initial_system), original_residual, 0.0);
state = original_state;
omega = 0.31;
evaluation = evaluator.evaluate(coefficients, geometry());
REQUIRE(evaluation);
check_close(residual(**evaluation), original_residual, 2.0e-14);
retained = *evaluation;
retained_direction = test::deterministic(size, 0.8);
retained->jacobian().action()->Mult(retained_direction, retained_action);
}
CHECK_FALSE(resources.expired());
Vector after_owner_release;
retained->jacobian().action()->Mult(retained_direction, after_owner_release);
check_close(after_owner_release, retained_action, 0.0);
retained.reset();
CHECK(resources.expired());
}
} // namespace
TEST_CASE("Equilibrium graph combines the density balance, primal gravity and moving isobar rows", "[unit][model][equilibrium_graph]") {
check_equilibrium_graph<true>();
}
TEST_CASE("Prescribed rotation remains a physical input without a Newton column or angular momentum equation", "[unit][model][equilibrium_graph]") {
check_equilibrium_graph<false>();
}

View File

@@ -0,0 +1,148 @@
#include <algorithm>
#include <cmath>
#include <numbers>
#include <ranges>
#include <type_traits>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/model/finite_elements.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/runtime.hpp"
#include "serif/solver/seed/state.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/model/execution_validation.hpp"
#include "../sandbox/polytropic_model.hpp"
TEST_CASE("Production resources reproduce the established stellar coefficient representation", "[model][resources]") {
namespace fixture = serif::tests::solver;
namespace model = serif::model;
namespace deformation = serif::geometry::deformation;
serif::initialize();
serif::initialize(); // Reusing the application runtime must be harmless.
const auto star = fixture::make_model();
const auto radial = serif::solver::seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto old_resources = fixture::make_resources(radial->scales.radius.value());
CHECK(old_resources->geometry_space.FEColl() == old_resources->displacement_space.FEColl());
CHECK(old_resources->geometry_space.FEColl() == old_resources->reference_displacement.FEColl());
const auto previous = fixture::make_discretization(old_resources, 2);
const auto current = model::make_stellar_discretization(star, {.radius = radial->scales.radius},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}});
const auto old_state = serif::solver::seed::make_polytropic_state(star, previous, *radial);
const auto new_state = serif::solver::seed::make_polytropic_state(star, current, *radial);
REQUIRE(old_state);
REQUIRE(new_state);
CHECK(std::ranges::equal(old_state->values(), new_state->values()));
const auto copied = current;
CHECK(copied.owner == current.owner);
CHECK(&copied.template binding<fixture::Density>().space() == &current.template binding<fixture::Density>().space());
}
TEST_CASE("Geometry construction needs only the fields of the selected equations", "[model][resources]") {
namespace model = serif::model;
namespace equations = serif::equations;
namespace deformation = serif::geometry::deformation;
const auto star = model::make_stellar_model(serif::eos::models::Polytrope{1.0, 1.0}, model::EquilibriumEquations{
.interior_equations = equations::EquationRegistry{equations::PrimalPoissonBalance{1.0}}});
const auto finite_elements = model::make_stellar_discretization(star, {},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{});
const auto& reference = finite_elements.deformation.reference;
CHECK(reference.scalar_space.FEColl() == reference.displacement_space.FEColl());
CHECK(reference.scalar_space.GetVDim() == 1);
CHECK(reference.displacement_space.GetVDim() == 3);
CHECK_THROWS(model::make_stellar_discretization(star, {.radius = serif::dimensions::LengthValue{-1.0}}));
CHECK_THROWS(model::make_stellar_discretization(star, {.infinity_radius_fraction = 1.0}));
}
TEST_CASE("The polytropic factory retains fixed physical targets in the scalar formulation", "[model][unit][primal]") {
namespace model = serif::model;
namespace equations = serif::equations;
namespace blocks = serif::discretization::blocks;
namespace dependencies = serif::utils::dependencies;
const auto star = serif::examples::make_polytropic_model(2.0, 0.4, 1.7, 0.03, 0.8, 0.02);
const auto definition = model::resolve_equilibrium_equations(star);
using Description = std::remove_cvref_t<decltype(definition)>;
using Model = std::remove_cvref_t<decltype(star)>;
using Plan = model::EquilibriumPlan<typename Model::EquationOfState, Description>;
static_assert(model::executable_model_topology<Model, Description, Plan>());
static_assert(dependencies::key_count_v<typename Plan::Values> == 5);
static_assert(dependencies::key_count_v<typename Plan::Residuals> == 5);
static_assert(std::same_as<typename Plan::Surface::Formulation::CarrierTerm, blocks::density::mass>);
CHECK(star.equation_of_state().polytropic_index() == 2.0);
CHECK(star.equation_of_state().polytropic_constant() == 0.4);
CHECK(star.specification<model::FixedTotalMass>().target().value() == 1.7);
CHECK(star.specification<model::FixedAngularMomentum>().target().value() == 0.03);
CHECK(definition.interior_equations.get<equations::MassNormalization>().residual_offset() == -1.7);
CHECK(definition.interior_equations.get<equations::AngularMomentumConstraint>().residual_offset() == -0.03);
CHECK(definition.interior_equations.get<equations::PrimalPoissonBalance>().four_pi_G() == Catch::Approx(4.0 * std::numbers::pi * 0.8));
CHECK(definition.surface_condition.target_pressure().value() == 0.02);
}
TEST_CASE("The factory rejects only the unresolved nonrotating n3 vacuum scale at equation resolution", "[model][unit][primal]") {
namespace model = serif::model;
CHECK_NOTHROW(serif::eos::models::Polytrope{3.0, 0.4});
const auto unresolved = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.0, 1.0, 0.0);
CHECK(unresolved.equation_of_state().polytropic_index() == 3.0);
CHECK_THROWS_WITH(model::resolve_equilibrium_equations(unresolved),
Catch::Matchers::ContainsSubstring("compatible mass leaves radius free"));
const auto rotating = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.01, 1.0, 0.0);
const auto confined = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.0, 1.0, 0.01);
const auto below = serif::examples::make_polytropic_model(2.999, 0.4, 1.0, 0.0, 1.0, 0.0);
CHECK_NOTHROW(model::resolve_equilibrium_equations(rotating));
CHECK_NOTHROW(model::resolve_equilibrium_equations(confined));
CHECK_NOTHROW(model::resolve_equilibrium_equations(below));
// Admission of these declarations does not certify existence or uniqueness for their chosen targets.
}
TEST_CASE("The scalar factory seed contains only primal fields and fixes its density trace", "[model][resources][primal]") {
namespace model = serif::model;
namespace blocks = serif::discretization::blocks;
namespace dependencies = serif::utils::dependencies;
namespace seed = serif::solver::seed;
namespace deformation = serif::geometry::deformation;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using SurfaceLevel = blocks::barotropic_constant::mass_normalization;
serif::initialize();
const auto star = serif::examples::make_polytropic_model();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star, {.radius = radial->scales.radius},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}});
const auto state = seed::make_polytropic_state(star, finite_elements, *radial);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
using State = std::remove_cvref_t<decltype(*state)>;
static_assert(dependencies::key_count_v<typename State::Layout::Values> == 5);
CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); }));
CHECK(state->template get<Density>().size() == static_cast<std::size_t>(finite_elements.template binding<Density>().size()));
CHECK(state->template get<Potential>().size() == static_cast<std::size_t>(finite_elements.template binding<Potential>().size()));
CHECK(state->template get<blocks::surface_deformation::coefficients>().size() == 9);
CHECK(state->template get<SurfaceLevel>()[0] == Catch::Approx(-1.0).epsilon(3.0e-7));
CHECK(state->template get<blocks::rotation::angular_velocity>()[0] == 0.0);
CHECK(std::ranges::max(state->template get<Density>()) > 0.7);
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(finite_elements.template binding<Density>());
REQUIRE(surface_rows.size() > 0);
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<Density>()[dof] == 0.0);
}
const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.template binding<Potential>());
REQUIRE(infinity_rows.size() > 0);
for (const int dof : infinity_rows.reduced_dofs()) {
CHECK(state->template get<Potential>()[dof] == 0.0);
}
CHECK(star.specification<model::FixedTotalMass>().target().value() == 1.0);
CHECK(star.equation_of_state().polytropic_constant() == 2.0 / std::numbers::pi);
}

30
tests/model/meson.build Normal file
View File

@@ -0,0 +1,30 @@
model_test_sources = files(
'finite_elements.cpp',
'reconstruction.cpp',
'reconstruction_system.cpp',
'surface_refinement.cpp',
'automatic_surface.cpp',
'description.cpp',
'state.cpp',
'scalar_row.cpp',
'pressure_graph.cpp',
'runtime.cpp',
'runtime_deformation.cpp',
'runtime_volume.cpp',
'solver_services.cpp',
'equilibrium_graph.cpp',
'moving_equilibrium.cpp',
'normalized_system.cpp',
'move_only.cpp',
'../equations/fixture.cpp',
)
serif_model_tests = executable(
'serif_model_tests',
model_test_sources,
dependencies: [serif_dep, catch2_dep, stroid_dep],
include_directories: include_directories('../include'),
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
)
test('serif_model_tests', serif_model_tests, args: ['~[moving_preconditioner]'], suite: 'model', timeout: 600, is_parallel: false)
test('serif_moving_preconditioner_tests', serif_model_tests, args: ['[moving_preconditioner]'], suite: 'model', timeout: 1200, is_parallel: false)

175
tests/model/move_only.cpp Normal file
View File

@@ -0,0 +1,175 @@
#include <algorithm>
#include <concepts>
#include <memory>
#include <string_view>
#include <utility>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/tests/equations/fixture.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace equations = serif::equations;
namespace execution = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace blocks = serif::discretization::blocks;
namespace tests = serif::tests::equations;
using EOS = serif::eos::models::Polytrope;
using Density = blocks::density::mass;
using Enthalpy = ::serif::tests::equations::Enthalpy;
using Displacement = blocks::displacement::vector;
using Kind = blocks::RepresentedValueKind;
using Vector = mfem::Vector;
/** @brief Require genuine ownership transfer while preserving the wrapped equation's physics and weak test. */
template <typename Equation>
class MoveOnlyEquation final : public equations::Contribution<equations::TestOperandOf<Equation>, equations::SupportOf<Equation>> {
public:
using Operands = typename Equation::Operands;
static constexpr std::string_view name = "move-only equation";
/** @brief Retain an evaluation counter so tests can observe lifetime without owning the equation. */
MoveOnlyEquation(Equation equation, std::shared_ptr<int> evaluations) :
m_equation(std::make_unique<Equation>(std::move(equation))), m_evaluations(std::move(evaluations)) {
}
/** @brief Forward the exact physical residual and derivatives without supplying a clone operation. */
[[nodiscard]] auto evaluate(const equations::EquationPoint<Operands>& point) const {
++*m_evaluations;
return m_equation->evaluate(point);
}
/** @brief Use the same integration order as the underlying physical equation. */
template <typename Operand>
[[nodiscard]] int additional_order(Operand operand, const int position_order) const {
return m_equation->additional_order(operand, position_order);
}
private:
std::unique_ptr<Equation> m_equation;
std::shared_ptr<int> m_evaluations;
};
static_assert(!std::copy_constructible<MoveOnlyEquation<tests::Closure>>);
static_assert(std::move_constructible<MoveOnlyEquation<tests::Closure>>);
static_assert(serif::operators::VolumeEquation<MoveOnlyEquation<tests::Closure>>);
/** @brief All borrowed finite-element bindings belong to the retained fixture. */
[[nodiscard]] auto bindings(const tests::Fixture& fields) {
return dependencies::make_dependency_context(dependencies::borrow<Density>(fields.density_binding),
dependencies::borrow<Enthalpy>(fields.enthalpy_binding), dependencies::borrow<Displacement>(fields.displacement_binding));
}
/** @brief Copy evaluated residuals before perturbing the candidate state. */
[[nodiscard]] Vector residual(const backend::PhysicalSystem& system) {
Vector result(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), result.GetData());
return result;
}
/** @brief Check complete actions and exact transposes after their model and cache have gone away. */
void check_retained(const backend::PhysicalSystem& system, const Vector& direction, const Vector& dual,
const Vector& expected_action, const Vector& expected_transpose) {
Vector action, transpose;
system.jacobian().action()->Mult(direction, action);
system.jacobian().action()->MultTranspose(dual, transpose);
CHECK(tests::relative_error(action, expected_action) == 0.0);
CHECK(tests::relative_error(transpose, expected_transpose) == 0.0);
}
}
TEST_CASE("Move-only interior equations preserve evaluation derivatives and retained ownership", "[model][move_only][integration]") {
std::weak_ptr<tests::Fixture> resources;
std::weak_ptr<int> equation_lifetime;
std::shared_ptr<const backend::PhysicalSystem> retained;
Vector direction, dual, expected_action, expected_transpose, expected_residual;
{
auto fields = std::make_shared<tests::Fixture>();
resources = fields;
auto evaluations = std::make_shared<int>(0);
equation_lifetime = evaluations;
const EOS eos{2.0, 0.3};
auto physical = model::make_stellar_model(eos, model::EquilibriumEquations {
.interior_equations = equations::EquationRegistry{MoveOnlyEquation{tests::Closure{eos}, std::move(evaluations)}}
});
STATIC_CHECK_FALSE(std::copy_constructible<decltype(physical)>);
auto discretization = backend::make_stellar_discretization(fields, bindings(*fields), fields->compactification, fields->mapper);
Vector density = fields->density(), enthalpy = fields->enthalpy();
const Vector displacement = fields->displacement(0.03);
const auto coefficients = fields->state(density, enthalpy);
auto evaluator = backend::bind_stellar_model(std::move(physical), std::move(discretization), coefficients, displacement);
CHECK(*equation_lifetime.lock() == 0);
auto evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
CHECK(*equation_lifetime.lock() > 0);
retained = *evaluation;
expected_residual = residual(*retained);
tests::PreparedClosure reference(tests::Closure{eos}, fields->density_binding, fields->displacement_binding, fields->compactification, fields->mapper);
reference.prepare(coefficients, displacement, fields->workspace);
CHECK(tests::relative_error(expected_residual, reference.data().residual) < 2.0e-13);
const int initial_evaluations = *equation_lifetime.lock();
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
CHECK(*evaluation == retained);
CHECK(*equation_lifetime.lock() == initial_evaluations);
direction = tests::deterministic(retained->jacobian().action()->Width(), 0.41);
dual = tests::deterministic(retained->jacobian().action()->Height(), 0.83);
retained->jacobian().action()->Mult(direction, expected_action);
retained->jacobian().action()->MultTranspose(dual, expected_transpose);
REQUIRE(expected_action.Norml2() > 0.0);
CHECK((expected_action * dual) == Catch::Approx(direction * expected_transpose).margin(2.0e-12));
const auto& layout = evaluator.layout();
const int density_offset = static_cast<int>(layout.template offset<Kind::value, Density>());
const int enthalpy_offset = static_cast<int>(layout.template offset<Kind::value, Enthalpy>());
const Vector initial_density(density), initial_enthalpy(enthalpy);
const auto perturb = [&](const double step) {
for (int index = 0; index < density.Size(); ++index) {
density[index] = initial_density[index] + step * direction[density_offset + index];
}
for (int index = 0; index < enthalpy.Size(); ++index) {
enthalpy[index] = initial_enthalpy[index] + step * direction[enthalpy_offset + index];
}
};
constexpr double step = 1.0e-6;
perturb(step);
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
CHECK(*evaluation != retained);
CHECK(*equation_lifetime.lock() > initial_evaluations);
Vector difference = residual(**evaluation);
perturb(-step);
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE(evaluation);
difference -= residual(**evaluation);
difference /= 2.0 * step;
CHECK(tests::relative_error(expected_action, difference) < 2.0e-8);
const auto before_rejection = *evaluation;
const Vector before_rejection_residual = residual(*before_rejection);
Vector before_rejection_action, before_rejection_transpose;
before_rejection->jacobian().action()->Mult(direction, before_rejection_action);
before_rejection->jacobian().action()->MultTranspose(dual, before_rejection_transpose);
enthalpy = -1.0;
evaluation = evaluator.evaluate(coefficients, displacement);
REQUIRE_FALSE(evaluation);
CHECK_FALSE(evaluator.cache().ready());
CHECK(tests::relative_error(residual(*before_rejection), before_rejection_residual) == 0.0);
check_retained(*before_rejection, direction, dual, before_rejection_action, before_rejection_transpose);
CHECK(tests::relative_error(residual(*retained), expected_residual) == 0.0);
check_retained(*retained, direction, dual, expected_action, expected_transpose);
}
CHECK_FALSE(resources.expired());
CHECK_FALSE(equation_lifetime.expired());
CHECK(tests::relative_error(residual(*retained), expected_residual) == 0.0);
check_retained(*retained, direction, dual, expected_action, expected_transpose);
retained.reset();
CHECK(resources.expired());
CHECK(equation_lifetime.expired());
}

View File

@@ -0,0 +1,809 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <numbers>
#include <tuple>
#include <utility>
#include <variant>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/solvers.hpp"
#include "mfem/general/communication.hpp"
#include "serif/equations/angular_momentum.hpp"
#include "serif/equations/barotropic_density.hpp"
#include "serif/equations/primal_gravity.hpp"
#include "serif/equations/surface_potential.hpp"
#include "serif/equations/mass_normalization.hpp"
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/model/eos_reference.hpp"
#include "serif/solver/preconditioner/default_preconditioners.hpp"
#include "serif/tests/deformation/mesh.hpp"
#include "serif/tests/equations/gravity_fixture.hpp"
// MeanField's prepared_stellar_equilibrium.cpp checks the complete moving root
// against its children and centered differences. Keep both checks: child parity
// verifies composition, while finite differences verify the assembled derivative.
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace execution = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace equations = serif::equations;
namespace deformation = serif::geometry::deformation;
namespace surface = serif::geometry::surface;
namespace blocks = serif::discretization::blocks;
namespace tests = serif::tests::equations;
using Polytrope = serif::eos::models::Polytrope;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using Constant = blocks::barotropic_constant::mass_normalization;
using Omega = blocks::rotation::angular_velocity;
using Shape = blocks::surface_deformation::coefficients;
using ShapeRow = blocks::surface_deformation::shape_equilibrium;
using Displacement = blocks::displacement::vector;
using Kind = blocks::RepresentedValueKind;
using Vector = mfem::Vector;
using InteriorRows = dependencies::KeyList<Density, Potential, Constant, Omega>;
/** @brief Own paired stellar/vacuum meshes and all resources borrowed by the public model binding. */
struct Fixture {
stroid::StroidMesh generated{serif::tests::deformation::make_mesh(1)};
tests::gravity::GravityFixture fields;
mfem::FiniteElementSpace reference_displacement{&fields.mesh, &fields.enthalpy_collection, 3, mfem::Ordering::byNODES};
mfem::FiniteElementSpace compactification_space{&fields.mesh, generated.exterior_coordinate->space->FEColl()};
mfem::GridFunction compactification{&compactification_space};
/** @brief Preserve the generator's exterior coordinate on the identically numbered mesh copy. */
explicit Fixture(const mfem::Ordering::Type ordering) : fields(mfem::Mesh(*generated.mesh), ordering) {
REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size());
compactification = *generated.exterior_coordinate->values;
}
};
/** @brief Exercise all stellar equations at a valid, deliberately unconverged rotating state. */
[[nodiscard]] auto physical_model(const double index = 1.5) {
const auto definition = [](const auto& physics) {
const auto eos = model::borrow_eos(physics.equation_of_state());
const auto& rotation = physics.template specification<model::FixedAngularMomentum>();
const auto& axis = rotation.axis();
const auto& center = rotation.center();
return model::EquilibriumEquations{
.interior_equations = equations::EquationRegistry{
equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}, axis, center}, equations::PrimalPoissonBalance{2.3},
equations::MassNormalization{physics.template specification<model::FixedTotalMass>().target().value()},
equations::AngularMomentumConstraint{rotation.target().value(), axis, center}
},
.shape_force = equations::SurfacePotentialBalance{axis, center},
.surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}}
};
};
return model::make_stellar_model(Polytrope{index, 0.25}, definition,
model::FixedTotalMass{serif::dimensions::MassValue{1.1}},
model::FixedAngularMomentum{{.angular_momentum = serif::dimensions::AngularMomentumValue{0.14},
.axis = {0.2, -0.3, 1.0}, .center = {0.03, -0.02, 0.01}}});
}
/** @brief Use the production radial lift and vacuum extension with nine independent surface harmonics. */
[[nodiscard]] auto discretize(const std::shared_ptr<Fixture>& fixture) {
const auto& fields = fixture->fields;
const auto bindings = dependencies::make_dependency_context(
dependencies::borrow<Density>(fields.density_binding), dependencies::borrow<Potential>(fields.potential_binding),
dependencies::borrow<Constant>(fields.constant_binding), dependencies::borrow<Omega>(fields.angular_velocity_binding),
dependencies::borrow<Displacement>(fields.displacement_binding));
const auto reference = deformation::backend::mfem::deformation_discretization(
fields.enthalpy_space, fixture->reference_displacement, *fixture->generated.reference_mesh);
const deformation::DomainDeformation<deformation::SphericalHarmonicSurface> geometry{
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}
};
return backend::with_deformation(backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference);
}
/** @brief Populate all physical fields, scalar constraints and surface modes in the moving-star fixture. */
template <typename Layout, typename ShapeCoefficients>
[[nodiscard]] model::StellarState<Layout> initial_state(const Layout& layout, tests::gravity::GravityFixture& fields, const ShapeCoefficients& shape) {
model::StellarState state(layout);
const auto initialize = [&]<typename Term>(const Vector& values) {
const auto destination = state.template get<Term>();
REQUIRE(destination.size() == static_cast<std::size_t>(values.Size()));
std::copy_n(values.GetData(), values.Size(), destination.begin());
};
initialize.template operator()<Density>(fields.density());
initialize.template operator()<Potential>(fields.potential());
state.template get<Constant>()[0] = 0.72;
state.template get<Omega>()[0] = 0.31;
std::ranges::copy(shape, state.template get<Shape>().begin());
return state;
}
/** @brief Make an independent physical candidate without sharing any mutable field storage. */
template <typename Layout>
[[nodiscard]] model::StellarState<Layout> perturbed_state(const model::StellarState<Layout>& original, const double step, const Vector& direction) {
REQUIRE(original.values().size() == static_cast<std::size_t>(direction.Size()));
model::StellarState result(original);
for (int index = 0; index < direction.Size(); ++index) {
result.values()[static_cast<std::size_t>(index)] += step * direction[index];
}
return result;
}
/** @brief Copy a frozen residual before evaluating another candidate. */
template <typename System>
[[nodiscard]] Vector residual(const System& system) {
Vector result(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), result.GetData());
return result;
}
/** @brief Check each equation separately so large gravity rows cannot hide missing scalar or shape rows. */
template <typename Layout>
void check_rows(const Vector& actual, const Vector& expected, const Layout& layout, double relative_tolerance, double absolute_tolerance) {
REQUIRE(actual.Size() == expected.Size());
int offset = 0;
for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) {
CAPTURE(row);
double error_squared = 0.0, reference_squared = 0.0;
for (std::size_t entry = 0; entry < layout.residual_sizes[row]; ++entry) {
const double difference = actual[offset] - expected[offset];
error_squared += difference * difference;
reference_squared += expected[offset] * expected[offset];
++offset;
}
CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared));
}
}
/** @brief Compare cached scalar functionals with the independently retained moving volume actions. */
template <typename Row, typename Evaluator>
void check_scalar_row(const Evaluator& evaluator, const backend::PhysicalSystem& physical) {
const auto& layout = evaluator.layout();
const auto& cache = evaluator.cache();
const auto volume = cache.template share<typename execution::volume_keys::Scoped<Row>::Linearization>();
const auto& shape = cache.template get<model::computations::ShapeDerivatives>();
const auto reference = backend::volume_row_action(volume, layout, backend::ColumnContribution{
static_cast<int>(layout.template offset<Kind::value, Shape>()), shape.tangent});
const auto& cached = cache.template get<model::computations::ScalarResidualRow<Row>>();
const Vector direction = tests::deterministic(reference.action()->Width(), 0.47);
const Vector dual = tests::constant(1, 0.37);
Vector expected(1), actual(1), expected_transpose, actual_transpose(direction.Size());
reference.action()->Mult(direction, expected);
cached.action()->Mult(direction, actual);
CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12));
reference.action()->MultTranspose(dual, expected_transpose);
cached.action()->MultTranspose(dual, actual_transpose);
CHECK(tests::relative_error(actual_transpose, expected_transpose) < 2.0e-12);
const auto check_column = [&]<typename Column>() {
const int offset = static_cast<int>(layout.template offset<Kind::value, Column>());
const int size = static_cast<int>(layout.template size<Kind::value, Column>());
const auto selected = physical.block(static_cast<int>(layout.template offset<Kind::residual, Row>()), 1, offset, size);
Vector input(size), selected_transpose;
std::copy_n(direction.GetData() + offset, size, input.GetData());
Vector complete(direction.Size());
complete = 0.0;
std::copy_n(input.GetData(), size, complete.GetData() + offset);
cached.action()->Mult(complete, expected);
selected.action()->Mult(input, actual);
CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12));
selected.action()->MultTranspose(dual, selected_transpose);
Vector expected_column(expected_transpose.GetData() + offset, size);
CHECK(tests::relative_error(selected_transpose, expected_column) < 2.0e-12);
};
check_column.template operator()<Density>();
check_column.template operator()<Shape>();
check_column.template operator()<Omega>();
}
/** @brief Place the volume and surface children, then independently enforce both physical traces. */
template <typename Evaluator, typename Layout>
void check_composition(const Evaluator& evaluator, const model::StellarState<Layout>& state,
const backend::PhysicalSystem& physical, const serif::discretization::backend::mfem::DofSelection& boundary) {
const auto& layout = evaluator.layout();
const auto& cache = evaluator.cache();
Vector expected(static_cast<int>(layout.template total_size<Kind::residual>()));
expected = 0.0;
[&]<typename... Rows>(dependencies::KeyList<Rows...>) {
const auto place = [&]<typename Row>() {
const auto& child = cache.template get<typename execution::volume_keys::Scoped<Row>::Linearization>();
REQUIRE(static_cast<std::size_t>(child.residual.Size()) == layout.template size<Kind::residual, Row>());
std::copy_n(child.residual.GetData(), child.residual.Size(), expected.GetData() + layout.template offset<Kind::residual, Row>());
};
(place.template operator()<Rows>(), ...);
}(InteriorRows{});
const auto& surface_child = cache.template get<model::computations::ShapeContribution>();
REQUIRE(surface_child.residual.size() == 1);
for (const auto& contribution : surface_child.residual) {
std::copy(contribution.values.begin(), contribution.values.end(), expected.GetData() + contribution.row_offset);
}
const int shape_row = static_cast<int>(layout.template offset<Kind::residual, ShapeRow>());
const int shape_size = static_cast<int>(layout.template size<Kind::residual, ShapeRow>());
Vector shape_residual(expected.GetData() + shape_row, shape_size);
REQUIRE(shape_residual.Norml2() > 1.0e-8);
// Compare each retained surface child coupling with the corresponding assembled block.
for (const auto& contribution : surface_child.jacobian) {
const auto selected = physical.block(contribution.row_offset, contribution.block.action()->Height(),
contribution.column_offset, contribution.block.action()->Width());
const Vector direction = tests::deterministic(contribution.block.action()->Width(), 0.47);
const Vector dual = tests::deterministic(contribution.block.action()->Height(), 0.71);
Vector actual(contribution.block.action()->Height()), reference(contribution.block.action()->Height());
Vector actual_transpose(contribution.block.action()->Width()), reference_transpose(contribution.block.action()->Width());
selected.action()->Mult(direction, actual);
contribution.block.action()->Mult(direction, reference);
CHECK(tests::relative_error(actual, reference) < 2.0e-13);
selected.action()->MultTranspose(dual, actual_transpose);
contribution.block.action()->MultTranspose(dual, reference_transpose);
CHECK(tests::relative_error(actual_transpose, reference_transpose) < 2.0e-13);
}
const auto density = state.template get<Density>();
REQUIRE(boundary.size() > 0);
REQUIRE(boundary.size() < static_cast<int>(density.size()));
const double surface_density = serif::eos::evaluate<serif::dimensions::MassDensity>(
evaluator.model().equation_of_state(), serif::dimensions::PressureValue{0.025}).value();
const int density_row = static_cast<int>(layout.template offset<Kind::residual, Density>());
for (const int row : boundary.reduced_dofs()) {
expected[density_row + row] = density[row] - surface_density;
}
const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(
evaluator.binding().discretization->template binding<Potential>());
REQUIRE(infinity.size() > 0);
const int potential_row = static_cast<int>(layout.template offset<Kind::residual, Potential>());
for (const int row : infinity.reduced_dofs()) {
expected[potential_row + row] = state.template get<Potential>()[row];
}
check_rows(residual(physical), expected, layout, 2.0e-13, 2.0e-13);
// Each strong trace must reach its own scalar field alone, including in the transpose.
const auto check_trace = [&]<typename Field>(const auto& selected) {
Vector dual(expected.Size()), transpose;
dual = 0.0;
Vector expected_transpose(physical.jacobian().action()->Width());
expected_transpose = 0.0;
const int row_offset = static_cast<int>(layout.template offset<Kind::residual, Field>());
const int column_offset = static_cast<int>(layout.template offset<Kind::value, Field>());
for (const int row : selected.reduced_dofs()) {
dual[row_offset + row] = 0.17 + 0.01 * (row % 7);
expected_transpose[column_offset + row] = dual[row_offset + row];
}
physical.jacobian().action()->MultTranspose(dual, transpose);
CHECK(tests::relative_error(transpose, expected_transpose) < 2.0e-13);
};
check_trace.template operator()<Density>(boundary);
check_trace.template operator()<Potential>(infinity);
}
/** @brief Compare every physical state column and a mixed perturbation through the public evaluation path. */
template <typename Evaluator, typename Layout>
void check_derivatives(Evaluator& evaluator, const model::StellarState<Layout>& state) {
const auto& layout = evaluator.layout();
const auto evaluation = evaluator.evaluate(state);
REQUIRE(evaluation);
const auto frozen = *evaluation;
const Vector direction_seed = tests::deterministic(static_cast<int>(state.values().size()), 0.43);
const Vector dual = tests::deterministic(frozen->jacobian().action()->Height(), 1.17);
Vector transpose, block_transpose;
frozen->jacobian().action()->MultTranspose(dual, transpose);
const auto blocks = frozen->block(0, dual.Size(), 0, static_cast<int>(state.values().size()));
blocks.action()->MultTranspose(dual, block_transpose);
CHECK(tests::relative_error(transpose, block_transpose) < 2.0e-12);
// The final iteration moves all physical fields and the surface simultaneously.
int offset = 0;
for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) {
CAPTURE(column);
Vector direction(direction_seed);
if (column < layout.state_sizes.size()) {
direction = 0.0;
std::copy_n(direction_seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset);
offset += static_cast<int>(layout.state_sizes[column]);
}
Vector action, block_action;
frozen->jacobian().action()->Mult(direction, action);
blocks.action()->Mult(direction, block_action);
REQUIRE(action.Norml2() > 1.0e-10);
check_rows(action, block_action, layout, 2.0e-12, 2.0e-12);
CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(2.0e-12).margin(2.0e-12));
constexpr double step = 1.0e-5;
const auto plus = evaluator.evaluate(perturbed_state(state, step, direction));
REQUIRE(plus);
Vector difference = residual(**plus);
const auto minus = evaluator.evaluate(perturbed_state(state, -step, direction));
REQUIRE(minus);
difference -= residual(**minus);
difference /= 2.0 * step;
check_rows(action, difference, layout, 2.0e-6, 2.0e-9);
if (column == Layout::template index<Kind::value, Shape>()) {
// Shape must affect each interior equation and the surface potential, not only its own row.
int row_offset = 0;
for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) {
Vector row_action(action.GetData() + row_offset, static_cast<int>(layout.residual_sizes[row]));
CHECK(row_action.Norml2() > 1.0e-10);
row_offset += row_action.Size();
}
}
}
const auto restored = evaluator.evaluate(state);
REQUIRE(restored);
check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0);
}
/** @brief Check the assembled solver coordinates, including strong rows, scalar constraints and native surface modes. */
template <typename Evaluator, typename Layout>
void check_normalized_system(Evaluator& evaluator, const model::StellarState<Layout>& state,
const serif::discretization::backend::mfem::DofSelection& boundary) {
namespace normalization = serif::solver::normalization;
namespace dimensions = serif::dimensions;
const auto evaluation = evaluator.evaluate(state);
REQUIRE(evaluation);
const auto frozen = *evaluation;
REQUIRE(frozen->physical() == evaluator.cache().template get<model::keys::PhysicalSystem>());
const auto& layout = evaluator.layout();
const auto& coordinates = frozen->coordinates();
const auto& scales = frozen->reference_scales();
CHECK(scales.mass.value() == Catch::Approx(1.1));
CHECK(scales.radius.value() > 0.0);
CHECK(scales.radius.value() != 1.0);
REQUIRE(coordinates.state_size() == state.values().size());
REQUIRE(coordinates.residual_size() == frozen->residual().size());
CHECK(coordinates.state_factors().front() != 1.0);
Vector expected_residual(static_cast<int>(frozen->residual().size()));
coordinates.normalize_residual(frozen->physical()->residual(), {expected_residual.GetData(), frozen->residual().size()});
check_rows(residual(*frozen), expected_residual, layout, 0.0, 0.0);
const double radius = scales.radius.value();
const std::size_t density_row = layout.template offset<Kind::residual, Density>();
for (const int row : boundary.reduced_dofs()) {
CHECK(coordinates.residual_factors()[density_row + row] == Catch::Approx(1.0 / scales.density.value()).epsilon(2.0e-13));
}
const auto& density_metric = evaluator.cache().template get<normalization::ReferenceVolumeMetric<Density>>();
const auto& selected_rows = boundary.reduced_dofs();
int interior = 0;
while (std::find(selected_rows.begin(), selected_rows.end(), interior) != selected_rows.end()) {
++interior;
}
REQUIRE(interior < static_cast<int>(state.template get<Density>().size()));
// The density balance residual is a barotropic pressure potential, a specific energy.
CHECK(coordinates.residual_factors()[density_row + interior] == Catch::Approx(1.0 /
(scales.specific_energy.value() * std::sqrt(density_metric[interior] * radius * radius * radius))).epsilon(2.0e-13));
CHECK(coordinates.state_factors()[layout.template offset<Kind::value, Constant>()] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13));
const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(
evaluator.binding().discretization->template binding<Potential>());
for (const int row : infinity.reduced_dofs()) {
CHECK(coordinates.residual_factors()[layout.template offset<Kind::residual, Potential>() + row] ==
Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13));
}
CHECK(coordinates.residual_factors()[layout.template offset<Kind::residual, Constant>()] == Catch::Approx(1.0 / scales.mass.value()).epsilon(2.0e-13));
CHECK(coordinates.residual_factors()[layout.template offset<Kind::residual, Omega>()] == Catch::Approx(1.0 / scales.angular_momentum.value()).epsilon(2.0e-13));
const auto& surface_metric = evaluator.cache().template get<deformation::keys::SurfaceMetric>();
for (std::size_t mode = 0; mode < state.template get<Shape>().size(); ++mode) {
CHECK(coordinates.state_factors()[layout.template offset<Kind::value, Shape>() + mode] == Catch::Approx(std::sqrt(surface_metric[mode]) / (radius * radius)).epsilon(2.0e-13));
CHECK(coordinates.residual_factors()[layout.template offset<Kind::residual, ShapeRow>() + mode] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13));
}
const Vector seed = tests::deterministic(static_cast<int>(state.values().size()), 0.43);
const Vector dual = tests::deterministic(static_cast<int>(coordinates.residual_size()), 1.17);
Vector transpose;
frozen->jacobian().MultTranspose(dual, transpose);
for (const int selected : {-1, 0, 1}) {
CAPTURE(selected);
Vector direction(seed);
if (selected >= 0) {
direction = 0.0;
const std::size_t offset = selected == 0 ? layout.template offset<Kind::value, Shape>() : layout.template offset<Kind::value, Constant>();
const std::size_t count = selected == 0 ? state.template get<Shape>().size() : 1;
std::copy_n(seed.GetData() + offset, count, direction.GetData() + offset);
}
Vector physical_direction(static_cast<int>(state.values().size())), action, physical_action, expected_action(static_cast<int>(coordinates.residual_size()));
coordinates.denormalize_state({direction.GetData(), coordinates.state_size()}, {physical_direction.GetData(), coordinates.state_size()});
frozen->jacobian().Mult(direction, action);
frozen->physical()->jacobian().action()->Mult(physical_direction, physical_action);
coordinates.normalize_residual({physical_action.GetData(), coordinates.residual_size()}, {expected_action.GetData(), coordinates.residual_size()});
check_rows(action, expected_action, layout, 2.0e-13, 2.0e-13);
CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(3.0e-12).margin(3.0e-12));
constexpr double step = 2.0e-6;
const auto plus = evaluator.evaluate(perturbed_state(state, step, physical_direction));
REQUIRE(plus);
CHECK(evaluator.cache().report().template status<normalization::keys::Scales>() == dependencies::PreparationStatus::reused);
CHECK(evaluator.cache().report().template status<normalization::keys::Normalization>() == dependencies::PreparationStatus::reused);
CHECK(evaluator.cache().report().template status<deformation::keys::SurfaceMetric>() == dependencies::PreparationStatus::reused);
Vector difference = residual(**plus);
const auto minus = evaluator.evaluate(perturbed_state(state, -step, physical_direction));
REQUIRE(minus);
difference -= residual(**minus);
difference /= 2.0 * step;
check_rows(action, difference, layout, 3.0e-6, 3.0e-8);
}
const auto restored = evaluator.evaluate(state);
REQUIRE(restored);
check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0);
Vector repeated;
frozen->jacobian().MultTranspose(dual, repeated);
CHECK(tests::relative_error(repeated, transpose) == 0.0);
}
/** @brief Check the dimensional inverse, solver-coordinate adapter and independently measured linear residual. */
template <typename Prepared, typename Layout>
void check_stellar_linear_solve(const Prepared& prepared, const Layout& layout,
const serif::discretization::backend::mfem::DofSelection& boundary) {
const auto& system = *prepared.system();
const auto& coordinates = system.coordinates();
const int size = system.jacobian().Width();
REQUIRE(size == system.jacobian().Height());
REQUIRE(coordinates.state_factors().front() != 1.0);
REQUIRE(coordinates.residual_factors().front() != 1.0);
CHECK(prepared.contract() == serif::solver::preconditioner::ApplicationContract::fixed_linear);
const Vector normalized_direction = tests::deterministic(size, 0.37);
Vector physical_direction(size), physical_rhs, normalized_rhs(size);
coordinates.denormalize_state({normalized_direction.GetData(), coordinates.state_size()},
{physical_direction.GetData(), coordinates.state_size()});
system.physical()->jacobian().action()->Mult(physical_direction, physical_rhs);
coordinates.normalize_residual({physical_rhs.GetData(), coordinates.residual_size()},
{normalized_rhs.GetData(), coordinates.residual_size()});
Vector physical_correction(size), expected_correction(size), normalized_correction(size), preconditioned_action;
prepared.physical_preconditioner().Mult(physical_rhs, physical_correction);
coordinates.normalize_state({physical_correction.GetData(), coordinates.state_size()},
{expected_correction.GetData(), coordinates.state_size()});
prepared.preconditioner().Mult(normalized_rhs, normalized_correction);
CHECK(tests::relative_error(normalized_correction, expected_correction) < 3.0e-13);
system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action);
for (const int row : boundary.reduced_dofs()) {
const int index = static_cast<int>(layout.template offset<Kind::residual, Density>()) + row;
CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12));
}
// The joint surface/scalar Schur solve must enforce every border equation and mutual coupling.
for (const std::size_t index : {layout.template offset<Kind::residual, Constant>(), layout.template offset<Kind::residual, Omega>()}) {
CHECK(preconditioned_action[static_cast<int>(index)] == Catch::Approx(physical_rhs[static_cast<int>(index)]).epsilon(2.0e-11).margin(2.0e-12));
}
for (std::size_t mode = 0; mode < layout.template size<Kind::residual, ShapeRow>(); ++mode) {
const int index = static_cast<int>(layout.template offset<Kind::residual, ShapeRow>() + mode);
CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12));
}
mfem::FGMRESSolver krylov;
krylov.SetRelTol(1.0e-10);
krylov.SetAbsTol(1.0e-13);
krylov.SetMaxIter(400);
krylov.SetKDim(100);
krylov.SetPrintLevel(0);
krylov.SetPreconditioner(prepared.preconditioner());
krylov.SetOperator(system.jacobian());
for (const bool newton_right_hand_side : {false, true}) {
CAPTURE(newton_right_hand_side);
Vector solve_rhs(physical_rhs);
if (newton_right_hand_side) {
solve_rhs = residual(*system.physical());
solve_rhs *= -1.0;
}
coordinates.normalize_residual({solve_rhs.GetData(), coordinates.residual_size()},
{normalized_rhs.GetData(), coordinates.residual_size()});
Vector solution(size);
solution = 0.0;
krylov.Mult(normalized_rhs, solution);
CAPTURE(krylov.GetNumIterations(), krylov.GetFinalNorm());
CHECK(krylov.GetConverged());
coordinates.denormalize_state({solution.GetData(), coordinates.state_size()},
{physical_correction.GetData(), coordinates.state_size()});
system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action);
Vector measured_residual(preconditioned_action);
measured_residual -= solve_rhs;
Vector normalized_residual(size);
coordinates.normalize_residual({measured_residual.GetData(), coordinates.residual_size()},
{normalized_residual.GetData(), coordinates.residual_size()});
CHECK(normalized_residual.Norml2() / normalized_rhs.Norml2() < 2.0e-10);
CHECK(measured_residual.Norml2() / solve_rhs.Norml2() < 2.0e-8);
check_rows(preconditioned_action, solve_rhs, layout, 2.0e-7, 2.0e-9);
}
}
/** @brief Prepare the production stellar recipe selectively and retain its complete frozen numerical state. */
void check_moving_preconditioner() {
namespace normalization = serif::solver::normalization;
namespace preconditioner = serif::solver::preconditioner;
using InverseKey = preconditioner::keys::Preconditioner;
using PreparedKey = model::preconditioning::PreparedSystem;
using Status = dependencies::PreparationStatus;
#ifdef MFEM_USE_MPI
// The AMG backend borrows the application's MPI session; the test is its application owner.
if (!mfem::Mpi::IsInitialized()) {
mfem::Mpi::Init();
}
#endif
auto fixture = std::make_shared<Fixture>(mfem::Ordering::byNODES);
std::weak_ptr<Fixture> lifetime = fixture;
Vector right_hand_side, frozen_correction, frozen_action;
auto retained = [&]() {
const auto physics = physical_model();
const auto discretization = discretize(fixture);
using Description = decltype(model::resolve_equilibrium_equations(physics));
using Plan = model::EquilibriumPlan<Polytrope, Description>;
const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002};
const auto layout = backend::equilibrium_layout<Plan>(discretization, shape);
const auto state = initial_state(layout, fixture->fields, shape);
const auto recipe = preconditioner::make_primal_preconditioner(layout);
static_assert(dependencies::ContainsKey<Density, typename decltype(recipe)::Values>);
static_assert(dependencies::ContainsKey<Potential, typename decltype(recipe)::Values>);
static_assert(dependencies::ContainsKey<Shape, typename decltype(recipe)::Values>);
static_assert(!dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>);
const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{
.model = physics, .finite_elements = discretization,
.normalization = normalization::make_normalization(normalization::NormalizationOptions{
.scheme = normalization::LaneEmdenApproximation{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}}, .coordinates = normalization::Dimensionless{},
.gravitational_constant = serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{2.3 / (4.0 * std::numbers::pi)}
}),
.preconditioner = recipe
});
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = 8});
const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}};
auto evaluator = backend::bind_stellar_model(configured, state, volume_options);
const auto physical = evaluator.evaluate(state);
REQUIRE(physical);
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::not_required);
CHECK(evaluator.cache().report().template status<PreparedKey>() == Status::not_required);
const auto prepared = evaluator.prepare_linear_system(state);
if (!prepared) {
UNSCOPED_INFO("Failed computation index: " << prepared.error().computation_index);
if (std::holds_alternative<preconditioner::Failure>(prepared.error().error)) {
UNSCOPED_INFO(std::get<preconditioner::Failure>(prepared.error().error).message);
}
}
REQUIRE(prepared);
const auto first = *prepared;
CHECK(first->system() == *physical);
CHECK(evaluator.cache().report().template status<model::keys::PhysicalSystem>() == Status::reused);
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::computed);
const auto repeated = evaluator.prepare_linear_system(state);
REQUIRE(repeated);
CHECK(*repeated == first);
CHECK(evaluator.cache().report().computed_count() == 0);
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::reused);
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(fixture->fields.density_binding);
check_stellar_linear_solve(*first, layout, boundary);
right_hand_side = tests::deterministic(static_cast<int>(state.values().size()), 0.82);
frozen_correction.SetSize(right_hand_side.Size());
first->preconditioner().Mult(right_hand_side, frozen_correction);
first->system()->jacobian().Mult(right_hand_side, frozen_action);
const Vector first_residual = residual(*first->system());
auto candidate = state;
candidate.template get<Density>()[0] += 0.01;
candidate.template get<Shape>()[0] += 0.0002;
const auto trial = evaluator.evaluate(candidate);
REQUIRE(trial);
CHECK(*trial != first->system());
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::not_required);
CHECK(evaluator.cache().report().template status<PreparedKey>() == Status::not_required);
CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0);
const auto updated = evaluator.prepare_linear_system(candidate);
REQUIRE(updated);
CHECK(*updated != first);
CHECK((*updated)->system() == *trial);
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::computed);
const auto rebuilt = evaluator.prepare_linear_system(candidate, dependencies::make_dependency_context(), dependencies::PreparationMode::rebuild);
REQUIRE(rebuilt);
CHECK(*rebuilt != *updated);
CHECK(evaluator.cache().report().template status<model::keys::PhysicalSystem>() == Status::computed);
CHECK(evaluator.cache().report().template status<InverseKey>() == Status::computed);
Vector repeated_correction(right_hand_side.Size()), repeated_action;
first->preconditioner().Mult(right_hand_side, repeated_correction);
first->system()->jacobian().Mult(right_hand_side, repeated_action);
CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0);
CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0);
// At n=1.5 the exact density Jacobian is undefined at zero density; reject the EOS trial.
auto singular = state;
std::ranges::fill(singular.template get<Density>(), 0.0);
singular.template get<Omega>()[0] = 0.0;
const auto preparations = evaluator.cache().preparation_count();
const auto rejected = evaluator.prepare_linear_system(singular);
REQUIRE_FALSE(rejected);
REQUIRE(std::holds_alternative<execution::PreparationRejection>(rejected.error().error));
CHECK(std::get<execution::PreparationRejection>(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION);
CHECK_FALSE(evaluator.cache().ready());
CHECK_FALSE(evaluator.cache().template contains<PreparedKey>());
CHECK(evaluator.cache().report().computed_count() == 0);
CHECK(evaluator.cache().report().reused_count() == 0);
CHECK(evaluator.cache().preparation_count() == preparations);
first->preconditioner().Mult(right_hand_side, repeated_correction);
first->system()->jacobian().Mult(right_hand_side, repeated_action);
CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0);
CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0);
CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0);
const auto recovered = evaluator.prepare_linear_system(candidate);
REQUIRE(recovered);
CHECK(evaluator.cache().ready());
return first;
}();
fixture.reset();
CHECK_FALSE(lifetime.expired());
Vector repeated_correction(right_hand_side.Size()), repeated_action;
retained->preconditioner().Mult(right_hand_side, repeated_correction);
retained->system()->jacobian().Mult(right_hand_side, repeated_action);
CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0);
CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0);
retained.reset();
CHECK(lifetime.expired());
}
/** @brief Evaluate the full moving star in both native displacement orderings and retain its frozen actions. */
template <bool Normalize = false>
void check_moving_equilibrium(const mfem::Ordering::Type ordering) {
auto fixture = std::make_shared<Fixture>(ordering);
std::weak_ptr<Fixture> lifetime = fixture;
Vector direction, dual, action, transpose, normalized_action, normalized_transpose;
std::shared_ptr<const backend::NormalizedSystem> normalized_retained;
const auto exercise = [&]() {
auto& fields = fixture->fields;
const auto physics = physical_model();
const auto discretization = discretize(fixture);
using Description = decltype(model::resolve_equilibrium_equations(physics));
using Plan = model::EquilibriumPlan<Polytrope, Description>;
const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002};
const auto layout = backend::equilibrium_layout<Plan>(discretization, shape);
REQUIRE(layout.state_sizes.size() == 5);
REQUIRE(layout.residual_sizes.size() == 5);
REQUIRE(layout.template total_size<Kind::value>() == layout.template total_size<Kind::residual>());
REQUIRE(shape.size() < static_cast<std::size_t>(fields.displacement_binding.size()));
const auto state = initial_state(layout, fields, shape);
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(fields.density_binding);
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = 8});
const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}};
auto evaluator = [&]() {
if constexpr (Normalize) {
namespace normalization = serif::solver::normalization;
using Reference = normalization::LaneEmdenApproximation<normalization::FromModel, serif::dimensions::DensityValue>;
const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{
.model = physics, .finite_elements = discretization,
.normalization = normalization::make_normalization(normalization::NormalizationOptions<Reference>{
.scheme = Reference{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}},
.coordinates = normalization::Dimensionless{},
.gravitational_constant = serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{2.3 / (4.0 * std::numbers::pi)}
})
});
return backend::bind_stellar_model(configured, state, volume_options);
} else {
return backend::bind_stellar_model(physics, discretization, state, volume_options);
}
}();
const auto initial = evaluator.evaluate(state);
REQUIRE(initial);
const auto first = *initial;
using MassRow = model::computations::ScalarResidualRow<Constant>;
using AngularMomentumRow = model::computations::ScalarResidualRow<Omega>;
const auto mass_row = evaluator.cache().template share<MassRow>();
const auto angular_momentum_row = evaluator.cache().template share<AngularMomentumRow>();
CHECK(evaluator.cache().report().template status<MassRow>() == dependencies::PreparationStatus::computed);
CHECK(evaluator.cache().report().template status<AngularMomentumRow>() == dependencies::PreparationStatus::computed);
const auto repeated = evaluator.evaluate(state);
REQUIRE(repeated);
CHECK(*repeated == first);
CHECK(evaluator.cache().report().computed_count() == 0);
CHECK(evaluator.cache().template share<MassRow>() == mass_row);
CHECK(evaluator.cache().template share<AngularMomentumRow>() == angular_momentum_row);
// A successful trial is merely another result; the caller retains the previous candidate.
const auto original = state;
auto copied = state;
CHECK(copied.values().data() != state.values().data());
copied.template get<Density>()[0] += 0.01;
copied.template get<Shape>()[0] += 0.0002;
auto moved = std::move(copied);
CHECK(std::ranges::equal(state.values(), original.values()));
const Vector first_residual = residual(*first);
const auto discarded = evaluator.evaluate(moved);
REQUIRE(discarded);
CHECK(evaluator.cache().template share<MassRow>() != mass_row);
CHECK(evaluator.cache().template share<AngularMomentumRow>() != angular_momentum_row);
CHECK(evaluator.cache().report().template status<MassRow>() == dependencies::PreparationStatus::computed);
CHECK(evaluator.cache().report().template status<AngularMomentumRow>() == dependencies::PreparationStatus::computed);
CHECK(*discarded != first);
CHECK(tests::relative_error(residual(**discarded), first_residual) > 1.0e-10);
CHECK(tests::relative_error(residual(*first), first_residual) == 0.0);
CHECK(std::ranges::equal(state.values(), original.values()));
const auto restored = evaluator.evaluate(state);
REQUIRE(restored);
if constexpr (Normalize) {
check_normalized_system(evaluator, state, boundary);
} else {
check_composition(evaluator, state, **restored, boundary);
check_scalar_row<Constant>(evaluator, **restored);
check_scalar_row<Omega>(evaluator, **restored);
check_derivatives(evaluator, state);
}
const auto evaluated = evaluator.evaluate(state);
REQUIRE(evaluated);
const auto retained = *evaluated;
const auto physical = [&]() {
if constexpr (Normalize) {
return retained->physical();
} else {
return retained;
}
}();
const auto preparations = evaluator.cache().preparation_count();
const Vector retained_residual = residual(*retained);
direction = tests::deterministic(static_cast<int>(state.values().size()), 0.61);
dual = tests::deterministic(static_cast<int>(state.values().size()), 1.31);
physical->jacobian().action()->Mult(direction, action);
physical->jacobian().action()->MultTranspose(dual, transpose);
if constexpr (Normalize) {
normalized_retained = retained;
normalized_retained->jacobian().Mult(direction, normalized_action);
normalized_retained->jacobian().MultTranspose(dual, normalized_transpose);
}
auto invalid = state;
invalid.template get<Shape>()[0] = std::numeric_limits<double>::quiet_NaN();
const auto rejected = evaluator.evaluate(invalid);
REQUIRE_FALSE(rejected);
CHECK(std::holds_alternative<deformation::DeformationFailure>(rejected.error().error));
CHECK(tests::relative_error(residual(*retained), retained_residual) == 0.0);
if constexpr (Normalize) {
CHECK_FALSE(evaluator.cache().template contains<serif::model::normalization::PreparedSystem>());
}
CHECK_FALSE(evaluator.cache().ready());
CHECK_FALSE(evaluator.cache().template contains<model::keys::PhysicalSystem>());
CHECK(evaluator.cache().report().computed_count() == 0);
CHECK(evaluator.cache().report().reused_count() == 0);
CHECK(evaluator.cache().preparation_count() == preparations);
const auto recovered = evaluator.evaluate(state);
REQUIRE(recovered);
CHECK(*recovered == retained);
CHECK(evaluator.cache().report().computed_count() == 0);
return physical;
};
auto retained = exercise();
fixture.reset();
CHECK_FALSE(lifetime.expired());
Vector repeated_action, repeated_transpose;
retained->jacobian().action()->Mult(direction, repeated_action);
retained->jacobian().action()->MultTranspose(dual, repeated_transpose);
CHECK(tests::relative_error(repeated_action, action) == 0.0);
CHECK(tests::relative_error(repeated_transpose, transpose) == 0.0);
retained.reset();
if constexpr (Normalize) {
CHECK_FALSE(lifetime.expired());
normalized_retained->jacobian().Mult(direction, repeated_action);
normalized_retained->jacobian().MultTranspose(dual, repeated_transpose);
CHECK(tests::relative_error(repeated_action, normalized_action) == 0.0);
CHECK(tests::relative_error(repeated_transpose, normalized_transpose) == 0.0);
normalized_retained.reset();
}
CHECK(lifetime.expired());
}
}
TEST_CASE("Complete moving stellar assembly preserves every coupled equation and boundary row", "[model][moving_equilibrium][integration]") {
SECTION("Component-block displacement ordering") {
check_moving_equilibrium(mfem::Ordering::byNODES);
}
SECTION("Interleaved displacement ordering") {
check_moving_equilibrium(mfem::Ordering::byVDIM);
}
}
TEST_CASE("Complete moving stellar normalization preserves solver coordinates and frozen ownership", "[model][moving_normalization][integration]") {
check_moving_equilibrium<true>(mfem::Ordering::byNODES);
}
TEST_CASE("Complete moving stellar preconditioning prepares a frozen solver system and solves its physical Jacobian", "[model][moving_preconditioner][integration]") {
#ifdef MFEM_USE_MPI
check_moving_preconditioner();
#else
SKIP("The default stellar recipe uses the one-process BoomerAMG backend, which requires an MPI-enabled MFEM build.");
#endif
}

View File

@@ -0,0 +1,115 @@
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/model/normalized_system.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
#include "serif/backend/mfem/model/normalization/computations.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace normalization = serif::solver::normalization;
namespace dimensions = serif::dimensions;
struct PhysicalResidual {};
struct ResidualFactor {};
/** @brief Give the frozen normalization adapter an independently owned physical action. */
[[nodiscard]] std::shared_ptr<const backend::PhysicalSystem> physical_system(double value) {
auto matrix = std::make_shared<mfem::DenseMatrix>(1);
(*matrix)(0, 0) = 3.0 + value;
const auto block = serif::operators::backend::mfem::linearized_block(std::shared_ptr<const mfem::DenseMatrix>(matrix));
return backend::make_physical_system(1, 1, {{.row_offset = 0, .values = {value}}}, {{.row_offset = 0, .column_offset = 0, .block = block}});
}
/** @brief Keep a valid reference fixed while failure is induced only in coordinate conversion. */
[[nodiscard]] normalization::StellarReferenceScales reference_scales() {
const auto scales = normalization::prepare_stellar_reference_scales(dimensions::MassValue{2.0}, dimensions::LengthValue{3.0});
REQUIRE(scales);
return *scales;
}
}
TEST_CASE("A normalization overflow rejects the new physical preparation without losing frozen solver coordinates", "[model][normalization][rejection]") {
using namespace dependencies;
double value = 1.0;
double factor = 2.0;
int physical_preparations = 0;
const auto scales = reference_scales();
const auto inputs = make_dependency_context(borrow<PhysicalResidual>(value), borrow<ResidualFactor>(factor), borrow<normalization::keys::Scales>(scales));
const auto physical = make_computation<model::keys::PhysicalSystem, InputList<PhysicalResidual>>([&](double residual) {
++physical_preparations;
return physical_system(residual);
});
const auto coordinates = make_computation<normalization::keys::Normalization, InputList<ResidualFactor>>([](double residual_factor) {
return normalization::make_diagonal_normalization({3.0}, {residual_factor});
});
const auto normalized = make_computation<model::normalization::PreparedSystem,
InputList<model::keys::PhysicalSystem, normalization::keys::Normalization, normalization::keys::Scales>>(backend::make_normalized_system);
auto graph = make_computation_graph(physical, coordinates, normalized);
auto cache = make_computation_cache(std::move(graph), inputs);
using Requested = KeyList<model::normalization::PreparedSystem>;
REQUIRE(cache.try_prepare(inputs, Requested{}));
const auto physical_snapshot = cache.get<model::keys::PhysicalSystem>();
const auto frozen = cache.template get<model::normalization::PreparedSystem>();
REQUIRE(frozen->physical() == physical_snapshot);
CHECK(frozen->residual() == std::vector<double>{2.0});
mfem::Vector direction(1), original_action, repeated_action;
direction[0] = 1.0;
frozen->jacobian().Mult(direction, original_action);
CHECK(original_action[0] == Catch::Approx(8.0 / 3.0));
const auto preparations = cache.preparation_count();
value = 2.0;
factor = std::numeric_limits<double>::max();
const auto rejected = cache.try_prepare(inputs, Requested{});
REQUIRE_FALSE(rejected);
CHECK_THAT(rejected.error().error, Catch::Matchers::ContainsSubstring("normalized stellar residual is not finite"));
CHECK(physical_preparations == 2);
CHECK(frozen->physical() == physical_snapshot);
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.template contains<model::keys::PhysicalSystem>());
CHECK_FALSE(cache.template contains<model::normalization::PreparedSystem>());
CHECK(cache.report().computed_count() == 0);
CHECK(cache.report().reused_count() == 0);
CHECK(cache.preparation_count() == preparations);
CHECK(frozen->residual() == std::vector<double>{2.0});
frozen->jacobian().Mult(direction, repeated_action);
CHECK(repeated_action[0] == original_action[0]);
value = 1.0;
factor = 2.0;
REQUIRE(cache.try_prepare(inputs, Requested{}));
CHECK(cache.get<model::keys::PhysicalSystem>() == physical_snapshot);
CHECK(cache.template get<model::normalization::PreparedSystem>() == frozen);
CHECK(cache.report().computed_count() == 0);
value = 2.0;
REQUIRE(cache.try_prepare(inputs, Requested{}));
CHECK(cache.get<model::keys::PhysicalSystem>() != physical_snapshot);
CHECK(cache.template get<model::normalization::PreparedSystem>()->residual() == std::vector<double>{4.0});
CHECK(frozen->residual() == std::vector<double>{2.0});
}
TEST_CASE("Normalized system construction diagnoses missing owners and incompatible coordinates", "[model][normalization][validation]") {
const auto factors = normalization::make_diagonal_normalization({1.0}, {2.0});
REQUIRE(factors);
CHECK_THROWS_WITH(backend::make_normalized_system({}, *factors, reference_scales()), Catch::Matchers::ContainsSubstring("physical system"));
const auto wrong_state = normalization::make_diagonal_normalization({1.0, 1.0}, {2.0});
const auto wrong_residual = normalization::make_diagonal_normalization({1.0}, {2.0, 2.0});
REQUIRE(wrong_state);
REQUIRE(wrong_residual);
CHECK_THROWS_AS(backend::make_normalized_system(physical_system(1.0), *wrong_state, reference_scales()), std::invalid_argument);
CHECK_THROWS_AS(backend::make_normalized_system(physical_system(1.0), *wrong_residual, reference_scales()), std::invalid_argument);
const auto excessive = normalization::make_diagonal_normalization({1.0}, {std::numeric_limits<double>::max()});
REQUIRE(excessive);
CHECK_FALSE(backend::make_normalized_system(physical_system(2.0), *excessive, reference_scales()));
}

View File

@@ -0,0 +1,144 @@
#include <algorithm>
#include <limits>
#include <memory>
#include <tuple>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/backend/mfem/model/computations/pressure_boundary.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace {
namespace test = serif::tests::surface;
namespace fixture = test::boundary;
namespace surface = serif::geometry::surface;
namespace boundary = surface::backend::mfem;
namespace physical = serif::operators::backend::mfem;
namespace model = serif::model;
namespace dependencies = serif::utils::dependencies;
namespace blocks = serif::discretization::blocks;
using Density = fixture::BoundaryDensity;
using Temperature = test::Temperature;
using Vector = mfem::Vector;
struct OtherEquation {
};
/** @brief Keep an independent nonzero interior action so replacement must remove every old constrained-row column. */
std::shared_ptr<const mfem::SparseMatrix> base_matrix(const int rows, const int columns) {
auto matrix = std::make_shared<mfem::SparseMatrix>(rows, columns);
for (int row = 0; row < rows; ++row) {
matrix->Add(row, row % columns, 7.0);
}
matrix->Finalize();
return matrix;
}
/** @brief Check every row against independent MFEM interpolation and the analytic ideal-gas boundary relation. */
template <typename Formulation, bool SolveTemperature>
void check_pressure_graph() {
using Values = std::conditional_t<SolveTemperature, dependencies::KeyList<Temperature, Density>, dependencies::KeyList<Density>>;
using Layout = blocks::SystemLayout<Values, dependencies::KeyList<OtherEquation, Density>>;
fixture::Fixture mesh;
Vector density = fixture::sequence(mesh.density.size(), 0.8, 0.2);
Vector temperature = fixture::sequence(mesh.temperature.size(), 1.7, 0.4);
const auto state = physical::make_term_coefficients(physical::bind(mesh.density, density), physical::bind(mesh.temperature, temperature));
const auto compiled = boundary::make_pressure_boundary<Formulation>(state);
Layout layout;
if constexpr (SolveTemperature) {
layout.state_sizes = {static_cast<std::size_t>(temperature.Size()), static_cast<std::size_t>(density.Size())};
}
else {
layout.state_sizes = {static_cast<std::size_t>(density.Size())};
}
layout.residual_sizes = {2, static_cast<std::size_t>(density.Size())};
test::GasEOS eos;
const auto inputs = boundary::make_pressure_boundary_inputs(compiled, dependencies::borrow<surface::keys::EquationOfState>(eos, dependencies::track_by([](const test::GasEOS&) {
return 0;
})),
surface::IsobaricSurface{test::quantities::PressureValue{0.8}}, state);
auto cache = std::apply([&](auto... node) {
return dependencies::make_computation_cache<surface::SurfaceFailure>(inputs, std::move(node)...);
},
std::tuple_cat(boundary::make_pressure_boundary_computations<Formulation>(), std::tuple{model::computations::pressure_boundary_contribution<Formulation>(layout)}));
REQUIRE(cache.try_prepare(inputs));
const auto frozen = cache.template share<model::computations::PressureBoundaryContribution>();
REQUIRE(frozen->replacements.size() == 1);
const auto& replacement = frozen->replacements[0];
REQUIRE(replacement.jacobian.size() == (SolveTemperature ? 2 : 1));
REQUIRE(replacement.residual.size() == static_cast<std::size_t>(compiled->selected_rows->size()));
const int rows = density.Size() + 2;
const int columns = static_cast<int>(layout.template total_size<blocks::RepresentedValueKind::value>());
const auto base = base_matrix(rows, columns);
const model::backend::mfem::PhysicalSystem accepted(rows, columns,
{{.row_offset = 0, .values = std::vector<double>(rows, 9.0)}, {.row_offset = 0, .values = std::vector<double>(rows, 2.0)}},
{{.row_offset = 0, .column_offset = 0, .block = physical::linearized_block(base)}}, frozen->replacements);
const Vector density_samples = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.density, density);
const Vector temperature_samples = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.temperature, temperature);
Vector direction = fixture::sequence(columns, 0.3, 0.7);
const int density_offset = static_cast<int>(layout.template offset<blocks::RepresentedValueKind::value, Density>());
Vector density_direction(direction.GetData() + density_offset, density.Size());
const Vector density_variation = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.density, density_direction);
Vector temperature_variation(compiled->selected_rows->size());
temperature_variation = 0.0;
if constexpr (SolveTemperature) {
Vector temperature_direction(direction.GetData(), temperature.Size());
temperature_variation = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.temperature, temperature_direction);
}
Vector expected(rows), actual(rows), expected_residual(rows);
base->Mult(direction, expected);
expected_residual = 11.0;
for (int index = 0; index < compiled->selected_rows->size(); ++index) {
const int row = 2 + compiled->selected_rows->reduced_dofs()[index];
CHECK(replacement.selected_rows[index] == row);
const double rho = density_samples[index];
const double thermal = temperature_samples[index];
if constexpr (Formulation::resolves_carrier) {
expected_residual[row] = rho - 0.8 / thermal;
expected[row] = density_variation[index] + 0.8 * temperature_variation[index] / (thermal * thermal);
}
else {
expected_residual[row] = rho * thermal - 0.8;
expected[row] = thermal * density_variation[index] + rho * temperature_variation[index];
}
}
accepted.jacobian().action()->Mult(direction, actual);
for (int row = 0; row < rows; ++row) {
CHECK(accepted.residual()[row] == Catch::Approx(expected_residual[row]).margin(3.0e-13));
CHECK(actual[row] == Catch::Approx(expected[row]).margin(3.0e-13));
}
const Vector dual = fixture::sequence(rows, 0.5, 0.2);
Vector transpose(columns);
accepted.jacobian().action()->MultTranspose(dual, transpose);
CHECK((dual * actual) == Catch::Approx(direction * transpose).margin(2.0e-11));
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.report().computed_count() == 0);
CHECK(cache.template share<model::computations::PressureBoundaryContribution>() == frozen);
density += 0.04;
REQUIRE(cache.try_prepare(inputs));
CHECK(cache.template share<model::computations::PressureBoundaryContribution>() != frozen);
accepted.jacobian().action()->Mult(direction, actual);
for (int row = 0; row < rows; ++row) {
CHECK(actual[row] == Catch::Approx(expected[row]).margin(3.0e-13));
}
}
} // namespace
TEST_CASE("Model pressure replacements preserve general EOS fields and independent system ordering", "[unit][model][pressure_graph]") {
SECTION("Density carries an isobaric condition with solved temperature") {
check_pressure_graph<fixture::DensityFormulation<>, true>();
}
SECTION("Direct pressure contributes interpolated columns for every solved EOS field") {
check_pressure_graph<fixture::PressureFormulation<>, true>();
}
SECTION("Prescribed temperature is evaluated without introducing a Newton column") {
check_pressure_graph<fixture::PressureFormulation<>, false>();
}
}
TEST_CASE("Pressure replacement rejects layouts that cannot be represented by MFEM indices", "[unit][model][pressure_graph]") {
using Layout = blocks::SystemLayout<dependencies::KeyList<Density>, dependencies::KeyList<Density>>;
const Layout layout{.state_sizes = {static_cast<std::size_t>(std::numeric_limits<int>::max()) + 1}, .residual_sizes = {1}};
CHECK_THROWS(model::computations::pressure_boundary_contribution<fixture::PressureFormulation<>>(layout));
}

View File

@@ -0,0 +1,119 @@
#include <cmath>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/model/finite_elements.hpp"
#include "serif/backend/mfem/discretization/field_values.hpp"
#include "serif/backend/mfem/serialize/discretization.hpp"
#include "../sandbox/polytropic_model.hpp"
TEST_CASE("Stellar factory selects compatible surface layers and preserves the ordinary default", "[model][reconstruction]") {
namespace reconstruction = serif::discretization::reconstruction;
namespace blocks = serif::discretization::blocks;
namespace model = serif::model;
using Density = blocks::density::mass;
const auto star = serif::examples::make_polytropic_model(4.0 / 3.0);
const auto ordinary = model::make_stellar_discretization(star);
CHECK_FALSE(ordinary.binding<Density>().reconstruction());
const auto identity = model::make_stellar_discretization(star, {}, reconstruction::SurfacePower<Density>{.density_exponent = 1.0});
CHECK_FALSE(identity.binding<Density>().reconstruction());
for (const int equatorial : {0, 2}) {
const auto weighted = model::make_stellar_discretization(star,
{.surface_refinement = {.levels = 1, .equatorial_levels = equatorial, .equatorial_half_angle_degrees = 10.0}},
reconstruction::SurfacePower<Density>{.density_exponent = 4.0 / 3.0,
.region = reconstruction::SurfaceAdjacent<serif::discretization::domain::StellarSurfaceBoundary>{}});
const auto& density = weighted.binding<Density>();
REQUIRE(density.reconstruction());
int selected = 0, ordinary_material = 0;
for (int element = 0; element < density.mesh().GetNE(); ++element) {
if (!density.supports(element)) { continue; }
if (density.reconstruction_element(element)) { ++selected; }
else { ++ordinary_material; }
}
CHECK(selected > 0);
CHECK(ordinary_material > 0);
CHECK_FALSE(weighted.binding<blocks::gravity::potential>().reconstruction());
}
CHECK_THROWS_WITH((model::make_stellar_discretization(star, {}, reconstruction::SurfacePower<blocks::gravity::potential>{})),
Catch::Matchers::ContainsSubstring("only scalar H1 mass density"));
CHECK_THROWS_WITH((model::make_stellar_discretization(star, {}, reconstruction::SurfacePower<Density>{}, reconstruction::SurfacePower<Density>{})),
Catch::Matchers::ContainsSubstring("at most one"));
}
TEST_CASE("Weighted archives preserve between-node density and reject missing interpretation", "[model][reconstruction]") {
namespace reconstruction = serif::discretization::reconstruction;
namespace backend = serif::backend::mfem::serialize;
namespace discretization = serif::discretization::backend::mfem;
using Density = serif::discretization::blocks::density::mass;
const auto star = serif::examples::make_polytropic_model(5.0 / 3.0);
const auto original = serif::model::make_stellar_discretization(star, {}, reconstruction::SurfacePower<Density>{.density_exponent = 5.0 / 3.0});
const auto record = backend::save_discretization(original);
auto restored = backend::restore_discretization<decltype(original)>(record);
INFO((restored ? "restored" : restored.error().message));
REQUIRE(restored);
CHECK(backend::save_discretization(*restored) == record);
const auto& binding = original.binding<Density>();
::mfem::Vector coefficients(binding.size());
for (int i = 0; i < coefficients.Size(); ++i) { coefficients[i] = std::sin(0.13 * i); }
discretization::FieldValues<Density> before(binding, coefficients), after(restored->binding<Density>(), coefficients);
::mfem::IntegrationPoint point;
point.Set3(0.17, 0.31, 0.87);
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
if (!binding.supports(element)) { continue; }
CHECK(before.value(element, point) == Catch::Approx(after.value(element, point)).margin(1e-13));
}
auto malformed = record;
for (auto& [key, saved] : malformed.children.at("bindings").children) {
if (saved.children.contains("reconstruction")) {
CHECK(saved.version == 2);
saved.children.erase("reconstruction");
}
}
CHECK_FALSE(backend::restore_discretization<decltype(original)>(malformed));
}
TEST_CASE("Reconstruction plans reject attachment after the boundary changes", "[model][reconstruction]") {
namespace reconstruction = serif::discretization::reconstruction;
using Density = serif::discretization::blocks::density::mass;
const auto star = serif::examples::make_polytropic_model(1.5);
const auto finite_elements = serif::model::make_stellar_discretization(star, {}, reconstruction::SurfacePower<Density>{.density_exponent = 1.5});
const auto& density = finite_elements.binding<Density>();
const auto plan = density.reconstruction();
auto& mesh = density.mesh();
REQUIRE(mesh.GetNBE() > 0);
mesh.SetBdrAttribute(0, mesh.GetBdrAttribute(0) + 10);
using Binding = std::remove_cvref_t<decltype(density)>;
CHECK_THROWS_WITH(Binding(density.space(), plan), Catch::Matchers::ContainsSubstring("Rebuild the reconstruction plan"));
CHECK_THROWS(density.validate());
}
TEST_CASE("Reconstruction rejects radial hanging layers and stale refined-space plans", "[model][reconstruction]") {
namespace backend = serif::discretization::backend::mfem;
namespace reconstruction = serif::discretization::reconstruction;
using Density = serif::discretization::blocks::density::mass;
using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
auto mesh = ::mfem::Mesh::MakeCartesian3D(2, 1, 1, ::mfem::Element::HEXAHEDRON);
::mfem::Array<int> vertices;
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) {
mesh.GetBdrElementVertices(boundary, vertices);
bool surface = true;
for (int vertex : vertices) { surface = surface && mesh.GetVertex(vertex)[2] == 0.0; }
mesh.GetBdrElement(boundary)->SetAttribute(surface ? 1 : 2);
}
mesh.SetAttributes();
::mfem::H1_FECollection collection(3, 3);
::mfem::FiniteElementSpace space(&mesh, &collection);
backend::TermBinding<Density, Schema> original(space);
const auto specification = reconstruction::SurfacePower<Density>{.density_exponent = 1.5};
const auto old_plan = backend::make_reconstruction(specification, original);
::mfem::Array<::mfem::Refinement> refinement;
refinement.Append(::mfem::Refinement(0, ::mfem::Refinement::Z));
mesh.GeneralRefinement(refinement, 1);
space.Update();
CHECK_THROWS_WITH((backend::TermBinding<Density, Schema>(space, old_plan)),
Catch::Matchers::ContainsSubstring("Rebuild the reconstruction plan"));
backend::TermBinding<Density, Schema> updated(space);
CHECK_THROWS_WITH(backend::make_reconstruction(specification, updated),
Catch::Matchers::ContainsSubstring("matching radial layers"));
}

View File

@@ -0,0 +1,206 @@
#include <algorithm>
#include <cmath>
#include <span>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/model/equilibrium.hpp"
#include "serif/model/finite_elements.hpp"
#include "serif/runtime.hpp"
#include "serif/solver/seed/state.hpp"
#include "../sandbox/polytropic_model.hpp"
namespace {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace blocks = serif::discretization::blocks;
namespace deformation = serif::geometry::deformation;
namespace normalization = serif::solver::normalization;
using Density = blocks::density::mass;
using Potential = blocks::gravity::potential;
using Constant = blocks::barotropic_constant::mass_normalization;
using Omega = blocks::rotation::angular_velocity;
using Shape = blocks::surface_deformation::coefficients;
using Kind = blocks::RepresentedValueKind;
/** @brief Make quadrature refinement explicit without changing the represented stellar state. */
[[nodiscard]] serif::operators::backend::mfem::VolumeOptions integration_order(int order) {
serif::discretization::quadrature::RuleSet rules;
rules.set_fallback_control({.fixed_order = order});
return {.quadrature_policy = serif::discretization::quadrature::Policy{rules}};
}
/** @brief Freeze a residual before preparing another physical candidate. */
template <typename System>
[[nodiscard]] mfem::Vector residual_copy(const System& system) {
mfem::Vector result(static_cast<int>(system.residual().size()));
std::copy(system.residual().begin(), system.residual().end(), result.GetData());
return result;
}
/** @brief Exercise all coefficients with a reproducible bounded direction, independent of mesh numbering. */
[[nodiscard]] mfem::Vector direction_values(int size, double phase) {
mfem::Vector result(size);
for (int index = 0; index < size; ++index) { result[index] = std::sin(0.37 * index + phase); }
return result;
}
/** @brief Compare each physical equation separately so gravity cannot mask an incorrect scalar or surface row. */
template <typename Layout>
void check_rows(const mfem::Vector& actual, const mfem::Vector& expected, const Layout& layout,
double relative_tolerance, double absolute_tolerance) {
REQUIRE(actual.Size() == expected.Size());
int offset = 0;
for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) {
CAPTURE(row);
double error_squared = 0.0, reference_squared = 0.0;
for (std::size_t entry = 0; entry < layout.residual_sizes[row]; ++entry, ++offset) {
const double error = actual[offset] - expected[offset];
error_squared += error * error;
reference_squared += expected[offset] * expected[offset];
}
CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared));
}
}
/** @brief Perturb solver coordinates through their actual dimensional scaling. */
template <typename Layout>
[[nodiscard]] model::StellarState<Layout> perturbed(const model::StellarState<Layout>& state,
const normalization::DiagonalNormalization& coordinates, const mfem::Vector& direction, double step) {
mfem::Vector physical(direction.Size());
coordinates.denormalize_state({direction.GetData(), static_cast<std::size_t>(direction.Size())},
{physical.GetData(), static_cast<std::size_t>(physical.Size())});
model::StellarState<Layout> result(state);
for (int index = 0; index < direction.Size(); ++index) { result.values()[index] += step * physical[index]; }
return result;
}
/** @brief Verify strong physical boundary equations after the complete normalized transpose assembly. */
template <typename Field, typename Layout, typename Selection>
void check_boundary_transpose(const backend::NormalizedSystem& system, const Layout& layout, const Selection& selected) {
const auto& coordinates = system.coordinates();
const int row_offset = static_cast<int>(layout.template offset<Kind::residual, Field>());
const int column_offset = static_cast<int>(layout.template offset<Kind::value, Field>());
mfem::Vector dual(system.jacobian().Height()), expected(system.jacobian().Width()), actual;
dual = 0.0;
expected = 0.0;
REQUIRE(selected.size() > 0);
for (int row : selected.reduced_dofs()) {
const double value = 0.2 + 0.01 * (row % 7);
dual[row_offset + row] = value;
expected[column_offset + row] = value * coordinates.residual_factors()[row_offset + row] /
coordinates.state_factors()[column_offset + row];
}
system.jacobian().MultTranspose(dual, actual);
actual -= expected;
CHECK(actual.Norml2() <= 2e-12 * expected.Norml2());
}
}
TEST_CASE("Weighted moving stars retain complete normalized derivatives and physical boundary rows", "[model][integration][reconstruction][reconstruction_system]") {
serif::initialize();
const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04);
const auto radial = serif::solver::seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 3},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{.surface = {.maximum_degree = 2}},
serif::discretization::reconstruction::SurfacePower<Density>{.density_exponent = 4.0 / 3.0});
auto seeded = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(seeded);
auto state = *seeded;
state.get<Omega>()[0] = 0.08;
REQUIRE(state.get<Shape>().size() == 9);
state.get<Shape>()[0] = 0.01;
state.get<Shape>()[6] = -0.015;
const auto scales = normalization::prepare_stellar_reference_scales(radial->scales.mass, radial->scales.radius);
REQUIRE(scales);
const auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = star, .finite_elements = finite_elements,
.normalization = normalization::NormalizationOptions{.scheme = *scales}});
auto evaluator = backend::bind_stellar_model(configuration, state, integration_order(12));
const auto evaluated = evaluator.evaluate(state);
REQUIRE(evaluated);
const auto frozen = *evaluated;
const auto& layout = state.layout();
const auto& coordinates = frozen->coordinates();
const mfem::Vector original_residual = residual_copy(*frozen);
const mfem::Vector seed = direction_values(frozen->jacobian().Width(), 0.43);
const mfem::Vector dual = direction_values(frozen->jacobian().Height(), 1.17);
mfem::Vector transpose;
frozen->jacobian().MultTranspose(dual, transpose);
int offset = 0;
for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) {
CAPTURE(column);
mfem::Vector direction(seed);
if (column < layout.state_sizes.size()) {
direction = 0.0;
std::copy_n(seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset);
offset += static_cast<int>(layout.state_sizes[column]);
}
mfem::Vector action;
frozen->jacobian().Mult(direction, action);
REQUIRE(action.Norml2() > 1e-10);
CHECK(action * dual == Catch::Approx(direction * transpose).epsilon(2e-11).margin(2e-11));
const std::vector<double> steps = column < layout.state_sizes.size() ? std::vector<double>{2e-6} : std::vector<double>{2e-6, 1e-6};
for (const double step : steps) {
CAPTURE(step);
const auto plus = evaluator.evaluate(perturbed(state, coordinates, direction, step));
REQUIRE(plus);
mfem::Vector difference = residual_copy(**plus);
const auto minus = evaluator.evaluate(perturbed(state, coordinates, direction, -step));
REQUIRE(minus);
difference -= residual_copy(**minus);
difference /= 2.0 * step;
check_rows(action, difference, layout, 5e-6, 2e-8);
}
}
const auto density_surface = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(finite_elements.binding<Density>());
const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.binding<Potential>());
check_boundary_transpose<Density>(*frozen, layout, density_surface);
check_boundary_transpose<Potential>(*frozen, layout, infinity);
const auto restored = evaluator.evaluate(state);
REQUIRE(restored);
check_rows(residual_copy(**restored), original_residual, layout, 0.0, 0.0);
mfem::Vector repeated;
frozen->jacobian().MultTranspose(dual, repeated);
repeated -= transpose;
CHECK(repeated.Norml2() == 0.0);
}
TEST_CASE("Weighted moving-star mass and angular momentum converge under independent quadrature refinement", "[model][integration][reconstruction][reconstruction_quadrature]") {
serif::initialize();
const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04);
const auto radial = serif::solver::seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto finite_elements = model::make_stellar_discretization(star,
{.radius = radial->scales.radius, .geometry_order = 3},
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{.surface = {.maximum_degree = 2}},
serif::discretization::reconstruction::SurfacePower<Density>{.density_exponent = 4.0 / 3.0});
auto state = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial);
REQUIRE(state);
state->get<Omega>()[0] = 0.08;
state->get<Shape>()[6] = -0.015;
std::vector<mfem::Vector> residuals;
for (int order : {12, 20, 28}) {
auto evaluator = backend::bind_stellar_model(star, finite_elements, *state, integration_order(order));
const auto evaluated = evaluator.evaluate(*state);
REQUIRE(evaluated);
residuals.push_back(residual_copy(**evaluated));
}
const auto check_functional = [&]<typename Row>() {
const int offset = static_cast<int>(state->layout().template offset<Kind::residual, Row>());
const double coarse_error = std::abs(residuals[0][offset] - residuals[2][offset]);
const double fine_error = std::abs(residuals[1][offset] - residuals[2][offset]);
CAPTURE(offset, coarse_error, fine_error);
CHECK(fine_error <= 0.7 * coarse_error + 1e-11);
CHECK(fine_error < 1e-4);
};
check_functional.template operator()<Constant>();
check_functional.template operator()<Omega>();
}

385
tests/model/runtime.cpp Normal file
View File

@@ -0,0 +1,385 @@
#include <array>
#include <limits>
#include <memory>
#include <optional>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/model/system.hpp"
namespace {
namespace backend = serif::model::backend::mfem;
using Catch::Matchers::WithinAbs;
/** @brief Make small independent matrices whose entries reveal coordinate permutations. */
[[nodiscard]] backend::LinearizedBlock matrix(int rows, int columns, std::initializer_list<double> entries) {
auto values = std::make_shared<::mfem::DenseMatrix>(rows, columns);
auto entry = entries.begin();
for (int row = 0; row < rows; ++row) {
for (int column = 0; column < columns; ++column) {
(*values)(row, column) = *entry++;
}
}
return serif::operators::backend::mfem::linearized_block(std::shared_ptr<const ::mfem::DenseMatrix>(values));
}
/** @brief Compare all coordinate entries, independent of the implementation's block selection. */
void check_vector(const ::mfem::Vector& actual, std::initializer_list<double> expected) {
REQUIRE(actual.Size() == static_cast<int>(expected.size()));
int index = 0;
for (const double value : expected) {
CHECK_THAT(actual[index++], WithinAbs(value, 1.0e-13));
}
}
} // namespace
TEST_CASE("Model runtime adds generated physics before replacing complete boundary rows", "[model][runtime][unit]") {
const auto accepted = backend::make_physical_system(4, 5,
{{0, {1.0, 2.0, 3.0}}, {1, {10.0, 20.0, 30.0}}},
{{0, 2, matrix(3, 3, {1, 2, 3, 4, 5, 6, 7, 8, 9})},
{1, 0, matrix(3, 2, {10, 11, 12, 13, 14, 15})},
{0, 1, matrix(4, 1, {20, 21, 22, 23})}},
{{{3, 1}, {300, 100}, {{2, matrix(2, 3, {2, 3, 4, 5, 6, 7})}}},
{{0}, {400}, {{0, matrix(1, 1, {8})}}}});
CHECK(accepted->residual() == std::vector<double>{400, 100, 23, 300});
const auto& jacobian = accepted->jacobian();
REQUIRE(jacobian.has_assembly());
CHECK_FALSE(jacobian.has_diagonal());
const auto assembled = jacobian.assemble();
const std::array<std::array<double, 5>, 4> expected{{{8, 0, 0, 0, 0}, {0, 0, 5, 6, 7}, {12, 35, 7, 8, 9}, {0, 0, 2, 3, 4}}};
for (int row = 0; row < 4; ++row) {
for (int column = 0; column < 5; ++column) {
CHECK_THAT((*assembled)(row, column), WithinAbs(expected[row][column], 1.0e-14));
}
}
::mfem::Vector direction(5), dual(4), action, transpose;
for (int index = 0; index < 5; ++index) {
direction[index] = index + 1;
}
for (int index = 0; index < 4; ++index) {
dual[index] = 0.25 + index;
}
jacobian.action()->Mult(direction, action);
check_vector(action, {8, 74, 180, 38});
jacobian.action()->MultTranspose(dual, transpose);
CHECK_THAT(dual * action, WithinAbs(direction * transpose, 1.0e-12));
const auto selected = accepted->block(1, 2, 2, 2);
CHECK(selected.has_diagonal());
CHECK(selected.diagonal() == std::vector<double>{5, 8});
::mfem::Vector local(2), selected_action;
local[0] = 2;
local[1] = 3;
selected.action()->Mult(local, selected_action);
check_vector(selected_action, {28, 38});
}
TEST_CASE("Model selected blocks retain owners and skip unrelated equation actions", "[model][runtime][unit]") {
auto calls = std::make_shared<int>(0);
auto owner = std::make_shared<int>(37);
std::weak_ptr<const int> lifetime = owner;
const auto unrelated = serif::operators::backend::mfem::linearized_action(1, 1, [calls](const auto& input, auto& output) {
++*calls;
output[0] = input[0];
}, [calls](const auto& input, auto& output) {
++*calls;
output[0] = input[0];
});
auto accepted = backend::make_physical_system(3, 3, {}, {{0, 0, matrix(2, 2, {2, 1, -1, 3})}, {2, 2, unrelated}}, {}, owner);
std::optional<backend::LinearizedBlock> selected = accepted->block(0, 2, 0, 2);
owner.reset();
accepted.reset();
CHECK_FALSE(lifetime.expired());
::mfem::Vector input(2), output;
input = 1.0;
selected->action()->Mult(input, output);
check_vector(output, {3, 2});
CHECK(*calls == 0);
CHECK(selected->has_diagonal());
CHECK(selected->has_assembly());
CHECK(selected->diagonal() == std::vector<double>{2, 3});
selected.reset();
CHECK(lifetime.expired());
}
TEST_CASE("Grouped model blocks preserve ordered ranges and boundary rows with one action per child", "[model][runtime][unit]") {
const auto original = matrix(4, 5, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20});
auto calls = std::make_shared<std::array<int, 2>>();
const auto action = serif::operators::backend::mfem::linearized_action(4, 5, [calls, original](const auto& input, auto& output) {
++(*calls)[0];
original.action()->Mult(input, output);
}, [calls, original](const auto& input, auto& output) {
++(*calls)[1];
original.action()->MultTranspose(input, output);
});
const backend::LinearizedBlock counted(action.action(), {}, [original]() {
return original.assemble();
});
auto unrelated_calls = std::make_shared<int>(0);
const auto unrelated_action = [unrelated_calls](const auto& input, auto& output) {
++*unrelated_calls;
output = input;
};
auto owner = std::make_shared<int>(42);
std::weak_ptr<const int> lifetime = owner;
auto accepted = backend::make_physical_system(5, 6, {}, {{0, 0, counted}, {4, 5, serif::operators::backend::mfem::linearized_action(1, 1, unrelated_action, unrelated_action)}},
{{{1, 3}, {0.0, 0.0}, {{0, matrix(2, 5, {101, 102, 103, 104, 105, 106, 107, 108, 109, 110})}}}}, owner);
const std::array rows{backend::CoordinateRange{2, 2}, backend::CoordinateRange{0, 2}};
const std::array columns{backend::CoordinateRange{3, 2}, backend::CoordinateRange{0, 2}};
std::optional<backend::LinearizedBlock> selected = accepted->block(rows, columns);
accepted.reset();
owner.reset();
CHECK_FALSE(lifetime.expired());
const std::array<std::array<double, 4>, 4> expected{{{14, 15, 11, 12}, {109, 110, 106, 107}, {4, 5, 1, 2}, {104, 105, 101, 102}}};
const auto assembled = selected->assemble();
for (int row = 0; row < 4; ++row) {
for (int column = 0; column < 4; ++column) {
CHECK((*assembled)(row, column) == expected[row][column]);
}
}
CHECK(selected->diagonal() == std::vector<double>{14, 110, 1, 102});
CHECK(*calls == std::array<int, 2>{0, 0});
::mfem::Vector input(4), dual(4), result, transpose;
for (int index = 0; index < 4; ++index) {
input[index] = index + 1;
dual[index] = index - 0.5;
}
selected->action()->Mult(input, result);
selected->action()->MultTranspose(dual, transpose);
check_vector(result, {125, 1075, 25, 1025});
CHECK_THAT(result * dual, WithinAbs(input * transpose, 1.0e-12));
CHECK(*calls == std::array<int, 2>{1, 1});
CHECK(*unrelated_calls == 0);
selected.reset();
CHECK(lifetime.expired());
}
TEST_CASE("Grouped model selections retain native diagonals and exact transposes for repeated ranges", "[model][runtime][unit]") {
const auto original = matrix(3, 3, {1, 2, 3, 4, 5, 6, 7, 8, 9});
const backend::LinearizedBlock diagonal_only(original.action(), [original]() {
return original.diagonal();
});
const auto accepted = backend::make_physical_system(3, 3, {}, {{0, 0, diagonal_only}});
const std::array ranges{backend::CoordinateRange{2, 1}, backend::CoordinateRange{0, 2}, backend::CoordinateRange{2, 1}};
const auto selected = accepted->block(ranges, ranges);
CHECK_FALSE(selected.has_assembly());
REQUIRE(selected.has_diagonal());
CHECK(selected.diagonal() == std::vector<double>{9, 1, 5, 9});
::mfem::Vector input(4), dual(4), output, transpose;
for (int index = 0; index < 4; ++index) {
input[index] = index + 1;
dual[index] = 0.5 * index - 0.3;
}
selected.action()->Mult(input, output);
selected.action()->MultTranspose(dual, transpose);
check_vector(output, {83, 23, 53, 83});
CHECK_THAT(output * dual, WithinAbs(input * transpose, 1.0e-12));
const auto empty = accepted->block(std::span<const backend::CoordinateRange>{}, ranges);
CHECK(empty.action()->Height() == 0);
CHECK(empty.action()->Width() == 4);
empty.action()->Mult(input, output);
CHECK(output.Size() == 0);
}
TEST_CASE("Model runtime actions permit both shifted buffer overlaps", "[model][runtime][unit]") {
const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, matrix(2, 2, {2, 1, -1, 3})}});
for (const bool transpose : {false, true}) {
for (const bool forward_overlap : {false, true}) {
std::array<double, 3> storage{};
::mfem::Vector input(storage.data() + (forward_overlap ? 0 : 1), 2);
::mfem::Vector output(storage.data() + (forward_overlap ? 1 : 0), 2);
input[0] = 4;
input[1] = 5;
if (transpose) {
accepted->jacobian().action()->MultTranspose(input, output);
check_vector(output, {3, 19});
}
else {
accepted->jacobian().action()->Mult(input, output);
check_vector(output, {13, 11});
}
}
}
}
TEST_CASE("Model runtime rejects malformed contribution and replacement coordinates", "[model][runtime][unit]") {
const auto unit = matrix(1, 1, {1});
CHECK_THROWS(backend::make_physical_system(-1, 1, {}, {}));
CHECK_THROWS(backend::make_physical_system(1, 1, {{1, {1}}}, {}));
CHECK_THROWS(backend::make_physical_system(1, 1, {}, {{0, 1, unit}}));
CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0, 0}, {1, 2}, {}}}));
CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0}, {}, {}}}));
CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{1}, {0}, {}}}));
CHECK_THROWS(backend::make_physical_system(2, 1, {}, {}, {{{0, 1}, {0, 0}, {{0, unit}}}}));
CHECK_THROWS(backend::make_physical_system(1, 1, {{0, {std::numeric_limits<double>::quiet_NaN()}}}, {}));
CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0}, {std::numeric_limits<double>::infinity()}, {}}}));
const auto accepted = backend::make_physical_system(1, 2, {}, {});
CHECK_THROWS(accepted->block(0, 2, 0, 1));
const std::array row{backend::CoordinateRange{0, 1}};
for (const backend::CoordinateRange invalid : {backend::CoordinateRange{-1, 1}, {0, -1}, {2, 1}, {std::numeric_limits<int>::max(), 1}}) {
CHECK_THROWS(accepted->block(row, std::array{invalid}));
}
const auto wide = backend::make_physical_system(0, std::numeric_limits<int>::max(), {}, {});
CHECK_THROWS_WITH(wide->block(std::span<const backend::CoordinateRange>{}, std::array{backend::CoordinateRange{0, std::numeric_limits<int>::max()}, backend::CoordinateRange{0, 1}}),
Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
::mfem::Vector wrong(3), result(1);
wrong = 0.0;
result = 19;
CHECK_THROWS(accepted->jacobian().action()->Mult(wrong, result));
CHECK(result[0] == 19);
}
TEST_CASE("Model action composition preserves forward transpose and native assembly", "[model][runtime][unit]") {
const auto left = matrix(2, 3, {1, 2, 3, 4, 5, 6});
const auto right = matrix(3, 2, {2, -1, 3, 2, 1, 4});
const auto product = serif::operators::backend::mfem::compose_blocks(left, right);
const auto result = serif::operators::backend::mfem::sum_blocks(2, 2, {product, serif::operators::backend::mfem::transpose_block(product)});
REQUIRE(result.has_assembly());
const auto assembled = result.assemble();
CHECK((*assembled)(0, 0) == 22);
CHECK((*assembled)(0, 1) == 44);
CHECK((*assembled)(1, 0) == 44);
CHECK((*assembled)(1, 1) == 60);
CHECK_THROWS(serif::operators::backend::mfem::compose_blocks(left, left));
CHECK_THROWS(serif::operators::backend::mfem::sum_blocks(1, 1, {left}));
}
TEST_CASE("Model native diagonals do not probe actions or require sparse assembly", "[model][runtime][unit]") {
auto actions = std::make_shared<int>(0);
auto diagonals = std::make_shared<int>(0);
const auto action = serif::operators::backend::mfem::linearized_action(2, 2, [actions](const auto& input, auto& output) {
++*actions;
output = input;
}, [actions](const auto& input, auto& output) {
++*actions;
output = input;
});
const backend::LinearizedBlock block(action.action(), [diagonals]() {
++*diagonals;
return std::vector<double>{1, 1};
});
const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, block}, {0, 0, block}});
REQUIRE(accepted->jacobian().has_diagonal());
CHECK_FALSE(accepted->jacobian().has_assembly());
CHECK(accepted->jacobian().diagonal() == std::vector<double>{2, 2});
CHECK(*actions == 0);
CHECK(*diagonals == 2);
}
TEST_CASE("Model composition and selection reject malformed physical block callbacks", "[model][runtime][unit]") {
const auto original = matrix(2, 2, {1, 0, 0, 1});
const auto check_rejection = [&original](backend::LinearizedBlock invalid) {
const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, invalid}});
CHECK_THROWS_WITH(accepted->jacobian().assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::transpose_block(invalid).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::compose_blocks(original, invalid).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::compose_blocks(invalid, original).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::sum_blocks(2, 2, {original, invalid}).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
};
check_rejection(backend::LinearizedBlock(original.action(), {}, []() {
return std::shared_ptr<const ::mfem::SparseMatrix>{};
}));
check_rejection(backend::LinearizedBlock(original.action(), {}, []() {
return std::make_shared<const ::mfem::SparseMatrix>(2);
}));
check_rejection(backend::LinearizedBlock(original.action(), {}, []() {
return matrix(1, 2, {1, 0}).assemble();
}));
const backend::LinearizedBlock invalid_diagonal(original.action(), []() {
return std::vector<double>{1};
});
const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, invalid_diagonal}});
CHECK_THROWS_WITH(accepted->jacobian().diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::transpose_block(invalid_diagonal).diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
CHECK_THROWS_WITH(serif::operators::backend::mfem::sum_blocks(2, 2, {original, invalid_diagonal}).diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
}
TEST_CASE("Model invalid callback outputs never overwrite caller storage", "[model][runtime][unit]") {
const auto transpose = [](const auto& input, auto& output) {
output = input;
};
const auto wrong_size = serif::operators::backend::mfem::linearized_action(1, 1, [](const auto&, auto& output) {
output.SetSize(0);
}, transpose);
const auto nonfinite = serif::operators::backend::mfem::linearized_action(1, 1, [](const auto&, auto& output) {
output[0] = std::numeric_limits<double>::infinity();
}, transpose);
::mfem::Vector input(1), output(1);
input = 1.0;
output = 42.0;
CHECK_THROWS(wrong_size.action()->Mult(input, output));
CHECK(output[0] == 42.0);
CHECK_THROWS(nonfinite.action()->Mult(input, output));
CHECK(output[0] == 42.0);
input = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS(nonfinite.action()->MultTranspose(input, output));
CHECK(output[0] == 42.0);
}
TEST_CASE("Complete model actions traverse each equation once while selected blocks keep their native representation", "[model][runtime][unit]") {
auto calls = std::make_shared<std::array<int, 4>>();
const auto counted = [calls](backend::LinearizedBlock block, const int index) {
const auto action = serif::operators::backend::mfem::linearized_action(block.action()->Height(), block.action()->Width(),
[calls, block, index](const auto& input, auto& output) {
++(*calls)[index];
block.action()->Mult(input, output);
},
[calls, block, index](const auto& input, auto& output) {
++(*calls)[index];
block.action()->MultTranspose(input, output);
});
return backend::LinearizedBlock(action.action(), {}, [block]() {
return block.assemble();
});
};
const auto left = counted(matrix(3, 2, {1, 2, 3, 4, 5, 6}), 0);
const auto right = counted(matrix(3, 2, {7, 8, 9, 10, 11, 12}), 1);
const auto complete_matrix = matrix(3, 4, {1, 2, 7, 8, 3, 4, 9, 10, 5, 6, 11, 12});
const auto complete = serif::operators::backend::mfem::linearized_action(3, 4, [calls, complete_matrix](const auto& input, auto& output) {
++(*calls)[2];
complete_matrix.action()->Mult(input, output);
}, [calls, complete_matrix](const auto& input, auto& output) {
++(*calls)[3];
complete_matrix.action()->MultTranspose(input, output);
});
const auto accepted = backend::make_physical_system(3, 4, {}, {{0, 0, left}, {0, 2, right}},
{{{1}, {0.0}, {{0, matrix(1, 4, {2, 3, 4, 5})}}}}, {},
std::vector<backend::JacobianContribution>{{0, 0, complete}});
mfem::Vector direction(4), dual(3), action, transpose;
for (int column = 0; column < 4; ++column) {
direction[column] = column + 1;
}
dual[0] = 0.2;
dual[1] = -0.7;
dual[2] = 0.4;
accepted->jacobian().action()->Mult(direction, action);
accepted->jacobian().action()->MultTranspose(dual, transpose);
check_vector(action, {58, 40, 98});
CHECK_THAT(action * dual, WithinAbs(direction * transpose, 1.0e-12));
CHECK(*calls == std::array<int, 4>{0, 0, 1, 1});
REQUIRE(accepted->jacobian().has_assembly());
const auto assembled = accepted->jacobian().assemble();
mfem::Vector assembled_action(3), assembled_transpose(4);
assembled->Mult(direction, assembled_action);
assembled->MultTranspose(dual, assembled_transpose);
assembled_action -= action;
assembled_transpose -= transpose;
CHECK(assembled_action.Norml2() == 0.0);
CHECK(assembled_transpose.Norml2() < 1.0e-13);
CHECK(*calls == std::array<int, 4>{0, 0, 1, 1});
const auto selected = accepted->block(0, 3, 0, 2);
mfem::Vector local(2), selected_action;
local[0] = 1.0;
local[1] = 2.0;
selected.action()->Mult(local, selected_action);
check_vector(selected_action, {5, 8, 17});
CHECK(*calls == std::array<int, 4>{1, 0, 1, 1});
CHECK(accepted->block(0, 2, 0, 2).diagonal() == std::vector<double>{1, 3});
CHECK(*calls == std::array<int, 4>{1, 0, 1, 1});
CHECK_THROWS(backend::make_physical_system(2, 4, {}, {}, {}, {}, std::vector<backend::JacobianContribution>{{0, 0, complete}}));
CHECK_THROWS(backend::make_physical_system(3, 3, {}, {}, {}, {}, std::vector<backend::JacobianContribution>{{0, 0, complete}}));
}

View File

@@ -0,0 +1,116 @@
#include <array>
#include <memory>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/model/deformation.hpp"
#include "serif/backend/mfem/model/system.hpp"
namespace {
namespace deformation = serif::geometry::deformation;
namespace backend = serif::model::backend::mfem;
using Catch::Matchers::WithinAbs;
/** @brief A nonlinear shape with independent mixed curvature and deliberately move-only scratch. */
struct Shape {
using InputSpace = deformation::spaces::ShapeParameters;
using OutputSpace = deformation::spaces::VolumeDisplacement;
using Workspace = std::unique_ptr<int>;
static constexpr bool is_linear = false;
/** @brief Two independent shape coefficients determine three displacement coefficients. */
[[nodiscard]] std::size_t input_size() const {
return 2;
}
/** @brief Keep the physical force dimension different from the shape dimension. */
[[nodiscard]] std::size_t output_size() const {
return 3;
}
/** @brief Ensure model adapters do not impose an undocumented copyable-workspace requirement. */
[[nodiscard]] Workspace make_workspace() const {
return std::make_unique<int>(0);
}
/** @brief Evaluate u(q) = (q0^2 + q1, q0*q1, q1^2). */
[[nodiscard]] deformation::DeformationStatus build_displacement(std::span<const double> q, std::span<double> u, Workspace&) const {
u[0] = q[0] * q[0] + q[1];
u[1] = q[0] * q[1];
u[2] = q[1] * q[1];
return {};
}
/** @brief Apply the analytic displacement derivative. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian(std::span<const double> q, std::span<const double> direction, std::span<double> output, Workspace&) const {
output[0] = 2.0 * q[0] * direction[0] + direction[1];
output[1] = q[1] * direction[0] + q[0] * direction[1];
output[2] = 2.0 * q[1] * direction[1];
return {};
}
/** @brief Pull a physical force back into the two shape coordinates. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(std::span<const double> q, std::span<const double> force, std::span<double> output, Workspace&) const {
output[0] = 2.0 * q[0] * force[0] + q[1] * force[1];
output[1] = force[0] + q[0] * force[1] + 2.0 * q[1] * force[2];
return {};
}
/** @brief Retain both pure and mixed second derivatives in the shape Newton action. */
[[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(std::span<const double>, std::span<const double> direction, std::span<const double> force, std::span<double> output, Workspace&) const {
output[0] = 2.0 * force[0] * direction[0] + force[1] * direction[1];
output[1] = force[1] * direction[0] + 2.0 * force[2] * direction[1];
return {};
}
};
/** @brief Use a nonsymmetric physical force derivative so the full transpose cannot be replaced by the forward action. */
[[nodiscard]] std::shared_ptr<const ::mfem::DenseMatrix> force_derivative() {
auto matrix = std::make_shared<::mfem::DenseMatrix>(3);
const std::array<std::array<double, 3>, 3> rows{{{2, -1, 0.5}, {0.2, 3, -0.7}, {1, 0.3, 4}}};
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
(*matrix)(row, column) = rows[row][column];
}
}
return matrix;
}
/** @brief Assemble E^T force and E^T K E using only the public model adapters. */
[[nodiscard]] std::shared_ptr<const backend::PhysicalSystem> evaluate(const std::array<double, 2>& coordinates) {
const auto map = std::make_shared<const Shape>();
const auto state = std::make_shared<const deformation::DeformationLinearization<Shape>>(deformation::DeformationLinearization<Shape>{map, {coordinates[0], coordinates[1]}});
auto workspace = map->make_workspace();
::mfem::Vector displacement(3), force(3), residual;
REQUIRE(map->build_displacement(coordinates, std::span<double>(displacement.GetData(), 3), workspace));
const auto matrix = force_derivative();
matrix->Mult(displacement, force);
force[0] += 0.7;
force[1] -= 0.2;
force[2] += 0.5;
const auto tangent = backend::deformation_tangent(state);
const auto transpose = serif::operators::backend::mfem::transpose_block(tangent);
transpose.action()->Mult(force, residual);
const auto force_block = serif::operators::backend::mfem::linearized_block(matrix);
const auto material = serif::operators::backend::mfem::compose_blocks(transpose, serif::operators::backend::mfem::compose_blocks(force_block, tangent));
return backend::make_physical_system(2, 2, {{0, std::vector<double>(residual.GetData(), residual.GetData() + 2)}}, {{0, 0, material}});
}
} // namespace
TEST_CASE("Model shape tangent composes with a nonsymmetric force derivative as an exact transpose pair", "[model][runtime][deformation][unit]") {
const std::array<double, 2> coordinates{0.4, -0.3};
const auto accepted = evaluate(coordinates);
::mfem::Vector direction(2), action, dual(2), transpose;
direction[0] = 0.7;
direction[1] = -0.2;
dual[0] = -0.8;
dual[1] = 1.3;
accepted->jacobian().action()->Mult(direction, action);
accepted->jacobian().action()->MultTranspose(dual, transpose);
CHECK_THAT(action * dual, WithinAbs(direction * transpose, 2.0e-13));
::mfem::Vector repeated;
accepted->jacobian().action()->Mult(direction, repeated);
for (int row = 0; row < 2; ++row) {
CHECK(repeated[row] == action[row]);
}
}

View File

@@ -0,0 +1,82 @@
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/backend/mfem/model/discretization.hpp"
#include "serif/backend/mfem/model/inputs.hpp"
#include "serif/backend/mfem/model/system.hpp"
#include "serif/backend/mfem/model/volume.hpp"
#include "serif/tests/equations/fixture.hpp"
namespace {
namespace backend = serif::model::backend::mfem;
namespace equation_tests = serif::tests::equations;
namespace execution = serif::operators::backend::mfem;
using Catch::Matchers::WithinAbs;
} // namespace
TEST_CASE("Model and standalone volume inputs reject missing compactification spaces before access", "[model][runtime][equations][unit]") {
namespace dependencies = serif::utils::dependencies;
namespace blocks = serif::discretization::blocks;
using Registry = serif::equations::EquationRegistry<equation_tests::Closure>;
auto fixture = std::make_shared<equation_tests::Fixture>();
const auto density = fixture->density(), enthalpy = fixture->enthalpy();
const auto coefficients = fixture->state(density, enthalpy);
const ::mfem::GridFunction unbound_compactification;
const auto bindings = dependencies::make_dependency_context(
dependencies::borrow<blocks::density::mass>(fixture->density_binding),
dependencies::borrow<::serif::tests::equations::Enthalpy>(fixture->enthalpy_binding),
dependencies::borrow<blocks::displacement::vector>(fixture->displacement_binding));
const auto discretization = backend::make_stellar_discretization(fixture, bindings, unbound_compactification, fixture->mapper);
CHECK_THROWS_AS(backend::make_model_inputs<Registry>(discretization, coefficients), std::logic_error);
CHECK_THROWS_AS(execution::make_volume_inputs<equation_tests::Closure>(discretization.equation_discretization<blocks::density::mass>(), coefficients), std::logic_error);
}
TEST_CASE("Model volume geometry adapters retain the accepted equation and complete coordinate transpose", "[model][runtime][equations][integration]") {
auto fixture = std::make_shared<equation_tests::Fixture>();
std::weak_ptr<equation_tests::Fixture> lifetime = fixture;
const auto density = fixture->density();
auto enthalpy = fixture->enthalpy();
const auto displacement = fixture->displacement(0.35);
const auto direction = fixture->displacement(0.23);
const auto dual = equation_tests::deterministic(density.Size(), 0.47);
std::shared_ptr<const backend::PhysicalSystem> system;
::mfem::Vector expected_action, expected_transpose; {
equation_tests::PreparedClosure prepared(equation_tests::Closure{serif::eos::models::Polytrope{3.0, 1.5}}, fixture->density_binding, fixture->displacement_binding, fixture->compactification, fixture->mapper);
prepared.prepare(fixture->state(density, enthalpy), displacement, fixture->workspace);
const auto accepted = prepared.cache().share<execution::volume_keys::Linearization>();
const auto geometry = backend::volume_geometry_block(accepted);
system = backend::make_physical_system(density.Size(), displacement.Size(), {{0, backend::volume_residual_values(accepted)}}, {{0, 0, geometry}}, {}, fixture);
geometry.action()->Mult(direction, expected_action);
geometry.action()->MultTranspose(dual, expected_transpose);
CHECK_THAT(dual * expected_action, WithinAbs(direction * expected_transpose, 2.0e-12));
REQUIRE(expected_action.Norml2() > 0.0);
constexpr double step = 1.0e-6;
::mfem::Vector perturbed(displacement);
perturbed.Add(step, direction);
prepared.prepare(fixture->state(density, enthalpy), perturbed, fixture->workspace);
::mfem::Vector difference(prepared.data().residual);
perturbed = displacement;
perturbed.Add(-step, direction);
prepared.prepare(fixture->state(density, enthalpy), perturbed, fixture->workspace);
difference -= prepared.data().residual;
difference *= 0.5 / step;
CHECK(equation_tests::relative_error(expected_action, difference) < 2.0e-8);
enthalpy = -1.0;
CHECK_FALSE(prepared.try_prepare(fixture->state(density, enthalpy), displacement, fixture->workspace));
::mfem::Vector repeated;
geometry.action()->Mult(direction, repeated);
CHECK(equation_tests::relative_error(repeated, expected_action) == 0.0);
}
fixture.reset();
CHECK_FALSE(lifetime.expired());
::mfem::Vector action, transpose;
system->jacobian().action()->Mult(direction, action);
system->jacobian().action()->MultTranspose(dual, transpose);
CHECK(equation_tests::relative_error(action, expected_action) == 0.0);
CHECK(equation_tests::relative_error(transpose, expected_transpose) == 0.0);
system.reset();
CHECK(lifetime.expired());
}

Some files were not shown because too many files have changed in this diff Show More