test(tests): added test suite

The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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