#include #include #include #include #include #include #include #include #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 parameter; }; struct MoveOnlyConstraint { using ScalarDescription = model::ScalarConstraint; using ModelDefinition = model::SpecificationDefinition; std::unique_ptr 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; std::unique_ptr 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 partial_derivative(Relation, dimensions::WithRespectTo, dimensions::SpecificEnthalpyValue) const noexcept { return dimensions::PartialDerivative{*coefficient}; } }; /** @brief Preserve surface members through aggregate deduction and the substitution performed by a templated factory. */ template [[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); STATIC_CHECK(std::same_as); STATIC_CHECK(std::same_as, Surface>); CHECK(star.equations().surface_condition.target_pressure().value() == 0.8); const auto equations = model::resolve_equilibrium_equations(star); STATIC_CHECK(std::same_as); 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().target().value() == 1.5); CHECK(std::tuple_size_v())> == 2); const auto descriptors = star.specification_descriptors(); REQUIRE(descriptors.size() == 2); CHECK(descriptors[0].name == "FixedTotalMass"); CHECK(descriptors[1].name == "FixedAngularMomentum"); std::vector 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{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().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; if constexpr (std::same_as) { 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(physics.equation_of_state().parameter); }, .shape_force = std::make_unique(8) }; const auto star = model::make_stellar_model(SymbolicEOS{4.0}, model::EquilibriumEquations { .interior_equations = definitions.interior_equations, .shape_force = std::make_unique(8) }); const auto equations = model::resolve_equilibrium_equations(star); STATIC_CHECK(std::is_reference_v); STATIC_CHECK_FALSE(std::is_reference_v); 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(3.0)}, model::EquilibriumEquations{std::make_unique(5)}, MoveOnlyConstraint{std::make_unique(2.0)}, model::FixedTotalMass{dimensions::MassValue{7.0}}); CHECK(*star.equation_of_state().parameter == 3.0); CHECK(star.specification().target().value() == 2.0); const auto changed = star.transform_specifications([](const auto& physics) { using Physics = std::remove_cvref_t; if constexpr (std::same_as) { return Physics{std::make_unique(*physics.parameter)}; } else if constexpr (std::same_as) { return Physics{std::make_unique(11.0)}; } else { return physics; } }, model::EquilibriumEquations{std::make_unique(9)}); CHECK(changed.specification().target().value() == 11.0); CHECK(star.specification().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::infinity(), std::numeric_limits::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::infinity(), std::numeric_limits::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{1.0, 2.0, 3.0}); for (double scale : {std::numeric_limits::max(), std::numeric_limits::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(2.0)}, model::EquilibriumEquations{1}); using Model = decltype(star); std::shared_ptr owner = std::make_shared(std::move(star)); std::weak_ptr lifetime = owner; const auto shared = model::share_equation_of_state(owner); STATIC_CHECK(serif::eos::models::EOSSupportingPartialDerivative); CHECK(&shared.get() == &owner->equation_of_state()); owner.reset(); CHECK_FALSE(lifetime.expired()); CHECK(serif::eos::evaluate(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 6.0); CHECK(serif::eos::partial_derivative(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 2.0); STATIC_CHECK(noexcept(shared.evaluate(MoveOnlyPhysicalEOS::Relation{}, dimensions::SpecificEnthalpyValue{1.0}))); CHECK_THROWS_AS(model::EosReference(std::shared_ptr{}), std::invalid_argument); CHECK_THROWS_AS(model::share_equation_of_state(std::shared_ptr{}), 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(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>; serif::equations::EquationPoint point; point.fields.get() = 11.0; point.fields.get() = 2.0; const auto linearization = equations.interior_equations.get().evaluate(point); CHECK(linearization.residual == 3.0); CHECK(linearization.field_derivatives.get() == -4.0); }