Files
MeanField_SERiF/tests/model/description.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
2026-11-09 13:53:17 -05:00

273 lines
13 KiB
C++

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