#include #include #include #include #include #include #include "serif/solver/continuation/polytropic.hpp" #include "serif/solver/continuation/identified_equations.hpp" namespace { namespace family = serif::solver::continuation; namespace model = serif::model; namespace dimensions = serif::dimensions; using Polytrope = serif::eos::models::Polytrope; struct Definition { double pressure{0.01}; /** @brief State explicitly which captured physical input belongs to family identity. */ [[nodiscard]] std::array continuation_identity() const { return {pressure}; } }; /** @brief Retain all physical prescriptions while changing selected independent inputs. */ [[nodiscard]] auto target_model(double index = 3.0, double constant = 0.36029966935264141, double momentum = 0.035, double mass = 1.0, double pressure = 0.01, std::array axis = {1.0, 2.0, 3.0}, std::array center = {0.2, -0.3, 0.4}) { return model::make_stellar_model(Polytrope{index, constant}, Definition{pressure}, model::FixedTotalMass{dimensions::MassValue{mass}}, model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{momentum}, .axis = axis, .center = center}}); } /** @brief Evaluate pressure independently through the EOS relation. */ [[nodiscard]] double pressure(const Polytrope& eos, double density) { return eos.evaluate(serif::eos::relations::PressureFromDensity{}, dimensions::DensityValue{density}).value(); } /** @brief Compare values using a relative tolerance that does not privilege one system of units. */ [[nodiscard]] bool close(double actual, double expected) { return std::abs(actual - expected) <= 128.0 * std::numeric_limits::epsilon() * std::abs(expected); } class TestEOS; struct TestPressure { using Specification = TestEOS; using Value = dimensions::PressureValue; static constexpr std::string_view name = "test pressure"; static constexpr std::string_view stable_id = "test.pressure"; /** @brief Read the extension's selected physical pressure. */ [[nodiscard]] static Value read(const TestEOS& value); /** @brief Test extension domain validation without exceptions during candidate preparation. */ [[nodiscard]] static bool valid(Value value) { return std::isfinite(value.value()) && value.value() > 0.0; } }; class TestEOS { public: using ContinuationParameters = family::ParameterList; double pressure{3.0}; bool ignore_updates{false}; double clone_offset{}; /** @brief Include all physical values; fault-injection switches are test behavior. */ [[nodiscard]] std::array continuation_identity() const { return {pressure}; } /** @brief Deliberately allow a faulty extension to exercise installed-value readback. */ template [[nodiscard]] TestEOS with_continuation_parameters(const Values& values) const { TestEOS result = *this; if (!ignore_updates) { result.pressure = values.get(TestPressure{}).value(); } return result; } /** @brief Deliberately allow a faulty clone to exercise exact target identity. */ [[nodiscard]] TestEOS clone_for_continuation() const { TestEOS result = *this; result.pressure += clone_offset; return result; } }; TestPressure::Value TestPressure::read(const TestEOS& value) { return Value{value.pressure}; } struct MoveOnlyDefinition { std::unique_ptr pressure; /** @brief Owning captures still identify physical family inputs. */ [[nodiscard]] std::array continuation_identity() const { return {*pressure}; } }; } TEST_CASE("Physical continuation preserves pressure pivots and exact target prescriptions", "[unit][solver][continuation][family]") { const auto target = target_model(); const auto bound = family::in(family::PolytropicIndex{}, family::AngularMomentum{}) .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target); CHECK(bound.target_coordinates() == std::array{3.0, 0.035}); CHECK(bound.starting_coordinates() == std::array{1.0, 0.0}); CHECK(bound.parameter_descriptors()[0].stable_id == "polytrope.index"); CHECK(bound.parameter_descriptors()[1].stable_id == "fixed_angular_momentum.target"); for (double index : {1.0, 1.5, 2.0, 2.999999, 3.0, 3.5}) { const auto stage = bound.model_at({index, 0.02}); CHECK(close(pressure(stage.equation_of_state(), 0.2), pressure(target.equation_of_state(), 0.2))); CHECK(stage.template specification().target().value() == 1.0); CHECK(stage.equations().pressure == 0.01); const auto& rotation = stage.template specification(); CHECK(rotation.target().value() == 0.02); CHECK(rotation.axis() == target.template specification().axis()); CHECK(rotation.center() == target.template specification().center()); REQUIRE(bound.try_validate_member(stage)); } const auto endpoint = bound.model_at(bound.target_coordinates()); CHECK(endpoint.equation_of_state().polytropic_constant() == target.equation_of_state().polytropic_constant()); CHECK(bound.model_signature(endpoint) == bound.model_signature(target)); CHECK(bound.coordinates_of(endpoint) == bound.target_coordinates()); } TEST_CASE("Pressure pivot families are invariant under consistent changes of physical units", "[unit][solver][continuation][family]") { constexpr double density = 0.4, density_units = 100.0, pressure_units = 0.01, index = 1.5, constant = 0.73; const double converted = pressure_units * constant / std::pow(density_units, 1.0 + 1.0 / index); const auto first = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density})).bind(target_model(index, constant)); const auto second = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density * density_units})).bind(target_model(index, converted)); for (double candidate : {1.0, 2.0, 3.0}) { const auto original = first.model_at({candidate}), rescaled = second.model_at({candidate}); for (double sample : {0.01, 0.4, 3.0}) { CHECK(close(pressure(rescaled.equation_of_state(), sample * density_units), pressure_units * pressure(original.equation_of_state(), sample))); } } } TEST_CASE("Continuation family membership checks all unchanged prescriptions and captured equations", "[unit][solver][continuation][family]") { const auto bound = family::in(family::AngularMomentum{}).bind(target_model()); CHECK(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01))); CHECK_FALSE(bound.try_validate_member(target_model(2.0))); CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.3))); CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 2.0))); CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.02))); CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {0.0, 0.0, 1.0}))); CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {1.0, 2.0, 3.0}, {}))); const auto index_family = family::in(family::PolytropicIndex{}) .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model()); CHECK_FALSE(index_family.try_validate_member(target_model(1.5, 0.36029966935264141, 0.035))); } TEST_CASE("Continuation laws report invalid numerical candidates and reject inconsistent configuration", "[unit][solver][continuation][family]") { const double nan = std::numeric_limits::quiet_NaN(); for (double invalid : {0.0, -1.0, nan, std::numeric_limits::infinity()}) { CHECK_THROWS_AS(family::preserve_pressure_at_reference_density(dimensions::DensityValue{invalid}), std::invalid_argument); } const auto mismatched = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [](double) { return 2.0; })); CHECK_THROWS_AS(mismatched.bind(target_model()), std::invalid_argument); const auto broken = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [nan](double index) { return index == 3.0 ? 0.36029966935264141 : nan; })).bind(target_model()); CHECK_FALSE(broken.try_model_at({2.0})); CHECK_FALSE(broken.try_model_at({0.5})); CHECK_FALSE(broken.try_model_at({nan})); const auto physical = family::in(family::PolytropicIndex{}, family::AngularMomentum{}) .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model()); CHECK_FALSE(physical.try_model_at({0.5, 0.01})); CHECK_FALSE(physical.try_model_at({2.0, -0.01})); CHECK_FALSE(physical.try_model_at({std::numeric_limits::infinity(), 0.01})); CHECK_FALSE(physical.try_model_at({2.0, nan})); const auto overflowing = family::in(family::PolytropicIndex{}).with( family::preserve_pressure_at_reference_density(dimensions::DensityValue{std::numeric_limits::max()})); CHECK_THROWS_AS(overflowing.bind(target_model()), std::invalid_argument); using Function = double (*)(double); CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), static_cast(nullptr)), std::invalid_argument); const family::ParameterLaw, Function> bypassed{nullptr}; CHECK_THROWS_AS(family::in(family::PolytropicIndex{}).with(bypassed).bind(target_model()), std::invalid_argument); using Member = double (dimensions::AngularMomentumValue::*)() const noexcept; CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::AngularMomentum{}), static_cast(nullptr)), std::invalid_argument); } TEST_CASE("Continuation owns move-only laws and explicitly reproducible equation definitions", "[unit][solver][continuation][family]") { auto definition = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [constant = std::make_unique(0.3)](double index) { return *constant * index / 3.0; })); const auto bound = std::move(definition).bind(target_model(3.0, 0.3)); CHECK(bound.model_at({1.5}).equation_of_state().polytropic_constant() == Catch::Approx(0.15)); CHECK(bound.model_at({3.0}).equation_of_state().polytropic_constant() == 0.3); auto target = model::make_stellar_model(Polytrope{3.0, 0.3}, MoveOnlyDefinition{std::make_unique(0.01)}, model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); const auto movable = family::in(family::AngularMomentum{}).bind(std::move(target), [](const MoveOnlyDefinition& original) { return MoveOnlyDefinition{std::make_unique(*original.pressure)}; }); auto first = movable.model_at({0.02}), second = movable.model_at({0.04}); CHECK(first.equations().pressure.get() != second.equations().pressure.get()); CHECK(movable.try_validate_member(first)); } TEST_CASE("Custom physical families verify installed values and exact target cloning", "[unit][solver][continuation][family]") { const auto target = model::make_stellar_model(TestEOS{}, Definition{}); const auto custom = family::in(TestPressure{}).bind(target); CHECK(custom.starting_coordinates() == std::array{3.0}); CHECK(custom.target_coordinates() == std::array{3.0}); const auto stage = custom.model_at({2.0}); CHECK(stage.equation_of_state().pressure == 2.0); CHECK(custom.try_validate_member(stage)); const auto faulty = model::make_stellar_model(TestEOS{.ignore_updates = true}, Definition{}, model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); const auto ignores = family::in(TestPressure{}).bind(faulty); CHECK(ignores.try_model_at(ignores.target_coordinates())); CHECK_FALSE(ignores.try_model_at({2.0})); CHECK_FALSE(ignores.try_model_at({std::nextafter(3.0, 2.0)})); CHECK(ignores.target_model().equation_of_state().pressure == 3.0); const auto derived = family::in(family::AngularMomentum{}).with(family::law(TestPressure{}, family::depends_on(family::AngularMomentum{}), [](dimensions::AngularMomentumValue momentum) { return dimensions::PressureValue{30.0 * momentum.value()}; })).bind(faulty); CHECK(derived.try_model_at(derived.target_coordinates())); CHECK_FALSE(derived.try_model_at({0.05})); CHECK(derived.target_model().equation_of_state().pressure == 3.0); const auto bad_clone = model::make_stellar_model(TestEOS{.clone_offset = std::numeric_limits::epsilon() * 3.0}, Definition{}, model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); CHECK_THROWS_AS(family::in(family::AngularMomentum{}).bind(bad_clone), std::invalid_argument); CHECK(bad_clone.equation_of_state().pressure == 3.0); } TEST_CASE("Identified equation definitions retain typed captures and support move-only callables", "[unit][solver][continuation][family]") { auto equations = family::identified_equations([offset = std::make_unique(2.0)](const auto& model) { return model.equation_of_state().polytropic_index() + *offset; }, dimensions::PressureValue{0.02}, dimensions::QuantityValue{1.0}); STATIC_CHECK_FALSE(std::copy_constructible); STATIC_CHECK(std::same_as, dimensions::PressureValue>); CHECK(equations.continuation_identity() == std::array{0.02, 1.0}); CHECK(equations(target_model()) == 5.0); auto moved = std::move(equations); CHECK(moved(target_model(1.5)) == 3.5); CHECK(moved.continuation_identity() == std::array{0.02, 1.0}); }