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:
285
tests/model/automatic_surface.cpp
Normal file
285
tests/model/automatic_surface.cpp
Normal 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
272
tests/model/description.cpp
Normal 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);
|
||||
}
|
||||
463
tests/model/equilibrium_graph.cpp
Normal file
463
tests/model/equilibrium_graph.cpp
Normal 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>();
|
||||
}
|
||||
148
tests/model/finite_elements.cpp
Normal file
148
tests/model/finite_elements.cpp
Normal 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() == ¤t.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
30
tests/model/meson.build
Normal 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
175
tests/model/move_only.cpp
Normal 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());
|
||||
}
|
||||
809
tests/model/moving_equilibrium.cpp
Normal file
809
tests/model/moving_equilibrium.cpp
Normal 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
|
||||
}
|
||||
115
tests/model/normalized_system.cpp
Normal file
115
tests/model/normalized_system.cpp
Normal 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()));
|
||||
}
|
||||
144
tests/model/pressure_graph.cpp
Normal file
144
tests/model/pressure_graph.cpp
Normal 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));
|
||||
}
|
||||
119
tests/model/reconstruction.cpp
Normal file
119
tests/model/reconstruction.cpp
Normal 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"));
|
||||
}
|
||||
206
tests/model/reconstruction_system.cpp
Normal file
206
tests/model/reconstruction_system.cpp
Normal 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
385
tests/model/runtime.cpp
Normal 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}}));
|
||||
}
|
||||
116
tests/model/runtime_deformation.cpp
Normal file
116
tests/model/runtime_deformation.cpp
Normal 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]);
|
||||
}
|
||||
}
|
||||
82
tests/model/runtime_volume.cpp
Normal file
82
tests/model/runtime_volume.cpp
Normal 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());
|
||||
}
|
||||
89
tests/model/scalar_row.cpp
Normal file
89
tests/model/scalar_row.cpp
Normal file
@@ -0,0 +1,89 @@
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "mfem/linalg/densemat.hpp"
|
||||
#include "serif/backend/mfem/model/scalar_row.hpp"
|
||||
#include "serif/backend/mfem/model/system.hpp"
|
||||
|
||||
TEST_CASE("A scalar residual row retains its exact functional without retaining or repeating the source action", "[model][scalar_row][unit]") {
|
||||
namespace backend = serif::model::backend::mfem;
|
||||
namespace execution = serif::operators::backend::mfem;
|
||||
int forward_calls = 0, transpose_calls = 0;
|
||||
std::optional<execution::LinearizedBlock> cached;
|
||||
std::weak_ptr<int> lifetime;
|
||||
{
|
||||
auto owner = std::make_shared<int>(1);
|
||||
lifetime = owner;
|
||||
const auto source = execution::linearized_action(1, 3, [&, owner](const auto& input, auto& output) {
|
||||
++forward_calls;
|
||||
output[0] = 2.0 * input[0] - 3.0 * input[1] + 5.0 * input[2];
|
||||
}, [&, owner](const auto& input, auto& output) {
|
||||
++transpose_calls;
|
||||
output[0] = 2.0 * input[0];
|
||||
output[1] = -3.0 * input[0];
|
||||
output[2] = 5.0 * input[0];
|
||||
});
|
||||
auto result = backend::cache_scalar_row(source);
|
||||
REQUIRE(result);
|
||||
cached.emplace(std::move(*result));
|
||||
}
|
||||
CHECK(lifetime.expired());
|
||||
CHECK(forward_calls == 0);
|
||||
CHECK(transpose_calls == 1);
|
||||
const auto system = backend::make_physical_system(1, 3, {}, {{0, 0, *cached}});
|
||||
mfem::Vector direction(3), action, transpose, dual(1);
|
||||
direction[0] = 7.0;
|
||||
direction[1] = 11.0;
|
||||
direction[2] = 13.0;
|
||||
dual[0] = 0.25;
|
||||
system->jacobian().action()->Mult(direction, action);
|
||||
CHECK(action[0] == Catch::Approx(46.0));
|
||||
system->jacobian().action()->MultTranspose(dual, transpose);
|
||||
CHECK(transpose[0] == Catch::Approx(0.5));
|
||||
CHECK(transpose[1] == Catch::Approx(-0.75));
|
||||
CHECK(transpose[2] == Catch::Approx(1.25));
|
||||
const auto selected = system->block(0, 1, 1, 2);
|
||||
mfem::Vector selected_direction(direction.GetData() + 1, 2);
|
||||
selected.action()->Mult(selected_direction, action);
|
||||
CHECK(action[0] == Catch::Approx(32.0));
|
||||
const auto matrix = selected.assemble();
|
||||
CHECK((*matrix)(0, 0) == Catch::Approx(-3.0));
|
||||
CHECK((*matrix)(0, 1) == Catch::Approx(5.0));
|
||||
CHECK(forward_calls == 0);
|
||||
CHECK(transpose_calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Scalar row caching rejects a spatial residual without invoking its transpose", "[model][scalar_row][unit]") {
|
||||
namespace execution = serif::operators::backend::mfem;
|
||||
int calls = 0;
|
||||
const auto source = execution::linearized_action(2, 3, [](const auto&, auto&) {}, [&](const auto&, auto&) {
|
||||
++calls;
|
||||
});
|
||||
CHECK_THROWS(serif::model::backend::mfem::cache_scalar_row(source));
|
||||
CHECK(calls == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Scalar row caching preserves numerical failure classification for nonfinite derivatives", "[model][scalar_row][unit]") {
|
||||
namespace execution = serif::operators::backend::mfem;
|
||||
for (const double value : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
|
||||
auto matrix = std::make_shared<mfem::DenseMatrix>(1, 1);
|
||||
(*matrix)(0, 0) = value;
|
||||
// A raw matrix deliberately bypasses the checked callback adapter to exercise the cache guard.
|
||||
const auto source = execution::linearized_block(std::shared_ptr<const mfem::DenseMatrix>(matrix));
|
||||
const auto raw_result = serif::model::backend::mfem::cache_scalar_row(source);
|
||||
REQUIRE_FALSE(raw_result);
|
||||
CHECK(raw_result.error().reason == execution::PreparationRejectionReason::NON_FINITE);
|
||||
const auto checked = execution::linearized_action(1, 1, [source](const auto& input, auto& output) {
|
||||
source.action()->Mult(input, output);
|
||||
}, [source](const auto& input, auto& output) {
|
||||
source.action()->MultTranspose(input, output);
|
||||
});
|
||||
const auto checked_result = serif::model::backend::mfem::cache_scalar_row(checked);
|
||||
REQUIRE_FALSE(checked_result);
|
||||
CHECK(checked_result.error().reason == execution::PreparationRejectionReason::NON_FINITE);
|
||||
}
|
||||
}
|
||||
404
tests/model/solver_services.cpp
Normal file
404
tests/model/solver_services.cpp
Normal file
@@ -0,0 +1,404 @@
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <type_traits>
|
||||
#include <variant>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
|
||||
#include "mfem/linalg/densemat.hpp"
|
||||
#include "mfem/general/communication.hpp"
|
||||
#include "serif/tests/equations/test_equations.hpp"
|
||||
#include "serif/backend/mfem/model/equilibrium.hpp"
|
||||
#include "serif/backend/mfem/solver/seed/state.hpp"
|
||||
#include "serif/solver/seed/polytrope.hpp"
|
||||
#include "serif/tests/solver/stellar_fixture.hpp"
|
||||
#include "serif/utils/dependencies/computation_cache.hpp"
|
||||
#include "serif/utils/dependencies/timing.hpp"
|
||||
#include "serif/backend/mfem/operators/actions.hpp"
|
||||
#include "serif/backend/mfem/model/preconditioned_system.hpp"
|
||||
#include "serif/backend/mfem/model/computations/physical_blocks.hpp"
|
||||
#include "serif/backend/mfem/model/computations/solver_layout.hpp"
|
||||
#include "serif/solver/normalization/normalization.hpp"
|
||||
#include "serif/solver/preconditioner/preconditioner.hpp"
|
||||
|
||||
namespace {
|
||||
namespace model = serif::model;
|
||||
namespace backend = model::backend::mfem;
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
namespace blocks = serif::discretization::blocks;
|
||||
namespace normalization = serif::solver::normalization;
|
||||
namespace preconditioner = serif::solver::preconditioner;
|
||||
using Density = blocks::density::mass;
|
||||
using Enthalpy = ::serif::tests::equations::Enthalpy;
|
||||
using Layout = blocks::SystemLayout<dependencies::KeyList<Enthalpy, Density>, dependencies::KeyList<Density, Enthalpy>>;
|
||||
using DensityBlock = serif::operators::PhysicalBlock<dependencies::KeyList<Density>, dependencies::KeyList<Density>>;
|
||||
using ReorderedBlock = serif::operators::PhysicalBlock<dependencies::KeyList<Enthalpy, Density>, dependencies::KeyList<Density, Enthalpy>>;
|
||||
using Rules = dependencies::Bindings<dependencies::Binding<Density, normalization::IdentityCoordinate>, dependencies::Binding<Enthalpy, normalization::IdentityCoordinate>>;
|
||||
using NormalizationPlan = normalization::NormalizationPlan<Rules, Rules>;
|
||||
struct DensityResponse {
|
||||
};
|
||||
struct EvaluationService {};
|
||||
struct LinearService {};
|
||||
|
||||
/** @brief Supply a nonsymmetric physical Jacobian with an independently known two-coordinate inverse. */
|
||||
[[nodiscard]] std::shared_ptr<const backend::PhysicalSystem> small_physical_system() {
|
||||
auto matrix = std::make_shared<::mfem::DenseMatrix>(2);
|
||||
(*matrix)(0, 0) = 4.0;
|
||||
(*matrix)(0, 1) = 1.0;
|
||||
(*matrix)(1, 0) = 2.0;
|
||||
(*matrix)(1, 1) = 3.0;
|
||||
const auto block = serif::operators::backend::mfem::linearized_block(std::shared_ptr<const ::mfem::DenseMatrix>(matrix));
|
||||
return backend::make_physical_system(2, 2, {{.row_offset = 0, .values = {9.0, 7.0}}},
|
||||
{{.row_offset = 0, .column_offset = 0, .block = block}});
|
||||
}
|
||||
|
||||
/** @brief Keep inverse ownership independent from the physical system so both lifetimes are tested. */
|
||||
[[nodiscard]] std::shared_ptr<const ::mfem::Operator> small_physical_inverse() {
|
||||
auto matrix = std::make_shared<::mfem::DenseMatrix>(2);
|
||||
(*matrix)(0, 0) = 0.3;
|
||||
(*matrix)(0, 1) = -0.1;
|
||||
(*matrix)(1, 0) = -0.2;
|
||||
(*matrix)(1, 1) = 0.4;
|
||||
return matrix;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Prepared stellar linear systems retain matching physical and normalized inverse owners", "[unit][model][preconditioner][normalization][solver_services]") {
|
||||
const auto check_coordinates = []<bool Normalize>() {
|
||||
using System = std::conditional_t<Normalize, backend::NormalizedSystem, backend::PhysicalSystem>;
|
||||
using Prepared = backend::PreconditionedSystem<System>;
|
||||
std::weak_ptr<const backend::PhysicalSystem> physical_owner;
|
||||
std::weak_ptr<const ::mfem::Operator> inverse_owner;
|
||||
std::shared_ptr<const Prepared> frozen;
|
||||
{
|
||||
const auto physical = small_physical_system();
|
||||
const auto inverse = small_physical_inverse();
|
||||
physical_owner = physical;
|
||||
inverse_owner = inverse;
|
||||
const auto system = [&]() {
|
||||
if constexpr (Normalize) {
|
||||
const auto coordinates = normalization::make_diagonal_normalization({2.0, 5.0}, {3.0, 7.0});
|
||||
const auto scales = normalization::prepare_stellar_reference_scales(
|
||||
serif::dimensions::MassValue{2.0}, serif::dimensions::LengthValue{3.0});
|
||||
REQUIRE(coordinates);
|
||||
REQUIRE(scales);
|
||||
const auto normalized = backend::make_normalized_system(physical, *coordinates, *scales);
|
||||
REQUIRE(normalized);
|
||||
return *normalized;
|
||||
} else {
|
||||
return physical;
|
||||
}
|
||||
}();
|
||||
frozen = std::make_shared<const Prepared>(system, preconditioner::backend::mfem::Inverse{.action = inverse});
|
||||
}
|
||||
REQUIRE_FALSE(physical_owner.expired());
|
||||
REQUIRE_FALSE(inverse_owner.expired());
|
||||
CHECK(frozen->contract() == preconditioner::ApplicationContract::fixed_linear);
|
||||
const ::mfem::Operator& jacobian = [&]() -> const ::mfem::Operator& {
|
||||
if constexpr (Normalize) {
|
||||
return frozen->system()->jacobian();
|
||||
} else {
|
||||
return *frozen->system()->jacobian().action();
|
||||
}
|
||||
}();
|
||||
CHECK_NOTHROW(frozen->preconditioner().SetOperator(jacobian));
|
||||
::mfem::Vector right_hand_side(2), correction(2), reconstructed(2);
|
||||
right_hand_side[0] = Normalize ? 27.0 : 9.0;
|
||||
right_hand_side[1] = Normalize ? 49.0 : 7.0;
|
||||
correction = 123.0;
|
||||
frozen->preconditioner().Mult(right_hand_side, correction);
|
||||
CHECK(correction[0] == Catch::Approx(Normalize ? 4.0 : 2.0));
|
||||
CHECK(correction[1] == Catch::Approx(Normalize ? 5.0 : 1.0));
|
||||
jacobian.Mult(correction, reconstructed);
|
||||
reconstructed -= right_hand_side;
|
||||
CHECK(reconstructed.Norml2() < 1.0e-13);
|
||||
|
||||
// Equal dimensions and identical entries do not establish a matching preparation.
|
||||
const auto unrelated = small_physical_system();
|
||||
CHECK_THROWS(frozen->preconditioner().SetOperator(*unrelated->jacobian().action()));
|
||||
if constexpr (Normalize) {
|
||||
const auto replacement = backend::make_normalized_system(unrelated, frozen->system()->coordinates(), frozen->system()->reference_scales());
|
||||
REQUIRE(replacement);
|
||||
CHECK_THROWS(frozen->preconditioner().SetOperator((*replacement)->jacobian()));
|
||||
}
|
||||
CHECK_NOTHROW(frozen->preconditioner().SetOperator(jacobian));
|
||||
correction = -91.0;
|
||||
frozen->preconditioner().Mult(right_hand_side, correction);
|
||||
CHECK(correction[0] == Catch::Approx(Normalize ? 4.0 : 2.0));
|
||||
CHECK(correction[1] == Catch::Approx(Normalize ? 5.0 : 1.0));
|
||||
|
||||
right_hand_side[0] = 9.0;
|
||||
right_hand_side[1] = 7.0;
|
||||
frozen->physical_preconditioner().Mult(right_hand_side, correction);
|
||||
CHECK(correction[0] == Catch::Approx(2.0));
|
||||
CHECK(correction[1] == Catch::Approx(1.0));
|
||||
frozen.reset();
|
||||
CHECK(physical_owner.expired());
|
||||
CHECK(inverse_owner.expired());
|
||||
};
|
||||
SECTION("Physical coordinates") {
|
||||
check_coordinates.template operator()<false>();
|
||||
}
|
||||
SECTION("Nonunit normalized coordinates") {
|
||||
check_coordinates.template operator()<true>();
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Prepared stellar systems preserve application contracts and reject incompatible owners", "[unit][model][preconditioner][solver_services]") {
|
||||
using Prepared = backend::PreconditionedSystem<backend::PhysicalSystem>;
|
||||
const preconditioner::backend::mfem::Inverse inverse{.action = small_physical_inverse(), .contract = preconditioner::ApplicationContract::variable};
|
||||
const auto physical = small_physical_system();
|
||||
const Prepared prepared(physical, inverse);
|
||||
CHECK(prepared.contract() == preconditioner::ApplicationContract::variable);
|
||||
CHECK_THROWS_WITH(Prepared({}, inverse), Catch::Matchers::ContainsSubstring("evaluated star"));
|
||||
CHECK_THROWS_WITH(Prepared(physical, {}), Catch::Matchers::ContainsSubstring("PRECONDITIONER_INVALID_OPERATOR"));
|
||||
const preconditioner::backend::mfem::Inverse wrong_size{.action = std::make_shared<const ::mfem::DenseMatrix>(1)};
|
||||
CHECK_THROWS_WITH(Prepared(physical, wrong_size), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH"));
|
||||
}
|
||||
|
||||
TEST_CASE("Model normalization and inverse computations share one physical evaluation", "[model][integration][preconditioner][normalization]") {
|
||||
using namespace dependencies;
|
||||
|
||||
const Layout layout{.state_sizes = {2, 1}, .residual_sizes = {1, 2}};
|
||||
double response = 4.0;
|
||||
int physical_preparations = 0;
|
||||
int physical_actions = 0;
|
||||
const auto inputs = make_dependency_context(borrow<DensityResponse>(response));
|
||||
const auto physical = make_computation<model::keys::PhysicalSystem, InputList<DensityResponse>>(
|
||||
[&](double density_response) -> std::expected<std::shared_ptr<const model::backend::mfem::PhysicalSystem>, std::string> {
|
||||
++physical_preparations;
|
||||
if (density_response < 0.0) {
|
||||
return std::unexpected("Synthetic physical rejection for transaction testing.");
|
||||
}
|
||||
auto matrix = std::make_shared<::mfem::DenseMatrix>(3);
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
for (int column = 0; column < 3; ++column) {
|
||||
(*matrix)(row, column) = 2.0 + row * 3.0 + column;
|
||||
}
|
||||
}
|
||||
(*matrix)(0, 2) = density_response;
|
||||
const auto original = serif::operators::backend::mfem::linearized_block(std::shared_ptr<const ::mfem::DenseMatrix>(matrix));
|
||||
const auto action = serif::operators::backend::mfem::linearized_action(3, 3, [original, &physical_actions](const auto& input, auto& output) {
|
||||
++physical_actions;
|
||||
original.action()->Mult(input, output);
|
||||
}, [original, &physical_actions](const auto& input, auto& output) {
|
||||
++physical_actions;
|
||||
original.action()->MultTranspose(input, output);
|
||||
});
|
||||
const serif::operators::backend::mfem::LinearizedBlock block(action.action(), [original]() {
|
||||
return original.diagonal();
|
||||
}, [original]() {
|
||||
return original.assemble();
|
||||
});
|
||||
return model::backend::mfem::make_physical_system(3, 3, {}, {{0, 0, block}});
|
||||
});
|
||||
const auto inverse = preconditioner::make_preconditioner<Density>(preconditioner::strategies::inverse::Diagonal{});
|
||||
using InverseKey = decltype(inverse)::Result;
|
||||
auto graph = std::apply([](auto... nodes) {
|
||||
return make_computation_graph(std::move(nodes)...);
|
||||
}, std::tuple_cat(std::tuple{physical}, model::computations::make_solver_layout_computations(layout),
|
||||
model::computations::make_physical_block_computations(layout, KeyList<DensityBlock, ReorderedBlock>{}),
|
||||
normalization::make_normalization_computations<NormalizationPlan>(layout),
|
||||
preconditioner::make_preconditioner_computations(inverse)));
|
||||
auto cache = make_computation_cache<std::variant<std::string, preconditioner::Failure>>(std::move(graph), inputs);
|
||||
using Requested = KeyList<model::keys::PhysicalSystem, InverseKey, ReorderedBlock, normalization::keys::Normalization>;
|
||||
REQUIRE(cache.try_prepare(inputs, Requested{}));
|
||||
CHECK(physical_preparations == 1);
|
||||
const auto frozen = cache.get<model::keys::PhysicalSystem>();
|
||||
const auto frozen_inverse = cache.template share<InverseKey>();
|
||||
const auto& factors = cache.template get<normalization::keys::Normalization>();
|
||||
REQUIRE(factors.state_factors().size() == 3);
|
||||
REQUIRE(factors.residual_factors().size() == 3);
|
||||
for (double factor : factors.state_factors()) {
|
||||
CHECK(factor == 1.0);
|
||||
}
|
||||
::mfem::Vector source(1), result(1);
|
||||
source = 8.0;
|
||||
frozen_inverse->action->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(2.0));
|
||||
|
||||
const auto reordered = cache.template get<ReorderedBlock>();
|
||||
::mfem::Vector direction(3), dual(3), reordered_result, transpose;
|
||||
direction[0] = 2.0;
|
||||
direction[1] = 3.0;
|
||||
direction[2] = 5.0;
|
||||
dual[0] = -0.4;
|
||||
dual[1] = 0.7;
|
||||
dual[2] = 1.2;
|
||||
reordered.action()->Mult(direction, reordered_result);
|
||||
CHECK(reordered_result[0] == Catch::Approx(7.0 * 2.0 + 5.0 * 3.0 + 6.0 * 5.0));
|
||||
CHECK(reordered_result[1] == Catch::Approx(10.0 * 2.0 + 8.0 * 3.0 + 9.0 * 5.0));
|
||||
CHECK(reordered_result[2] == Catch::Approx(4.0 * 2.0 + 2.0 * 3.0 + 3.0 * 5.0));
|
||||
reordered.action()->MultTranspose(dual, transpose);
|
||||
CHECK(dual * reordered_result == Catch::Approx(direction * transpose));
|
||||
CHECK(physical_actions == 2);
|
||||
CHECK(reordered.diagonal() == std::vector<double>{7, 8, 3});
|
||||
const auto reordered_matrix = reordered.assemble();
|
||||
::mfem::Vector assembled_result(3);
|
||||
reordered_matrix->Mult(direction, assembled_result);
|
||||
assembled_result -= reordered_result;
|
||||
CHECK(assembled_result.Norml2() == 0.0);
|
||||
CHECK(physical_actions == 2);
|
||||
|
||||
REQUIRE(cache.try_prepare(inputs, Requested{}));
|
||||
CHECK(physical_preparations == 1);
|
||||
response = 8.0;
|
||||
REQUIRE(cache.try_prepare(inputs, Requested{}));
|
||||
CHECK(physical_preparations == 2);
|
||||
cache.template get<InverseKey>().action->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(1.0));
|
||||
frozen_inverse->action->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(2.0));
|
||||
const auto last = cache.get<model::keys::PhysicalSystem>();
|
||||
|
||||
// A completed physical candidate must not be published if a requested inverse rejects it.
|
||||
response = 0.0;
|
||||
const auto preparations = cache.preparation_count();
|
||||
const auto singular = cache.try_prepare(inputs, Requested{});
|
||||
REQUIRE_FALSE(singular);
|
||||
CHECK(std::holds_alternative<preconditioner::Failure>(singular.error().error));
|
||||
last->block(0, 1, 2, 1).action()->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(64.0));
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_FALSE(cache.template contains<model::keys::PhysicalSystem>());
|
||||
CHECK_THROWS_AS(cache.template get<InverseKey>(), std::logic_error);
|
||||
CHECK(cache.report().computed_count() == 0);
|
||||
CHECK(cache.report().reused_count() == 0);
|
||||
CHECK(cache.preparation_count() == preparations);
|
||||
response = 8.0;
|
||||
REQUIRE(cache.try_prepare(inputs, Requested{}));
|
||||
CHECK(cache.get<model::keys::PhysicalSystem>() == last);
|
||||
CHECK(cache.report().computed_count() == 0);
|
||||
|
||||
response = -1.0;
|
||||
CHECK_FALSE(cache.try_prepare(inputs, Requested{}));
|
||||
CHECK_FALSE(cache.contains<model::keys::PhysicalSystem>());
|
||||
last->block(0, 1, 2, 1).action()->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(64.0));
|
||||
frozen->block(0, 1, 2, 1).action()->Mult(source, result);
|
||||
CHECK(result[0] == Catch::Approx(32.0));
|
||||
}
|
||||
|
||||
TEST_CASE("Physical solver blocks reject unrepresentable or unrelated coordinate layouts", "[unit][model][solver_services]") {
|
||||
using namespace dependencies;
|
||||
|
||||
const std::size_t too_large = static_cast<std::size_t>(std::numeric_limits<int>::max()) + 1;
|
||||
CHECK_THROWS_WITH(model::computations::physical_block<DensityBlock>(Layout{.state_sizes = {too_large, 1}, .residual_sizes = {1, 2}}),
|
||||
Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
|
||||
CHECK_THROWS_WITH(model::computations::physical_block<DensityBlock>(Layout{.state_sizes = {2, 1}, .residual_sizes = {too_large, 2}}),
|
||||
Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
|
||||
|
||||
const Layout layout{.state_sizes = {2, 1}, .residual_sizes = {1, 2}};
|
||||
std::shared_ptr<const model::backend::mfem::PhysicalSystem> system;
|
||||
const auto inputs = make_dependency_context(borrow<model::keys::PhysicalSystem>(system));
|
||||
auto cache = make_computation_cache(inputs, model::computations::physical_block<DensityBlock>(layout));
|
||||
CHECK_THROWS_WITH(cache.try_prepare(inputs), Catch::Matchers::ContainsSubstring("INVALID_ARGUMENT"));
|
||||
for (const auto dimensions : {std::pair{2, 3}, std::pair{3, 2}}) {
|
||||
system = model::backend::mfem::make_physical_system(dimensions.first, dimensions.second, {}, {});
|
||||
CHECK_THROWS_WITH(cache.try_prepare(inputs), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"));
|
||||
}
|
||||
system = model::backend::mfem::make_physical_system(3, 3, {}, {});
|
||||
REQUIRE(cache.try_prepare(inputs));
|
||||
CHECK(cache.get<DensityBlock>().action()->Height() == 1);
|
||||
CHECK(cache.get<DensityBlock>().action()->Width() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Configured model services share cache transactions and defer linear-only work", "[model][integration][solver_services]") {
|
||||
using namespace dependencies;
|
||||
namespace fixture = serif::tests::solver;
|
||||
using Constant = blocks::barotropic_constant::mass_normalization;
|
||||
if (!::mfem::Mpi::IsInitialized()) {
|
||||
::mfem::Mpi::Init();
|
||||
}
|
||||
const auto physics = fixture::make_model();
|
||||
const auto seed = serif::solver::seed::make_polytropic_seed(physics);
|
||||
REQUIRE(seed);
|
||||
const auto resources = fixture::make_resources(seed->scales.radius.value());
|
||||
const auto finite_elements = fixture::make_discretization(resources, 0);
|
||||
const auto state = serif::solver::seed::backend::mfem::make_polytropic_state(physics, finite_elements, *seed);
|
||||
REQUIRE(state);
|
||||
REQUIRE(state->template get<Constant>()[0] < 0.5);
|
||||
const auto configuration = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{
|
||||
.model = physics, .finite_elements = finite_elements,
|
||||
.normalization = normalization::NormalizationOptions{.scheme = *normalization::prepare_stellar_reference_scales(seed->scales.mass, seed->scales.radius)},
|
||||
.preconditioner = preconditioner::make_primal_preconditioner(state->layout())});
|
||||
int evaluations = 0;
|
||||
int linear_preparations = 0;
|
||||
const auto services = [&](const auto& bound, const auto& layout, auto, const auto& existing) {
|
||||
REQUIRE(bound->model);
|
||||
CHECK(layout == state->layout());
|
||||
using Existing = decltype(backend::detail::computation_keys(existing));
|
||||
static_assert(ContainsKey<model::preconditioning::PreparedSystem, Existing>);
|
||||
// The surface potential level is a tracked state input, so changing it invalidates both extensions.
|
||||
const auto evaluation = make_computation<EvaluationService, InputList<Constant>>([&](const auto& constant) -> std::expected<double, std::string> {
|
||||
++evaluations;
|
||||
if (constant.values[0] > 10.0) {
|
||||
return std::unexpected("Synthetic evaluation-service rejection.");
|
||||
}
|
||||
return constant.values[0];
|
||||
});
|
||||
const auto linear = make_computation<LinearService, InputList<EvaluationService, model::preconditioning::PreparedSystem>>(
|
||||
[&](double constant, const auto& prepared) -> std::expected<std::size_t, std::string> {
|
||||
++linear_preparations;
|
||||
if (constant > 0.5) {
|
||||
return std::unexpected("Synthetic linear-service rejection.");
|
||||
}
|
||||
return prepared->system()->residual().size();
|
||||
});
|
||||
return std::pair{std::tuple{evaluation}, std::tuple{linear}};
|
||||
};
|
||||
GraphTiming timing;
|
||||
auto evaluator = backend::bind_stellar_model(configuration, *state, {}, make_dependency_context(), services, time_graph_nodes(timing));
|
||||
REQUIRE(timing.runs().size() == 1);
|
||||
const auto recorded = [&]<typename Key>() -> const NodeTiming& {
|
||||
const auto& nodes = timing.runs().front()->nodes;
|
||||
const auto node = std::ranges::find(nodes, std::string{nameof::nameof_type<Key>()}, &NodeTiming::key_name);
|
||||
REQUIRE(node != nodes.end());
|
||||
return *node;
|
||||
};
|
||||
const auto evaluated = evaluator.evaluate(*state);
|
||||
REQUIRE(evaluated);
|
||||
CHECK(evaluations == 1);
|
||||
CHECK(linear_preparations == 0);
|
||||
CHECK(recorded.template operator()<EvaluationService>().completed == 1);
|
||||
CHECK(recorded.template operator()<LinearService>().calls == 0);
|
||||
CHECK(evaluator.cache().report().template status<LinearService>() == PreparationStatus::not_required);
|
||||
REQUIRE(evaluator.evaluate(*state));
|
||||
CHECK(evaluations == 1);
|
||||
const auto prepared = evaluator.prepare_linear_system(*state);
|
||||
REQUIRE(prepared);
|
||||
CHECK(evaluations == 1);
|
||||
CHECK(linear_preparations == 1);
|
||||
CHECK((*prepared)->system() == *evaluated);
|
||||
CHECK(evaluator.cache().template get<LinearService>() == (*evaluated)->residual().size());
|
||||
CHECK(recorded.template operator()<EvaluationService>().calls == 1);
|
||||
CHECK(recorded.template operator()<LinearService>().completed == 1);
|
||||
|
||||
auto changed = *state;
|
||||
changed.template get<Constant>()[0] = 1.0;
|
||||
REQUIRE(evaluator.evaluate(changed));
|
||||
CHECK(evaluations == 2);
|
||||
CHECK(linear_preparations == 1);
|
||||
const auto rejected_linear = evaluator.prepare_linear_system(changed);
|
||||
REQUIRE_FALSE(rejected_linear);
|
||||
CHECK(std::get<std::string>(rejected_linear.error().error) == "Synthetic linear-service rejection.");
|
||||
CHECK(recorded.template operator()<LinearService>().rejected == 1);
|
||||
CHECK(recorded.template operator()<LinearService>().calls == 2);
|
||||
CHECK(linear_preparations == 2);
|
||||
CHECK_FALSE(evaluator.cache().ready());
|
||||
CHECK((*prepared)->system() == *evaluated);
|
||||
CHECK_FALSE((*prepared)->system()->residual().empty());
|
||||
|
||||
changed.template get<Constant>()[0] = 11.0;
|
||||
const auto rejected_evaluation = evaluator.evaluate(changed);
|
||||
REQUIRE_FALSE(rejected_evaluation);
|
||||
CHECK(std::get<std::string>(rejected_evaluation.error().error) == "Synthetic evaluation-service rejection.");
|
||||
CHECK(recorded.template operator()<EvaluationService>().rejected == 1);
|
||||
CHECK(linear_preparations == 2);
|
||||
CHECK_FALSE(evaluator.cache().ready());
|
||||
REQUIRE(evaluator.evaluate(*state));
|
||||
CHECK(evaluator.cache().template get<EvaluationService>() == state->template get<Constant>()[0]);
|
||||
}
|
||||
93
tests/model/state.cpp
Normal file
93
tests/model/state.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include <array>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
|
||||
#include "serif/discretization/blocks/fields.hpp"
|
||||
#include "serif/model/state.hpp"
|
||||
|
||||
namespace {
|
||||
namespace blocks = serif::discretization::blocks;
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
using Density = blocks::density::mass;
|
||||
using Shape = blocks::surface_deformation::coefficients;
|
||||
using ShapeRow = blocks::surface_deformation::shape_equilibrium;
|
||||
using Constant = blocks::barotropic_constant::mass_normalization;
|
||||
using Layout = blocks::SystemLayout<dependencies::KeyList<Density, Shape, Constant>, dependencies::KeyList<ShapeRow, Constant, Density>>;
|
||||
using State = serif::model::StellarState<Layout>;
|
||||
}
|
||||
|
||||
TEST_CASE("A stellar state owns its layout and exposes fields in physical rather than residual order", "[model][state][unit]") {
|
||||
Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}};
|
||||
State state(layout, {1.0, 2.0, 3.0, 4.0, 5.0, 6.0});
|
||||
layout.state_sizes[0] = 9;
|
||||
layout.residual_sizes[0] = 8;
|
||||
|
||||
CHECK(state.layout().state_sizes == std::array<std::size_t, 3>{2, 3, 1});
|
||||
CHECK(state.layout().residual_sizes == std::array<std::size_t, 3>{3, 1, 2});
|
||||
CHECK(state.layout().offset<blocks::RepresentedValueKind::residual, Density>() == 4);
|
||||
CHECK(state.get<Density>().data() == state.values().data());
|
||||
CHECK(state.get<Shape>().data() == state.values().data() + 2);
|
||||
CHECK(state.get<Constant>().data() == state.values().data() + 5);
|
||||
REQUIRE(state.get<Shape>().size() == 3);
|
||||
state.get<Shape>()[1] = 7.0;
|
||||
state.get<Constant>()[0] = 8.0;
|
||||
CHECK(state.values()[3] == 7.0);
|
||||
CHECK(state.values()[5] == 8.0);
|
||||
|
||||
const State& read_only = state;
|
||||
CHECK(read_only.get<Shape>()[1] == 7.0);
|
||||
CHECK(read_only.values().data() == state.values().data());
|
||||
}
|
||||
|
||||
TEST_CASE("Copied and moved stellar candidates retain independent coefficients and valid typed access", "[model][state][ownership]") {
|
||||
const Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}};
|
||||
State original(layout, {1.0, 2.0, 3.0, 4.0, 5.0, 6.0});
|
||||
State candidate = original;
|
||||
CHECK(candidate.values().data() != original.values().data());
|
||||
candidate.get<Density>()[0] = 9.0;
|
||||
candidate.get<Shape>()[2] = 10.0;
|
||||
CHECK(original.get<Density>()[0] == 1.0);
|
||||
CHECK(original.get<Shape>()[2] == 5.0);
|
||||
|
||||
State moved(std::move(candidate));
|
||||
CHECK(moved.layout() == layout);
|
||||
CHECK(moved.get<Density>()[0] == 9.0);
|
||||
CHECK(moved.get<Shape>()[2] == 10.0);
|
||||
CHECK(moved.get<Shape>().data() == moved.values().data() + 2);
|
||||
moved.get<Constant>()[0] = 11.0;
|
||||
CHECK(original.get<Constant>()[0] == 6.0);
|
||||
|
||||
State assigned(Layout{.state_sizes = {1, 1, 1}, .residual_sizes = {1, 1, 1}});
|
||||
assigned = std::move(moved);
|
||||
CHECK(assigned.layout() == layout);
|
||||
CHECK(assigned.get<Constant>()[0] == 11.0);
|
||||
CHECK(assigned.get<Constant>().data() == assigned.values().data() + 5);
|
||||
assigned = original;
|
||||
assigned.get<Density>()[1] = 12.0;
|
||||
CHECK(original.get<Density>()[1] == 2.0);
|
||||
}
|
||||
|
||||
TEST_CASE("Stellar states reject inconsistent storage and overflowing sizes while supporting empty coordinates", "[model][state][validation]") {
|
||||
const Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}};
|
||||
const State zero(layout);
|
||||
REQUIRE(zero.values().size() == 6);
|
||||
for (double value : zero.values()) {
|
||||
CHECK(value == 0.0);
|
||||
}
|
||||
CHECK_THROWS_WITH(State(layout, std::vector<double>(5)), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
|
||||
CHECK_THROWS_WITH(State(layout, std::vector<double>(7)), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
|
||||
const Layout overflow{.state_sizes = {std::numeric_limits<std::size_t>::max(), 1, 0}, .residual_sizes = {0, 1, 0}};
|
||||
CHECK_THROWS_WITH(State(overflow), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"));
|
||||
|
||||
const State partial(Layout{.state_sizes = {0, 2, 0}, .residual_sizes = {2, 0, 0}});
|
||||
CHECK(partial.get<Density>().empty());
|
||||
CHECK(partial.get<Shape>().size() == 2);
|
||||
CHECK(partial.get<Constant>().empty());
|
||||
const blocks::SystemLayout<dependencies::KeyList<>, dependencies::KeyList<>> empty_layout{};
|
||||
const serif::model::StellarState empty(empty_layout);
|
||||
CHECK(empty.values().empty());
|
||||
}
|
||||
232
tests/model/surface_refinement.cpp
Normal file
232
tests/model/surface_refinement.cpp
Normal file
@@ -0,0 +1,232 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/backend/mfem/geometry/deformation/computations.hpp"
|
||||
#include "serif/model/finite_elements.hpp"
|
||||
#include "serif/tests/solver/stellar_fixture.hpp"
|
||||
#include "serif/utils/dependencies/computation_cache.hpp"
|
||||
|
||||
namespace {
|
||||
namespace model = serif::model;
|
||||
namespace factory = model::backend::mfem;
|
||||
namespace deformation = serif::geometry::deformation;
|
||||
namespace geometry = deformation::backend::mfem;
|
||||
namespace domain = serif::discretization::domain;
|
||||
using Resources = serif::discretization::backend::mfem::Resources;
|
||||
using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema;
|
||||
constexpr int stellar_surface = Schema::boundary_id<domain::StellarSurfaceBoundary>();
|
||||
constexpr int infinity_surface = Schema::boundary_id<domain::InfinitySurfaceBoundary>();
|
||||
|
||||
struct SurfaceStatistics {
|
||||
int faces{};
|
||||
int equatorial_faces{};
|
||||
int polar_faces{};
|
||||
double largest_equatorial_area{};
|
||||
double smallest_polar_area{std::numeric_limits<double>::infinity()};
|
||||
std::vector<double> absolute_latitudes;
|
||||
std::vector<double> areas;
|
||||
};
|
||||
|
||||
// Measure concentration in physical space, independently of the selector's
|
||||
// logical coordinates and refinement-depth bookkeeping.
|
||||
SurfaceStatistics surface_statistics(mfem::Mesh& mesh, std::array<double, 3> axis) {
|
||||
double scale = 0.0;
|
||||
for (const double value : axis) scale = std::max(scale, std::abs(value));
|
||||
for (double& value : axis) value /= scale;
|
||||
const double norm = std::hypot(axis[0], axis[1], axis[2]);
|
||||
for (double& value : axis) value /= norm;
|
||||
SurfaceStatistics result;
|
||||
mfem::Vector position(3);
|
||||
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) {
|
||||
if (mesh.GetBdrAttribute(boundary) != stellar_surface) continue;
|
||||
++result.faces;
|
||||
auto& transform = *mesh.GetBdrElementTransformation(boundary);
|
||||
transform.Transform(mfem::Geometries.GetCenter(mesh.GetBdrElementBaseGeometry(boundary)), position);
|
||||
double latitude = 0.0;
|
||||
for (int component = 0; component < 3; ++component) latitude += axis[component] * position[component];
|
||||
latitude = std::abs(latitude) / position.Norml2();
|
||||
double area = 0.0;
|
||||
const auto& rule = mfem::IntRules.Get(mesh.GetBdrElementBaseGeometry(boundary), 8);
|
||||
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
|
||||
const auto& point = rule.IntPoint(sample);
|
||||
transform.SetIntPoint(&point);
|
||||
area += point.weight * transform.Weight();
|
||||
}
|
||||
REQUIRE(std::isfinite(area));
|
||||
REQUIRE(area > 0.0);
|
||||
result.absolute_latitudes.push_back(latitude);
|
||||
result.areas.push_back(area);
|
||||
if (latitude < std::sin(15.0 * std::numbers::pi / 180.0)) {
|
||||
++result.equatorial_faces;
|
||||
result.largest_equatorial_area = std::max(result.largest_equatorial_area, area);
|
||||
}
|
||||
if (latitude > 0.75) {
|
||||
++result.polar_faces;
|
||||
result.smallest_polar_area = std::min(result.smallest_polar_area, area);
|
||||
}
|
||||
}
|
||||
std::ranges::sort(result.absolute_latitudes);
|
||||
std::ranges::sort(result.areas);
|
||||
return result;
|
||||
}
|
||||
|
||||
void check_mesh_invariants(Resources& resources) {
|
||||
REQUIRE(resources.meshes.size() == 2);
|
||||
auto& physical = *resources.meshes[0];
|
||||
auto& logical = *resources.meshes[1];
|
||||
REQUIRE(physical.GetNE() == logical.GetNE());
|
||||
REQUIRE(physical.GetNV() == logical.GetNV());
|
||||
REQUIRE(physical.GetNBE() == logical.GetNBE());
|
||||
CHECK(physical.Nonconforming() == logical.Nonconforming());
|
||||
mfem::Array<int> physical_vertices, logical_vertices;
|
||||
for (int element = 0; element < physical.GetNE(); ++element) {
|
||||
CHECK(physical.GetAttribute(element) == logical.GetAttribute(element));
|
||||
physical.GetElementVertices(element, physical_vertices);
|
||||
logical.GetElementVertices(element, logical_vertices);
|
||||
REQUIRE(physical_vertices.Size() == logical_vertices.Size());
|
||||
CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast<std::size_t>(physical_vertices.Size())},
|
||||
std::span{logical_vertices.GetData(), static_cast<std::size_t>(logical_vertices.Size())}));
|
||||
auto& transform = *physical.GetElementTransformation(element);
|
||||
const auto& rule = mfem::IntRules.Get(physical.GetElementBaseGeometry(element), 6);
|
||||
for (int sample = 0; sample < rule.GetNPoints(); ++sample) {
|
||||
transform.SetIntPoint(&rule.IntPoint(sample));
|
||||
const double determinant = transform.Jacobian().Det();
|
||||
REQUIRE(std::isfinite(determinant));
|
||||
REQUIRE(determinant > 0.0);
|
||||
}
|
||||
}
|
||||
REQUIRE(resources.compactification);
|
||||
auto& coordinate = *resources.compactification;
|
||||
auto& scalar_space = *coordinate.FESpace();
|
||||
mfem::Array<int> dofs;
|
||||
int stellar_faces = 0, infinity_faces = 0;
|
||||
for (int boundary = 0; boundary < physical.GetNBE(); ++boundary) {
|
||||
CHECK(physical.GetBdrAttribute(boundary) == logical.GetBdrAttribute(boundary));
|
||||
physical.GetBdrElementVertices(boundary, physical_vertices);
|
||||
logical.GetBdrElementVertices(boundary, logical_vertices);
|
||||
REQUIRE(physical_vertices.Size() == logical_vertices.Size());
|
||||
CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast<std::size_t>(physical_vertices.Size())},
|
||||
std::span{logical_vertices.GetData(), static_cast<std::size_t>(logical_vertices.Size())}));
|
||||
const int attribute = physical.GetBdrAttribute(boundary);
|
||||
if (attribute != stellar_surface && attribute != infinity_surface) continue;
|
||||
if (attribute == stellar_surface) ++stellar_faces;
|
||||
else ++infinity_faces;
|
||||
scalar_space.GetBdrElementDofs(boundary, dofs);
|
||||
for (const int dof : dofs) {
|
||||
CHECK(coordinate[mfem::FiniteElementSpace::DecodeDof(dof)] ==
|
||||
Catch::Approx(attribute == stellar_surface ? 0.0 : 1.0).margin(2.0e-13));
|
||||
}
|
||||
}
|
||||
REQUIRE(stellar_faces > 0);
|
||||
REQUIRE(infinity_faces > 0);
|
||||
// This retains the production guard: volume hanging nodes are allowed,
|
||||
// but every stellar trace coefficient must remain independent.
|
||||
const auto trace = geometry::select_surface_scalar_dofs(scalar_space, stellar_surface);
|
||||
INFO((trace ? "Conforming stellar trace" : trace.error().message));
|
||||
REQUIRE(trace);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Surface refinement adds levels beyond the existing global mesh", "[model][resources][surface_refinement]") {
|
||||
for (const int base : {0, 1}) {
|
||||
CAPTURE(base);
|
||||
model::StellarMeshOptions options{.refinement = base, .geometry_order = 2};
|
||||
auto original = factory::make_stellar_resources(options);
|
||||
const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0});
|
||||
options.surface_refinement.levels = 1;
|
||||
auto refined = factory::make_stellar_resources(options);
|
||||
const auto after = surface_statistics(*refined.meshes[0], {0.0, 0.0, 1.0});
|
||||
CHECK(after.faces == 4 * before.faces);
|
||||
check_mesh_invariants(refined);
|
||||
|
||||
if (base == 1) {
|
||||
options.surface_refinement.equatorial_levels = 1;
|
||||
auto combined = factory::make_stellar_resources(options);
|
||||
const auto combined_stats = surface_statistics(*combined.meshes[0], {0.0, 0.0, 1.0});
|
||||
CHECK(combined_stats.faces > after.faces);
|
||||
CHECK(combined_stats.polar_faces == after.polar_faces);
|
||||
check_mesh_invariants(combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Equatorial rings concentrate resolution around the requested physical axis", "[model][resources][surface_refinement]") {
|
||||
model::StellarMeshOptions options{.geometry_order = 2};
|
||||
auto original = factory::make_stellar_resources(options);
|
||||
const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0});
|
||||
options.surface_refinement.equatorial_levels = 3;
|
||||
auto native = factory::make_stellar_resources(options);
|
||||
const auto expected = surface_statistics(*native.meshes[0], options.surface_refinement.axis);
|
||||
CHECK(expected.faces > before.faces);
|
||||
CHECK(expected.equatorial_faces > before.equatorial_faces);
|
||||
CHECK(expected.polar_faces == before.polar_faces);
|
||||
CHECK(expected.largest_equatorial_area < 0.5 * expected.smallest_polar_area);
|
||||
check_mesh_invariants(native);
|
||||
|
||||
const double largest = std::numeric_limits<double>::max();
|
||||
const double smallest = std::numeric_limits<double>::min();
|
||||
for (const std::array<double, 3> axis : {
|
||||
std::array{1.0, 0.0, 0.0}, std::array{1.0, 1.0, 1.0},
|
||||
std::array{-1.0, -1.0, -1.0}, std::array{0.0, 0.0, -1.0},
|
||||
std::array{largest, largest, largest}, std::array{smallest, smallest, smallest}}) {
|
||||
CAPTURE(axis);
|
||||
options.surface_refinement.axis = axis;
|
||||
auto rotated = factory::make_stellar_resources(options);
|
||||
const auto actual = surface_statistics(*rotated.meshes[0], axis);
|
||||
REQUIRE(actual.faces == expected.faces);
|
||||
CHECK(actual.equatorial_faces == expected.equatorial_faces);
|
||||
CHECK(actual.polar_faces == expected.polar_faces);
|
||||
for (std::size_t index = 0; index < actual.areas.size(); ++index) {
|
||||
CHECK(actual.absolute_latitudes[index] == Catch::Approx(expected.absolute_latitudes[index]).margin(2.0e-12));
|
||||
CHECK(actual.areas[index] == Catch::Approx(expected.areas[index]).epsilon(2.0e-12));
|
||||
}
|
||||
check_mesh_invariants(rotated);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Refined rotated meshes retain one surface and volume deformation", "[model][resources][surface_refinement]") {
|
||||
const auto star = serif::tests::solver::make_model();
|
||||
const auto finite_elements = model::make_stellar_discretization(star,
|
||||
{.geometry_order = 3, .surface_refinement = {.levels = 1, .equatorial_levels = 2, .axis = {1.0, 1.0, 1.0}}},
|
||||
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
|
||||
.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}});
|
||||
std::vector<double> shape(9);
|
||||
for (std::size_t mode = 0; mode < shape.size(); ++mode) shape[mode] = 0.002 * std::sin(0.37 * (mode + 1));
|
||||
const auto inputs = geometry::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape);
|
||||
auto cache = serif::utils::dependencies::make_computation_cache<deformation::DeformationFailure>(
|
||||
geometry::make_deformation_graph<deformation::SphericalHarmonicSurface>(), inputs);
|
||||
const auto prepared = cache.try_prepare(inputs);
|
||||
INFO((prepared ? "Deformation graph prepared" : prepared.error().error.message));
|
||||
REQUIRE(prepared);
|
||||
const auto& reference = *cache.get<deformation::keys::ReferenceSurface>();
|
||||
const auto& surface = cache.get<deformation::keys::SurfaceDisplacement>();
|
||||
const auto& volume = cache.get<deformation::keys::VolumeDisplacement>();
|
||||
const auto& space = finite_elements.deformation.reference.displacement_space;
|
||||
REQUIRE(std::ranges::any_of(surface, [](double value) { return std::abs(value) > 1.0e-6; }));
|
||||
for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) {
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
CHECK(volume[reference.scalar_dofs[node] + component * space.GetNDofs()] ==
|
||||
Catch::Approx(surface[3 * node + component]).margin(2.0e-12));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Surface refinement rejects undefined levels bands and axes", "[model][resources][surface_refinement]") {
|
||||
model::StellarMeshOptions options{.geometry_order = 2};
|
||||
SECTION("negative surface levels") { options.surface_refinement.levels = -1; }
|
||||
SECTION("negative equatorial levels") { options.surface_refinement.equatorial_levels = -1; }
|
||||
SECTION("zero axis") { options.surface_refinement.axis = {0.0, 0.0, 0.0}; }
|
||||
SECTION("nonfinite axis") { options.surface_refinement.axis = {0.0, std::numeric_limits<double>::quiet_NaN(), 1.0}; }
|
||||
SECTION("nonfinite band") { options.surface_refinement.equatorial_half_angle_degrees = std::numeric_limits<double>::quiet_NaN(); }
|
||||
SECTION("zero band") { options.surface_refinement.equatorial_half_angle_degrees = 0.0; }
|
||||
SECTION("band outside the supported side patches") { options.surface_refinement.equatorial_half_angle_degrees = 35.1; }
|
||||
CHECK_THROWS(factory::make_stellar_resources(options));
|
||||
}
|
||||
Reference in New Issue
Block a user