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

163
tests/surface/automatic.cpp Normal file
View File

@@ -0,0 +1,163 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/tests/equations/test_equations.hpp"
#include "serif/eos/models/polytropic.hpp"
#include "serif/geometry/surface/automatic.hpp"
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
#include "serif/tests/surface/models.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
using namespace serif;
using namespace serif::tests::surface;
namespace backend = physics::backend::mfem;
using ContinuousDensity = ScalarTerm<quantities::MassDensity>;
using Pressure = ScalarTerm<quantities::Pressure>;
struct ForwardOnly : GasEOS
{
using Relations = eos::relations::RelationCatalog<PressureFromDensityAndTemperature>;
};
struct SymbolicEOS
{
};
}
TEST_CASE("Automatic isobaric compilation preserves the polytropic zero-pressure unit derivative", "[surface][automatic]")
{
using Fields = dependencies::KeyList<Enthalpy, Density, blocks::barotropic_constant::mass_normalization, blocks::rotation::angular_velocity>;
using Selection = physics::AutomaticPressureSurface<eos::models::Polytrope, Fields, dependencies::KeyList<Enthalpy>>;
using Formulation = Selection::Formulation;
const eos::models::Polytrope equation_of_state{3.0, 0.25};
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = 0.0;
const auto result = physics::evaluate_pressure_surface<Formulation>(equation_of_state, physics::Isobaric{quantities::PressureValue{0.0}}, point);
REQUIRE(result);
CHECK(result->residual == 0.0);
CHECK(result->field_derivatives.get<Enthalpy>() == 1.0);
CHECK(Formulation::resolves_carrier);
}
TEST_CASE("Automatic isobaric compilation uses inverse or direct EOS relations without enthalpy", "[surface][automatic]")
{
using Fields = dependencies::KeyList<ContinuousDensity, Temperature>;
using Rows = dependencies::KeyList<ContinuousDensity>;
const auto check = []<typename EOS>(const EOS& equation_of_state)
{
using Formulation = typename physics::AutomaticPressureSurface<EOS, Fields, Rows>::Formulation;
physics::SurfacePoint<Formulation> point{};
point.fields.template get<ContinuousDensity>() = 0.4;
point.fields.template get<Temperature>() = 2.0;
const auto result = physics::evaluate_pressure_surface<Formulation>(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, point);
REQUIRE(result);
CHECK(result->residual == 0.0);
constexpr double step = 1.0e-6;
auto plus = point;
auto minus = point;
plus.fields.template get<ContinuousDensity>() += step * 0.3;
minus.fields.template get<ContinuousDensity>() -= step * 0.3;
plus.fields.template get<Temperature>() -= step * 0.2;
minus.fields.template get<Temperature>() += step * 0.2;
const auto forward = physics::evaluate_pressure_surface<Formulation>(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, plus);
const auto backward = physics::evaluate_pressure_surface<Formulation>(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, minus);
REQUIRE(forward);
REQUIRE(backward);
const double derivative = result->field_derivatives.template get<ContinuousDensity>() * 0.3 - result->field_derivatives.template get<Temperature>() * 0.2;
CHECK_THAT((forward->residual - backward->residual) / (2.0 * step), Catch::Matchers::WithinAbs(derivative, 2.0e-9));
};
check(GasEOS{});
check(ForwardOnly{});
}
TEST_CASE("A pressure unknown needs no EOS relations or EOS graph input", "[surface][automatic]")
{
using Fields = dependencies::KeyList<Pressure>;
using Formulation = physics::AutomaticPressureSurface<SymbolicEOS, Fields, Fields>::Formulation;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Pressure>() = 0.2;
const auto single_point = physics::evaluate_pressure_surface<Formulation>(SymbolicEOS{}, physics::Isobaric{quantities::PressureValue{0.2}}, point);
REQUIRE(single_point);
CHECK(single_point->residual == 0.0);
CHECK(single_point->field_derivatives.get<Pressure>() == 1.0);
const auto inputs = [](const double target)
{
return dependencies::make_dependency_context(physics::pressure_condition(physics::Isobaric{quantities::PressureValue{target}}), physics::boundary_values<Pressure>({0.0, 0.2, 0.8}));
};
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<Formulation>(), inputs(0.2));
REQUIRE(cache.try_prepare(inputs(0.2)));
const auto& values = cache.get<physics::keys::Linearization>();
REQUIRE(values.size() == 3);
CHECK(values[0].residual == -0.2);
CHECK(values[1].residual == 0.0);
CHECK_THAT(values[2].residual, Catch::Matchers::WithinAbs(0.6, 1.0e-15));
for (const auto& value : values)
{
CHECK(value.field_derivatives.get<Pressure>() == 1.0);
}
REQUIRE(cache.try_prepare(inputs(0.8)));
CHECK(cache.get<physics::keys::Linearization>()[2].residual == 0.0);
const auto unchanged = cache.try_prepare(inputs(0.8));
REQUIRE(unchanged);
CHECK(unchanged->computed_count() == 0);
}
TEST_CASE("Automatic pressure-field boundaries preserve interior rows and exact transpose actions", "[surface][automatic][backend]")
{
namespace fixture = serif::tests::surface::boundary;
namespace coefficients = operators::backend::mfem;
using Fields = dependencies::KeyList<Pressure>;
using Formulation = physics::AutomaticPressureSurface<SymbolicEOS, Fields, Fields>::Formulation;
fixture::Fixture mesh;
discretization::backend::mfem::TermBinding<Pressure, fixture::Schema> binding{mesh.linear_space};
const ::mfem::Vector pressure = fixture::sequence(binding.size(), 0.4, 0.2);
const ::mfem::Vector direction = fixture::sequence(binding.size(), -0.1, 0.7);
const ::mfem::Vector dual = fixture::sequence(binding.size(), 0.3, 0.4);
const auto state = coefficients::make_term_coefficients(coefficients::bind(binding, pressure));
const auto directions = coefficients::make_term_coefficients(coefficients::bind(binding, direction));
const auto boundary = backend::make_pressure_boundary<Formulation>(state);
const SymbolicEOS equation_of_state;
const auto eos_input = dependencies::borrow<physics::keys::EquationOfState>(equation_of_state, dependencies::track_by([](const SymbolicEOS&)
{
return 0;
}));
const auto inputs = backend::make_pressure_boundary_inputs(boundary, eos_input, physics::Isobaric{quantities::PressureValue{0.2}}, state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(backend::make_pressure_boundary_graph<Formulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto& linearization = cache.get<physics::keys::Linearization>();
::mfem::Vector residual(binding.size()), action(binding.size()), transpose(binding.size());
residual = 4.0;
action = 0.0;
transpose = 0.0;
backend::apply_residual_rows(*boundary, linearization, residual);
backend::apply_jacobian_rows(*boundary, linearization, directions, action);
backend::add_jacobian_transpose_rows<Pressure>(*boundary, linearization, dual, transpose);
const ::mfem::Vector selected = fixture::boundary_mask(binding);
for (int row = 0; row < binding.size(); ++row)
{
if (selected[row] != 0.0)
{
CHECK_THAT(residual[row], Catch::Matchers::WithinAbs(pressure[row] - 0.2, 1.0e-14));
CHECK_THAT(action[row], Catch::Matchers::WithinAbs(direction[row], 1.0e-14));
CHECK_THAT(transpose[row], Catch::Matchers::WithinAbs(dual[row], 1.0e-14));
}
else
{
CHECK(residual[row] == 4.0);
CHECK(action[row] == 0.0);
CHECK(transpose[row] == 0.0);
}
}
CHECK_THAT(action * dual, Catch::Matchers::WithinAbs(direction * transpose, 1.0e-14));
}

544
tests/surface/boundary.cpp Normal file
View File

@@ -0,0 +1,544 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <stdexcept>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace test = serif::tests::surface;
namespace fixture = test::boundary;
namespace physics = serif::geometry::surface;
namespace boundary = physics::backend::mfem;
namespace dependencies = serif::utils::dependencies;
namespace coefficients = serif::operators::backend::mfem;
using fixture::Vector;
using Status = dependencies::PreparationStatus;
using Linearization = physics::keys::Linearization;
using Response = physics::keys::EOSResponse;
using Catch::Matchers::WithinAbs;
/** @brief Track physical EOS coefficients while excluding observational evaluation counters. */
[[nodiscard]] auto eos_input(const test::GeneralEOS& model)
{
return dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const test::GeneralEOS& eos)
{
return eos.scale;
}));
}
/** @brief The ideal-gas fixture has no mutable physical parameters. */
[[nodiscard]] auto eos_input(const test::GasEOS& model)
{
return dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by([](const test::GasEOS&)
{
return 0;
}));
}
/** @brief Make the prescribed thermodynamic pressure explicit in every candidate. */
[[nodiscard]] physics::IsobaricSurface pressure(const double value)
{
return physics::IsobaricSurface{test::quantities::PressureValue{value}};
}
/** @brief Bind the composition-dependent EOS to three distinct finite-element fields. */
[[nodiscard]] auto composition_state(const fixture::Fixture& mesh, const Vector& enthalpy, const Vector& entropy, const Vector& composition)
{
return coefficients::make_term_coefficients(coefficients::bind(mesh.enthalpy, enthalpy), coefficients::bind(mesh.entropy, entropy), coefficients::bind(mesh.composition, composition));
}
/** @brief Check the same nonlinear gas surface with either carrier-resolved or direct-pressure residuals. */
template <typename Formulation>
void check_gas_rows()
{
fixture::Fixture mesh;
test::GasEOS model;
Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1);
Vector temperature = fixture::sequence(mesh.temperature.size(), 1.3, 0.2);
const Vector density_direction = fixture::sequence(density.Size(), 0.07, 0.3);
const Vector temperature_direction = fixture::sequence(temperature.Size(), -0.11, 0.4);
const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.temperature, temperature));
const auto direction = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density_direction), coefficients::bind(mesh.temperature, temperature_direction));
const auto selected = boundary::make_pressure_boundary<Formulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<Formulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const Vector sampled_temperature = fixture::reference_samples(mesh.density, *selected->selected_rows, mesh.temperature, temperature);
const Vector sampled_direction = fixture::reference_samples(mesh.density, *selected->selected_rows, mesh.temperature, temperature_direction);
const auto& physics = cache.template get<Linearization>();
Vector residual(density.Size()), action(density.Size());
residual = 4.0;
action = -3.0;
boundary::apply_residual_rows(*selected, physics, residual);
boundary::apply_jacobian_rows(*selected, physics, direction, action);
const Vector mask = fixture::boundary_mask(mesh.density);
for (int index = 0; index < selected->selected_rows->size(); ++index)
{
const int row = selected->selected_rows->reduced_dofs()[index];
const double local_temperature = sampled_temperature[index];
const double expected_residual = Formulation::resolves_carrier ? density[row] - 0.8 / local_temperature : density[row] * local_temperature - 0.8;
const double expected_action = Formulation::resolves_carrier ? density_direction[row] + 0.8 * sampled_direction[index] / (local_temperature * local_temperature) : local_temperature * density_direction[row] + density[row] * sampled_direction[index];
CHECK_THAT(residual[row], WithinAbs(expected_residual, 1.0e-13));
CHECK_THAT(action[row], WithinAbs(expected_action, 1.0e-13));
}
for (int row = 0; row < mask.Size(); ++row)
{
if (mask[row] == 0.0)
{
CHECK(residual[row] == 4.0);
CHECK(action[row] == -3.0);
action[row] = 0.0;
}
}
const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8);
Vector density_force(density.Size()), temperature_force(temperature.Size());
density_force = 0.0;
temperature_force = 0.0;
boundary::add_jacobian_transpose_rows<fixture::BoundaryDensity>(*selected, physics, dual, density_force);
boundary::add_jacobian_transpose_rows<test::Temperature>(*selected, physics, dual, temperature_force);
const double forward_work = action * dual;
const double reverse_work = density_direction * density_force + temperature_direction * temperature_force;
CHECK_THAT(forward_work, WithinAbs(reverse_work, 2.0e-13 * std::max(1.0, std::abs(forward_work))));
constexpr double step = 1.0e-7;
const Vector original_density(density), original_temperature(temperature);
Vector plus(density.Size()), minus(density.Size());
plus = 0.0;
minus = 0.0;
density.Add(step, density_direction);
temperature.Add(step, temperature_direction);
REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state)));
boundary::apply_residual_rows(*selected, cache.template get<Linearization>(), plus);
density = original_density;
temperature = original_temperature;
density.Add(-step, density_direction);
temperature.Add(-step, temperature_direction);
REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state)));
boundary::apply_residual_rows(*selected, cache.template get<Linearization>(), minus);
for (const int row : selected->selected_rows->reduced_dofs())
{
CHECK_THAT((plus[row] - minus[row]) / (2.0 * step), WithinAbs(action[row], 2.0e-9));
}
}
}
TEST_CASE("General EOS pressure rows sample every field in its own finite-element basis", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const Vector enthalpy_direction = fixture::sequence(enthalpy.Size(), 0.7, 0.2);
const Vector entropy_direction = fixture::sequence(entropy.Size(), -0.2, 0.4);
const Vector composition_direction = fixture::sequence(composition.Size(), 0.05, 0.6);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
const auto direction = composition_state(mesh, enthalpy_direction, entropy_direction, composition_direction);
const auto selected = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
CHECK(model.evaluations == 0);
REQUIRE(cache.try_prepare(inputs));
const auto& accepted = cache.get<Linearization>();
const auto& rows = *selected->selected_rows;
REQUIRE(rows.size() > 0);
REQUIRE(rows.size() < enthalpy.Size());
const Vector mask = fixture::boundary_mask(mesh.enthalpy);
CHECK(rows.size() == static_cast<int>(mask.Sum()));
const Vector sampled_entropy = fixture::reference_samples(mesh.enthalpy, rows, mesh.entropy, entropy);
const Vector sampled_composition = fixture::reference_samples(mesh.enthalpy, rows, mesh.composition, composition);
const Vector sampled_entropy_direction = fixture::reference_samples(mesh.enthalpy, rows, mesh.entropy, entropy_direction);
const Vector sampled_composition_direction = fixture::reference_samples(mesh.enthalpy, rows, mesh.composition, composition_direction);
Vector residual(enthalpy.Size()), action(enthalpy.Size());
residual = 7.0;
action = -4.0;
boundary::apply_residual_rows(*selected, accepted, residual);
boundary::apply_jacobian_rows(*selected, accepted, direction, action);
for (int index = 0; index < rows.size(); ++index)
{
const int row = rows.reduced_dofs()[index];
CHECK(mask[row] == 1.0);
CHECK_THAT(residual[row], WithinAbs(enthalpy[row] - (0.8 + 3.0 * sampled_entropy[index] + 5.0 * sampled_composition[index]), 1.0e-13));
CHECK_THAT(action[row], WithinAbs(enthalpy_direction[row] - 3.0 * sampled_entropy_direction[index] - 5.0 * sampled_composition_direction[index], 1.0e-13));
}
for (int row = 0; row < mask.Size(); ++row)
{
if (mask[row] == 0.0)
{
CHECK(residual[row] == 7.0);
CHECK(action[row] == -4.0);
action[row] = 0.0;
}
}
const Vector dual = fixture::sequence(enthalpy.Size(), 0.3, 0.9);
Vector enthalpy_force(enthalpy.Size()), entropy_force(entropy.Size()), composition_force(composition.Size()), volume_dual;
enthalpy_force = 0.0;
entropy_force = 0.0;
composition_force = 0.0;
boundary::mask_residual_dual(*selected, dual, volume_dual);
boundary::add_jacobian_transpose_rows<test::Enthalpy>(*selected, accepted, dual, enthalpy_force);
boundary::add_jacobian_transpose_rows<test::Entropy>(*selected, accepted, dual, entropy_force);
boundary::add_jacobian_transpose_rows<fixture::QuadraticComposition>(*selected, accepted, dual, composition_force);
const double forward_work = dual * action;
const double reverse_work = enthalpy_direction * enthalpy_force + entropy_direction * entropy_force + composition_direction * composition_force;
CHECK_THAT(forward_work, WithinAbs(reverse_work, 2.0e-13 * std::max(1.0, std::abs(forward_work))));
for (int row = 0; row < dual.Size(); ++row)
{
CHECK(volume_dual[row] == (mask[row] != 0.0 ? 0.0 : dual[row]));
}
Vector aliased_direction(enthalpy_direction);
const auto aliased_state = composition_state(mesh, aliased_direction, entropy_direction, composition_direction);
boundary::apply_jacobian_rows(*selected, accepted, aliased_state, aliased_direction);
Vector aliased_dual(dual);
boundary::add_jacobian_transpose_rows<test::Enthalpy>(*selected, accepted, aliased_dual, aliased_dual);
for (int row = 0; row < enthalpy.Size(); ++row)
{
CHECK(aliased_direction[row] == (mask[row] != 0.0 ? action[row] : enthalpy_direction[row]));
CHECK_THAT(aliased_dual[row], WithinAbs(dual[row] + enthalpy_force[row], 1.0e-15));
}
// Mixed-order FE interpolation adds roundoff before residual subtraction; a larger step retains the original absolute tolerance.
// The local EOS regression separately preserves MeanField's original 1e-7 step.
constexpr double step = 1.0e-6;
const Vector original_enthalpy(enthalpy), original_entropy(entropy), original_composition(composition);
Vector plus(enthalpy.Size()), minus(enthalpy.Size());
plus = 0.0;
minus = 0.0;
enthalpy.Add(step, enthalpy_direction);
entropy.Add(step, entropy_direction);
composition.Add(step, composition_direction);
REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)));
boundary::apply_residual_rows(*selected, cache.get<Linearization>(), plus);
enthalpy = original_enthalpy;
entropy = original_entropy;
composition = original_composition;
enthalpy.Add(-step, enthalpy_direction);
entropy.Add(-step, entropy_direction);
composition.Add(-step, composition_direction);
REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)));
boundary::apply_residual_rows(*selected, cache.get<Linearization>(), minus);
for (const int row : rows.reduced_dofs())
{
CHECK_THAT((plus[row] - minus[row]) / (2.0 * step), WithinAbs(action[row], 2.0e-9));
}
}
TEST_CASE("A continuous density field enforces isobaric rows without enthalpy", "[surface][unit][boundary]")
{
SECTION("Density from pressure and temperature")
{
check_gas_rows<fixture::DensityFormulation<>>();
}
SECTION("Pressure directly from density and temperature")
{
check_gas_rows<fixture::PressureFormulation<>>();
}
}
TEST_CASE("Uniform EOS inputs scatter their transpose contributions into one global scalar", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GasEOS model;
Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1);
Vector temperature(1), density_direction = fixture::sequence(density.Size(), 0.1, 0.4), temperature_direction(1);
temperature[0] = 1.4;
temperature_direction[0] = -0.2;
const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.uniform_temperature, temperature));
const auto direction = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density_direction), coefficients::bind(mesh.uniform_temperature, temperature_direction));
using Formulation = fixture::DensityFormulation<fixture::UniformTemperature>;
const auto selected = boundary::make_pressure_boundary<Formulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<Formulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto& accepted = cache.get<Linearization>();
Vector residual(density.Size()), action(density.Size()), density_force(density.Size()), temperature_force(1);
residual = 0.0;
action = 0.0;
density_force = 0.0;
temperature_force = 0.0;
boundary::apply_residual_rows(*selected, accepted, residual);
boundary::apply_jacobian_rows(*selected, accepted, direction, action);
const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8);
boundary::add_jacobian_transpose_rows<fixture::BoundaryDensity>(*selected, accepted, dual, density_force);
boundary::add_jacobian_transpose_rows<fixture::UniformTemperature>(*selected, accepted, dual, temperature_force);
double expected_temperature_force = 0.0;
for (const int row : selected->selected_rows->reduced_dofs())
{
CHECK_THAT(residual[row], WithinAbs(density[row] - 0.8 / temperature[0], 1.0e-13));
expected_temperature_force += 0.8 / (temperature[0] * temperature[0]) * dual[row];
}
CHECK_THAT(temperature_force[0], WithinAbs(expected_temperature_force, 1.0e-13));
CHECK_THAT(action * dual, WithinAbs(density_direction * density_force + temperature_direction * temperature_force, 2.0e-13));
}
TEST_CASE("Boundary field sampling and EOS work have distinct dependency lifetimes", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
const auto selected = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
CHECK(model.evaluations == 0);
REQUIRE(cache.try_prepare(inputs));
const int evaluations = model.evaluations;
REQUIRE(evaluations > 0);
const auto repeated = cache.try_prepare(inputs);
REQUIRE(repeated);
CHECK(repeated->computed_count() == 0);
enthalpy[selected->selected_rows->reduced_dofs()[0]] += 0.1;
const auto carrier_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state));
REQUIRE(carrier_changed);
CHECK(carrier_changed->status<boundary::keys::BoundarySamples<test::Enthalpy>>() == Status::computed);
CHECK(carrier_changed->status<boundary::keys::BoundarySamples<test::Entropy>>() == Status::reused);
CHECK(carrier_changed->status<Response>() == Status::reused);
CHECK(carrier_changed->status<Linearization>() == Status::computed);
CHECK(model.evaluations == evaluations);
entropy *= 1.1;
const auto entropy_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state));
REQUIRE(entropy_changed);
CHECK(entropy_changed->status<boundary::keys::BoundarySamples<test::Entropy>>() == Status::computed);
CHECK(entropy_changed->status<Response>() == Status::computed);
const auto pressure_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.5), state));
REQUIRE(pressure_changed);
CHECK(pressure_changed->status<Response>() == Status::computed);
CHECK(pressure_changed->status<boundary::keys::BoundarySamples<test::Entropy>>() == Status::reused);
model.scale = 1.2;
const auto eos_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.5), state));
REQUIRE(eos_changed);
CHECK(eos_changed->status<Response>() == Status::computed);
CHECK(eos_changed->status<boundary::keys::BoundarySamples<fixture::QuadraticComposition>>() == Status::reused);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<test::Entropy>() == -3.0 * model.scale);
}
TEST_CASE("Direct-pressure boundary changes reuse EOS samples when only prescribed pressure changes", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GasEOS model;
Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1);
Vector temperature = fixture::sequence(mesh.temperature.size(), 1.3, 0.2);
const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.temperature, temperature));
using Formulation = fixture::PressureFormulation<>;
const auto selected = boundary::make_pressure_boundary<Formulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<Formulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const int evaluations = model.evaluations;
const double previous = cache.get<Linearization>()[0].residual;
const auto changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.9), state));
REQUIRE(changed);
CHECK(changed->status<Response>() == Status::reused);
CHECK(changed->status<Linearization>() == Status::computed);
CHECK(model.evaluations == evaluations);
CHECK_THAT(cache.get<Linearization>()[0].residual, WithinAbs(previous - 0.1, 1.0e-15));
}
TEST_CASE("A rejected boundary candidate hides old results and permits a valid retry", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
const auto selected = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const double previous = cache.get<Linearization>()[0].residual;
const Vector original_entropy(entropy);
entropy = std::numeric_limits<double>::quiet_NaN();
const auto rejected = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state));
REQUIRE_FALSE(rejected);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
entropy = original_entropy;
REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)));
CHECK(cache.get<Linearization>()[0].residual == previous);
}
TEST_CASE("Pressure boundaries reject stale and unrelated coefficient bindings before publishing rows", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
const auto selected = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
SECTION("A same-sized binding cannot silently replace the compiled field")
{
fixture::finite_elements::TermBinding<test::Entropy, fixture::Schema> other_entropy(mesh.linear_space);
const auto other_state = coefficients::make_term_coefficients(coefficients::bind(mesh.enthalpy, enthalpy), coefficients::bind(other_entropy, entropy), coefficients::bind(mesh.composition, composition));
const auto other_inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), other_state);
CHECK_THROWS_AS(cache.try_prepare(other_inputs), std::invalid_argument);
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
}
SECTION("The candidate vector must match its finite-element coefficients")
{
Vector short_entropy(entropy.Size() - 1);
short_entropy = 0.2;
const auto other_state = composition_state(mesh, enthalpy, short_entropy, composition);
CHECK_THROWS_AS(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), other_state)), std::invalid_argument);
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(inputs));
}
SECTION("Refinement invalidates traces even if all coefficients are unchanged")
{
mesh.mesh.UniformRefinement();
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
}
SECTION("Boundary labels invalidate the selected pressure rows without a topology revision")
{
const int previous = mesh.mesh.GetBdrAttribute(0);
mesh.mesh.GetBdrElement(0)->SetAttribute(previous == 1 ? 2 : 1);
mesh.mesh.SetAttributes();
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
mesh.mesh.GetBdrElement(0)->SetAttribute(previous);
mesh.mesh.SetAttributes();
REQUIRE(cache.try_prepare(inputs));
}
SECTION("Material labels invalidate the supported coefficient binding")
{
mesh.mesh.GetElement(0)->SetAttribute(2);
mesh.mesh.SetAttributes();
CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error);
CHECK_FALSE(cache.ready());
mesh.mesh.GetElement(0)->SetAttribute(1);
mesh.mesh.SetAttributes();
REQUIRE(cache.try_prepare(inputs));
}
SECTION("Curved coordinate edits preserve canonical finite-element traces")
{
const double previous = cache.get<Linearization>()[0].residual;
const int evaluations = model.evaluations;
const Vector original_nodes(*mesh.mesh.GetNodes());
::mfem::VectorFunctionCoefficient warp(3, [](const Vector& position, Vector& mapped)
{
mapped = position;
mapped[0] += 0.04 * position[1] * position[1];
mapped[2] += 0.03 * position[0] * position[1];
});
mesh.mesh.Transform(warp);
Vector coordinate_change(*mesh.mesh.GetNodes());
coordinate_change -= original_nodes;
REQUIRE(coordinate_change.Norml2() > 0.0);
const auto reused = cache.try_prepare(inputs);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(model.evaluations == evaluations);
CHECK(cache.get<Linearization>()[0].residual == previous);
}
}
TEST_CASE("Pressure row actions reject malformed buffers before replacing any entries", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
const auto selected = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
const auto& accepted = cache.get<Linearization>();
Vector result(enthalpy.Size());
result = 3.0;
SECTION("Missing point linearizations cannot index outside the accepted batch")
{
auto incomplete = accepted;
incomplete.pop_back();
CHECK_THROWS_AS(boundary::apply_residual_rows(*selected, incomplete, result), std::invalid_argument);
CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, incomplete, state, result), std::invalid_argument);
CHECK_THROWS_AS(boundary::add_jacobian_transpose_rows<test::Enthalpy>(*selected, incomplete, enthalpy, result), std::invalid_argument);
}
SECTION("Short directions cannot index outside an input field")
{
Vector short_entropy(entropy.Size() - 1);
short_entropy = 0.2;
const auto direction = composition_state(mesh, enthalpy, short_entropy, composition);
CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, direction, result), std::invalid_argument);
}
SECTION("A nonfinite direction cannot partially replace the rows")
{
entropy = std::numeric_limits<double>::infinity();
CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, state, result), std::domain_error);
}
SECTION("Short residual output is rejected before writing")
{
result.SetSize(enthalpy.Size() - 1);
result = 3.0;
CHECK_THROWS_AS(boundary::apply_residual_rows(*selected, accepted, result), std::invalid_argument);
CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, state, result), std::invalid_argument);
CHECK_THROWS_AS(boundary::add_jacobian_transpose_rows<test::Enthalpy>(*selected, accepted, enthalpy, result), std::invalid_argument);
}
for (int index = 0; index < result.Size(); ++index)
{
CHECK(result[index] == 3.0);
}
}
TEST_CASE("Accepted boundary snapshots retain their identity until a replacement is published", "[surface][unit][boundary]")
{
fixture::Fixture mesh;
test::GeneralEOS model;
Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1);
Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3);
Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5);
const auto state = composition_state(mesh, enthalpy, entropy, composition);
using BoundaryPointer = decltype(boundary::make_pressure_boundary<fixture::CompositionFormulation>(state));
std::weak_ptr<typename BoundaryPointer::element_type> previous_boundary;
auto cache = [&]()
{
const auto original = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
previous_boundary = original;
const auto inputs = boundary::make_pressure_boundary_inputs(original, eos_input(model), pressure(0.4), state);
auto prepared = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<fixture::CompositionFormulation>(), inputs);
REQUIRE(prepared.try_prepare(inputs));
return prepared;
}();
REQUIRE_FALSE(previous_boundary.expired());
const double previous_residual = cache.get<Linearization>()[0].residual;
const auto replacement = boundary::make_pressure_boundary<fixture::CompositionFormulation>(state);
const auto inputs = boundary::make_pressure_boundary_inputs(replacement, eos_input(model), pressure(0.4), state);
const auto changed = cache.try_prepare(inputs);
REQUIRE(changed);
CHECK(changed->status<boundary::keys::BoundarySamples<test::Enthalpy>>() == Status::computed);
CHECK(changed->status<boundary::keys::BoundarySamples<test::Entropy>>() == Status::computed);
CHECK(changed->status<boundary::keys::BoundarySamples<fixture::QuadraticComposition>>() == Status::computed);
CHECK(changed->status<Response>() == Status::computed);
CHECK(changed->status<Linearization>() == Status::computed);
CHECK(cache.get<Linearization>()[0].residual == previous_residual);
CHECK(previous_boundary.expired());
}

View File

@@ -0,0 +1,132 @@
#include <limits>
#include <type_traits>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/eos/models/polytropic.hpp"
#include "serif/tests/surface/models.hpp"
namespace {
using namespace serif;
using namespace serif::tests::surface;
}
TEST_CASE("Isobaric surface prescribes pressure without selecting a carrier", "[surface][unit]") {
// MeanField: Constant Pressure Surface Prescribes Only A Pressure Quantity.
const physics::IsobaricSurface condition{quantities::PressureValue{0.03125}};
CHECK(condition.target_pressure() == quantities::PressureValue{0.03125});
CHECK(condition.descriptor().target_pressure == 0.03125);
CHECK(condition.continuation_identity() == std::array<double, 1>{0.03125});
CHECK(physics::IsobaricSurface{physics::IsobaricSurface::Parameters{.PSurf = quantities::PressureValue{0.4}}}.target_pressure() == quantities::PressureValue{0.4});
for (const double invalid : {-0.1, std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_THROWS_AS(physics::IsobaricSurface{quantities::PressureValue{invalid}}, std::invalid_argument);
}
}
TEST_CASE("Polytropic surface resolves pressure through its declared enthalpy relation", "[surface][unit]") {
// MeanField: Polytropic EOS Resolves Constant Surface Pressure Through Its Enthalpy Relation.
using Formulation = physics::PressureSurfaceFormulation<eos::models::Polytrope, BarotropicState>;
const eos::models::Polytrope equation_of_state{3.0, 0.25};
const physics::IsobaricSurface condition{quantities::PressureValue{0.03125}};
const double required = eos::evaluate<quantities::SpecificEnthalpy>(equation_of_state, condition.target_pressure()).value();
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = required;
const auto result = physics::evaluate_pressure_surface<Formulation>(equation_of_state, condition, point);
REQUIRE(result);
CHECK(result->residual == 0.0);
CHECK(result->field_derivatives.get<Enthalpy>() * -0.19 == -0.19);
CHECK(result->position_derivative == std::array<double, 3>{});
}
TEST_CASE("General pressure surface uses the local composition", "[surface][unit]") {
// MeanField: General EOS Resolves Constant Surface Pressure With Local Composition.
const GeneralEOS equation_of_state;
const physics::IsobaricSurface condition{quantities::PressureValue{0.4}};
const auto first = general_point(2.0 * 0.4 + 3.0 * 0.2 + 5.0 * 0.1, 0.2, 0.1);
const auto second = general_point(2.0 * 0.4 + 3.0 * 0.3 + 5.0 * 0.1, 0.3, 0.1);
REQUIRE(first.fields.get<Enthalpy>() != second.fields.get<Enthalpy>());
const auto first_result = physics::evaluate_pressure_surface<GeneralFormulation>(equation_of_state, condition, first);
const auto second_result = physics::evaluate_pressure_surface<GeneralFormulation>(equation_of_state, condition, second);
REQUIRE(first_result);
REQUIRE(second_result);
CHECK(first_result->residual == 0.0);
CHECK(second_result->residual == 0.0);
}
TEST_CASE("General pressure surface Jacobian retains every MeanField state dependency", "[surface][unit]") {
// MeanField: preserve its finite-difference step, direction, and 2e-9 absolute gate.
const GeneralEOS equation_of_state;
const physics::IsobaricSurface condition{quantities::PressureValue{0.4}};
constexpr double step = 1.0e-7;
const auto point = general_point(1.7, 0.2, 0.1);
const auto forward = general_point(1.7 + step * 0.7, 0.2 - step * 0.2, 0.1 + step * 0.05);
const auto backward = general_point(1.7 - step * 0.7, 0.2 + step * 0.2, 0.1 - step * 0.05);
const auto result = physics::evaluate_pressure_surface<GeneralFormulation>(equation_of_state, condition, point);
const auto plus = physics::evaluate_pressure_surface<GeneralFormulation>(equation_of_state, condition, forward);
const auto minus = physics::evaluate_pressure_surface<GeneralFormulation>(equation_of_state, condition, backward);
REQUIRE(result);
REQUIRE(plus);
REQUIRE(minus);
const double action = result->field_derivatives.get<Enthalpy>() * 0.7 + result->field_derivatives.get<Entropy>() * -0.2 + result->field_derivatives.get<Composition>() * 0.05;
CHECK(action == 0.7 - 3.0 * -0.2 - 5.0 * 0.05);
CHECK_THAT((plus->residual - minus->residual) / (2.0 * step), Catch::Matchers::WithinAbs(action, 2.0e-9));
}
TEST_CASE("Isobaric surface supports density without an enthalpy quantity or EOS relation", "[surface][unit]") {
const GasEOS equation_of_state;
for (const double pressure : {0.0, 0.4, 2.0}) {
for (const double temperature : {0.5, 1.0, 3.0}) {
CAPTURE(pressure, temperature);
const physics::IsobaricSurface condition{quantities::PressureValue{pressure}};
const auto point = gas_point(pressure / temperature, temperature);
const auto inverse = physics::evaluate_pressure_surface<GasFormulation>(equation_of_state, condition, point);
const auto direct = physics::evaluate_pressure_surface<DirectGasFormulation>(equation_of_state, condition, point);
REQUIRE(inverse);
REQUIRE(direct);
CHECK(inverse->residual == 0.0);
CHECK_THAT(direct->residual, Catch::Matchers::WithinAbs(0.0, 1.0e-15));
CHECK(inverse->field_derivatives.get<Density>() == 1.0);
CHECK(inverse->field_derivatives.get<Temperature>() == pressure / (temperature * temperature));
CHECK(direct->field_derivatives.get<Density>() == temperature);
CHECK(direct->field_derivatives.get<Temperature>() == pressure / temperature);
}
}
}
TEST_CASE("Nonlinear density and direct pressure residuals have independent finite-difference checks", "[surface][unit]") {
const GasEOS equation_of_state;
const physics::IsobaricSurface condition{quantities::PressureValue{0.8}};
constexpr double step = 1.0e-6;
const auto check = [&]<typename Formulation>() {
const auto result = physics::evaluate_pressure_surface<Formulation>(equation_of_state, condition, gas_point(0.6, 1.3));
const auto plus = physics::evaluate_pressure_surface<Formulation>(equation_of_state, condition, gas_point(0.6 + step * 0.31, 1.3 - step * 0.27));
const auto minus = physics::evaluate_pressure_surface<Formulation>(equation_of_state, condition, gas_point(0.6 - step * 0.31, 1.3 + step * 0.27));
REQUIRE(result);
REQUIRE(plus);
REQUIRE(minus);
const double action = result->field_derivatives.template get<Density>() * 0.31 - result->field_derivatives.template get<Temperature>() * 0.27;
CHECK_THAT((plus->residual - minus->residual) / (2.0 * step), Catch::Matchers::WithinAbs(action, 2.0e-9));
};
check.template operator()<GasFormulation>();
check.template operator()<DirectGasFormulation>();
}
TEST_CASE("Shared thermodynamic targets accumulate their Jacobian contributions", "[surface][unit]") {
using SharedState = dependencies::Bindings<
dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>,
dependencies::Binding<quantities::Entropy, Enthalpy>,
dependencies::Binding<ElectronFraction, Enthalpy>
>;
using Formulation = physics::PressureSurfaceFormulation<GeneralEOS, SharedState>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = 0.2;
const auto result = physics::evaluate_pressure_surface<Formulation>(GeneralEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point);
REQUIRE(result);
CHECK_THAT(result->residual, Catch::Matchers::WithinAbs(0.2 - (2.0 * 0.4 + 8.0 * 0.2), 1.0e-15));
CHECK(result->field_derivatives.get<Enthalpy>() == 1.0 - 3.0 - 5.0);
}

View File

@@ -0,0 +1,169 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/tests/surface/contexts.hpp"
namespace {
using namespace serif::tests::surface;
using Status = dependencies::PreparationStatus;
using Response = physics::keys::EOSResponse;
using Linearization = physics::keys::Linearization;
using OffsetFormulation = physics::PressureSurfaceFormulation<OffsetEOS, BarotropicState>;
}
TEST_CASE("Surface graph construction performs no physics and pressure-only response is broadcast", "[surface][unit]") {
OffsetEOS model;
const auto context = offset_context(model, 0.4, {0.7, 1.1, 1.4});
auto graph = physics::make_pressure_surface_graph<OffsetEOS, BarotropicState>();
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(std::move(graph), context);
CHECK(model.evaluations == 0);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
const auto prepared = cache.try_prepare(context);
REQUIRE(prepared);
CHECK(prepared->computed_count() == 2);
CHECK(model.evaluations == 1);
REQUIRE(cache.get<Response>().size() == 1);
const auto& rows = cache.get<Linearization>();
REQUIRE(rows.size() == 3);
for (std::size_t index = 0; index < rows.size(); ++index) {
const double expected = context.get<Enthalpy>().values[index] - (0.3 + 2.0 * 0.4);
CHECK(rows[index].residual == expected);
CHECK(rows[index].field_derivatives.get<Enthalpy>() == 1.0);
}
}
TEST_CASE("Surface source changes refresh only the calculations that depend on them", "[surface][unit]") {
OffsetEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<OffsetFormulation>(), offset_context(model, 0.0, {0.3, 0.7}));
REQUIRE(cache.try_prepare(offset_context(model, 0.0, {0.3, 0.7})));
CHECK(cache.get<Linearization>()[0].residual == 0.0);
auto report = cache.try_prepare(offset_context(model, 0.0, {0.3, 0.7}));
REQUIRE(report);
CHECK(report->computed_count() == 0);
CHECK(model.evaluations == 1);
report = cache.try_prepare(offset_context(model, 0.0, {0.4, 0.9}));
REQUIRE(report);
CHECK(report->status<Response>() == Status::reused);
CHECK(report->status<Linearization>() == Status::computed);
CHECK(model.evaluations == 1);
report = cache.try_prepare(offset_context(model, 0.2, {0.4, 0.9}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(model.evaluations == 2);
model.offset = 0.6;
report = cache.try_prepare(offset_context(model, 0.2, {0.4, 0.9}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(cache.get<Linearization>()[0].residual == 0.4 - (0.6 + 2.0 * 0.2));
OffsetEOS replacement{.offset = model.offset};
report = cache.try_prepare(offset_context(replacement, 0.2, {0.4, 0.9}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(replacement.evaluations == 1);
report = cache.try_prepare(offset_context(replacement, 0.2, {0.4, 0.9}), {}, dependencies::PreparationMode::rebuild);
REQUIRE(report);
CHECK(report->computed_count() == 2);
}
TEST_CASE("Surface composition response caches values and all partial derivatives together", "[surface][unit]") {
GeneralEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GeneralFormulation>(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}));
REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})));
CHECK(model.evaluations == 2);
CHECK(model.partials == 4);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Entropy>() == -3.0);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Composition>() == -5.0);
auto report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.2, 0.3}, {0.1, 0.2}));
REQUIRE(report);
CHECK(report->status<Response>() == Status::reused);
CHECK(model.partials == 4);
report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.1, 0.2}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(model.evaluations == 4);
report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.3, 0.2}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(model.evaluations == 6);
model.scale = 2.0;
REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.3, 0.2})));
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Entropy>() == -6.0);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Composition>() == -10.0);
}
TEST_CASE("Density surface graph refreshes nonlinear temperature derivatives", "[surface][unit]") {
GasEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GasFormulation>(), gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0}));
REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0})));
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Temperature>() == 0.8);
CHECK(cache.get<Linearization>()[1].field_derivatives.get<Temperature>() == 0.2);
const auto report = cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {2.0, 4.0}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Temperature>() == 0.2);
CHECK(cache.get<Linearization>()[1].field_derivatives.get<Temperature>() == 0.05);
}
TEST_CASE("Direct pressure surface reuses EOS work when prescribed pressure changes", "[surface][unit]") {
GasEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<DirectGasFormulation>(), gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0}));
REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0})));
const auto report = cache.try_prepare(gas_context(model, 0.9, {0.4, 0.6}, {1.0, 2.0}));
REQUIRE(report);
CHECK(report->status<Response>() == Status::reused);
CHECK(report->status<Linearization>() == Status::computed);
CHECK(model.evaluations == 2);
CHECK(cache.get<Linearization>()[0].residual == 0.4 * 1.0 - 0.9);
CHECK(cache.get<Linearization>()[1].field_derivatives.get<Density>() == 2.0);
CHECK(cache.get<Linearization>()[1].field_derivatives.get<Temperature>() == 0.6);
}
TEST_CASE("Surface cache requested results and moves retain the ordinary dependency lifecycle", "[surface][unit]") {
OffsetEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<OffsetFormulation>(), offset_context(model, 0.0, {0.5}));
const auto report = cache.try_prepare(offset_context(model, 0.0, {std::numeric_limits<double>::quiet_NaN()}), dependencies::RequestedResults<Response>{});
REQUIRE(report);
CHECK(report->computed_count() == 1);
CHECK_FALSE(cache.contains<Linearization>());
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
REQUIRE(cache.try_prepare(offset_context(model, 0.0, {0.5})));
auto moved = std::move(cache);
CHECK_FALSE(cache.ready());
REQUIRE(moved.ready());
CHECK(moved.get<Linearization>()[0].residual == 0.5 - 0.3);
}
TEST_CASE("Surface batch with aliased quantities gathers each term once and sums every derivative", "[surface][unit]") {
using SharedState = dependencies::Bindings<
dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>,
dependencies::Binding<quantities::Entropy, Enthalpy>,
dependencies::Binding<ElectronFraction, Enthalpy>
>;
GeneralEOS model;
const auto context = 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{0.4}}),
physics::boundary_values<Enthalpy>({0.2, 0.3})
);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GeneralEOS, SharedState>(), context);
REQUIRE(cache.try_prepare(context));
REQUIRE(cache.get<Linearization>().size() == 2);
CHECK(cache.get<Linearization>()[0].field_derivatives.get<Enthalpy>() == -7.0);
CHECK(cache.get<Linearization>()[1].field_derivatives.get<Enthalpy>() == -7.0);
}

View File

@@ -0,0 +1,8 @@
surface_test_sources = files(
'automatic.cpp',
'boundary.cpp',
'compilation.cpp',
'computations.cpp',
'relations.cpp',
'rejection.cpp',
)

138
tests/surface/rejection.cpp Normal file
View File

@@ -0,0 +1,138 @@
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/surface/contexts.hpp"
namespace {
using namespace serif;
using namespace serif::tests::surface;
using Linearization = physics::keys::Linearization;
struct NonFinitePartialEOS : GeneralEOS {
using GeneralEOS::partial_derivative;
/// An independently bad partial must reject the response even when the EOS value is finite.
[[nodiscard]] quantities::PartialDerivative<quantities::SpecificEnthalpy, ElectronFraction> partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo<ElectronFraction>, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue<ElectronFraction>) const {
return quantities::PartialDerivative<quantities::SpecificEnthalpy, ElectronFraction>{std::numeric_limits<double>::infinity()};
}
};
}
TEST_CASE("Surface point validation rejects every required non-finite quantity before the EOS", "[surface][unit]") {
GeneralEOS model;
const physics::IsobaricSurface condition{quantities::PressureValue{0.4}};
for (const double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
for (const auto& point : {general_point(invalid, 0.2, 0.1), general_point(1.7, invalid, 0.1), general_point(1.7, 0.2, invalid)}) {
const auto result = physics::evaluate_pressure_surface<GeneralFormulation>(model, condition, point);
REQUIRE_FALSE(result);
CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_state);
}
}
CHECK(model.evaluations == 0);
}
TEST_CASE("Surface rejects non-finite EOS results and partials without clipping them", "[surface][unit]") {
const physics::IsobaricSurface condition{quantities::PressureValue{0.4}};
SECTION("EOS value") {
const OffsetEOS model{.offset = std::numeric_limits<double>::infinity()};
using Formulation = physics::PressureSurfaceFormulation<OffsetEOS, BarotropicState>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = 0.4;
const auto result = physics::evaluate_pressure_surface<Formulation>(model, condition, point);
REQUIRE_FALSE(result);
CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_eos_response);
}
SECTION("EOS partial") {
using Formulation = physics::PressureSurfaceFormulation<NonFinitePartialEOS, GeneralState>;
const auto result = physics::evaluate_pressure_surface<Formulation>(NonFinitePartialEOS{}, condition, general_point(1.7, 0.2, 0.1));
REQUIRE_FALSE(result);
CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_eos_response);
}
SECTION("Residual subtraction overflows after a finite EOS result") {
const OffsetEOS model{.offset = -std::numeric_limits<double>::max()};
using Formulation = physics::PressureSurfaceFormulation<OffsetEOS, BarotropicState>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = std::numeric_limits<double>::max();
const auto result = physics::evaluate_pressure_surface<Formulation>(model, condition, point);
REQUIRE_FALSE(result);
CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_linearization);
}
SECTION("Finite negative carrier values retain their declared residual") {
using Formulation = physics::PressureSurfaceFormulation<OffsetEOS, BarotropicState>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = -0.2;
const auto result = physics::evaluate_pressure_surface<Formulation>(OffsetEOS{}, condition, point);
REQUIRE(result);
CHECK(result->residual == -0.2 - (0.3 + 2.0 * 0.4));
}
}
TEST_CASE("A rejected surface batch hides all previous results and permits a valid retry", "[surface][unit]") {
GeneralEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GeneralFormulation>(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}));
REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})));
auto bad_enthalpy = std::vector<double>{1.7, 1.9};
auto bad_entropy = std::vector<double>{0.2, 0.3};
SECTION("Carrier rejection after a reusable EOS response") {
bad_enthalpy[1] = std::numeric_limits<double>::quiet_NaN();
}
SECTION("EOS rejection after the first sample has been evaluated") {
bad_entropy[1] = std::numeric_limits<double>::infinity();
}
const auto rejected = cache.try_prepare(general_context(model, 0.4, bad_enthalpy, bad_entropy, {0.1, 0.2}));
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.sample == 1);
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == 1);
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
CHECK_THROWS_AS(cache.get<physics::keys::EOSResponse>(), std::logic_error);
REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.2, 0.3}, {0.1, 0.2})));
CHECK(cache.preparation_count() == 2);
REQUIRE(cache.get<Linearization>().size() == 2);
CHECK(cache.get<Linearization>()[0].residual == 1.8 - (2.0 * 0.4 + 3.0 * 0.2 + 5.0 * 0.1));
}
TEST_CASE("Surface graph rejects mismatched columns instead of broadcasting a state-dependent relation", "[surface][unit]") {
GeneralEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GeneralFormulation>(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}));
SECTION("EOS columns disagree") {
const auto result = cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1}));
REQUIRE_FALSE(result);
CHECK(result.error().error.reason == physics::SurfaceFailureReason::inconsistent_sample_count);
CHECK(model.evaluations == 0);
}
SECTION("A singleton state-dependent response is not a constant") {
const auto result = cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2}, {0.1}));
REQUIRE_FALSE(result);
CHECK(result.error().error.reason == physics::SurfaceFailureReason::inconsistent_sample_count);
}
SECTION("Empty EOS columns") {
const auto result = cache.try_prepare(general_context(model, 0.4, {}, {}, {}));
REQUIRE_FALSE(result);
CHECK(result.error().error.reason == physics::SurfaceFailureReason::empty_surface);
}
CHECK_FALSE(cache.ready());
REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})));
}
TEST_CASE("A pressure-only surface rejects an empty carrier batch", "[surface][unit]") {
OffsetEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<OffsetEOS, BarotropicState>(), offset_context(model, 0.0, {}));
const auto result = cache.try_prepare(offset_context(model, 0.0, {}));
REQUIRE_FALSE(result);
CHECK(result.error().error.reason == physics::SurfaceFailureReason::empty_surface);
CHECK_FALSE(cache.ready());
}
TEST_CASE("An existing EOS domain exception leaves the surface cache unavailable until recovery", "[surface][unit]") {
GasEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GasFormulation>(), gas_context(model, 0.8, {0.4}, {1.0}));
REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4}, {1.0})));
CHECK_THROWS_AS(cache.try_prepare(gas_context(model, 0.8, {0.4}, {0.0})), eos::EOSEvaluationError);
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<Linearization>(), std::logic_error);
REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4}, {2.0})));
CHECK(cache.get<Linearization>()[0].residual == 0.0);
}

124
tests/surface/relations.cpp Normal file
View File

@@ -0,0 +1,124 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/geometry/surface/evaluation.hpp"
#include "serif/tests/surface/contexts.hpp"
namespace {
using namespace serif;
using namespace serif::tests::surface;
using PressureLast = eos::relations::Relation<quantities::SpecificEnthalpy, quantities::Entropy, quantities::Pressure>;
struct PressureLastEOS {
using Relations = eos::relations::RelationCatalog<PressureLast>;
/// Distinct coefficients detect an accidental permutation of pressure and state arguments.
[[nodiscard]] quantities::SpecificEnthalpyValue evaluate(PressureLast, const quantities::EntropyValue entropy, const quantities::PressureValue pressure) const {
return quantities::SpecificEnthalpyValue{3.0 * entropy.value() + 2.0 * pressure.value()};
}
/// Pressure is prescribed, so the only partial needed is the entropy derivative.
[[nodiscard]] quantities::PartialDerivative<quantities::SpecificEnthalpy, quantities::Entropy> partial_derivative(PressureLast, quantities::WithRespectTo<quantities::Entropy>, quantities::EntropyValue, quantities::PressureValue) const {
return quantities::PartialDerivative<quantities::SpecificEnthalpy, quantities::Entropy>{3.0};
}
};
using CarrierInput = eos::relations::Relation<quantities::MassDensity, quantities::Pressure, quantities::MassDensity>;
struct CarrierInputEOS {
using Relations = eos::relations::RelationCatalog<CarrierInput>;
/// A manufactured implicit-carrier relation checks the identity contribution against a nonzero EOS carrier partial.
[[nodiscard]] quantities::DensityValue evaluate(CarrierInput, const quantities::PressureValue pressure, const quantities::DensityValue density) const {
return quantities::DensityValue{pressure.value() + 0.5 * density.value()};
}
/// The final residual's density partial must be 1 - 0.5.
[[nodiscard]] quantities::PartialDerivative<quantities::MassDensity, quantities::MassDensity> partial_derivative(CarrierInput, quantities::WithRespectTo<quantities::MassDensity>, quantities::PressureValue, quantities::DensityValue) const {
return quantities::PartialDerivative<quantities::MassDensity, quantities::MassDensity>{0.5};
}
};
struct ForwardOnlyEOS {
using Relations = eos::relations::RelationCatalog<PressureFromDensityAndTemperature>;
/// This model exposes only pressure evaluation; there is no callable density inverse.
[[nodiscard]] quantities::PressureValue evaluate(PressureFromDensityAndTemperature, const quantities::DensityValue density, const quantities::TemperatureValue temperature) const {
return quantities::PressureValue{density.value() * temperature.value()};
}
/// The row's density derivative comes directly from the forward relation.
[[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()};
}
/// The temperature coupling is required even though density supplies the replaced row.
[[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()};
}
};
}
TEST_CASE("Surface EOS arguments follow relation order even when pressure is last", "[surface][unit]") {
using State = dependencies::Bindings<dependencies::Binding<quantities::Entropy, Entropy>, dependencies::Binding<quantities::SpecificEnthalpy, Enthalpy>>;
using Formulation = physics::PressureSurfaceFormulation<PressureLastEOS, State>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = 1.7;
point.fields.get<Entropy>() = 0.2;
const auto result = physics::evaluate_pressure_surface<Formulation>(PressureLastEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point);
REQUIRE(result);
CHECK(result->residual == 1.7 - (3.0 * 0.2 + 2.0 * 0.4));
CHECK(result->field_derivatives.get<Entropy>() == -3.0);
}
TEST_CASE("A carrier appearing among EOS inputs retains both derivative contributions", "[surface][unit]") {
using State = dependencies::Bindings<dependencies::Binding<quantities::MassDensity, Density>>;
using Formulation = physics::PressureSurfaceFormulation<CarrierInputEOS, State>;
physics::SurfacePoint<Formulation> point{};
point.fields.get<Density>() = 0.8;
const auto result = physics::evaluate_pressure_surface<Formulation>(CarrierInputEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point);
REQUIRE(result);
CHECK(result->residual == 0.0);
CHECK(result->field_derivatives.get<Density>() == 0.5);
}
TEST_CASE("A forward-only EOS can impose the same isobaric condition without inversion", "[surface][unit]") {
using Formulation = physics::DirectPressureSurfaceFormulation<ForwardOnlyEOS, GasState, quantities::MassDensity, PressureFromDensityAndTemperature>;
const auto result = physics::evaluate_pressure_surface<Formulation>(ForwardOnlyEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.8}}, gas_point(0.4, 2.0));
REQUIRE(result);
CHECK(result->residual == 0.0);
CHECK(result->field_derivatives.get<Density>() == 2.0);
CHECK(result->field_derivatives.get<Temperature>() == 0.4);
}
TEST_CASE("Pressure-only surface relations retain their analytic residual and derivative", "[surface][unit]") {
using Formulation = physics::PressureSurfaceFormulation<OffsetEOS, BarotropicState>;
const OffsetEOS model;
for (const double pressure : {0.0, 0.03125, 0.4}) {
const physics::IsobaricSurface condition{quantities::PressureValue{pressure}};
for (const double enthalpy : {-0.2, 0.0, 0.7}) {
physics::SurfacePoint<Formulation> point{};
point.fields.get<Enthalpy>() = enthalpy;
const auto actual = physics::evaluate_pressure_surface<Formulation>(model, condition, point);
REQUIRE(actual);
CHECK(actual->residual == enthalpy - (model.offset + 2.0 * pressure));
CHECK(actual->field_derivatives.get<Enthalpy>() == 1.0);
CHECK(actual->position_derivative == discretization::forms::VectorValue{});
}
}
}
TEST_CASE("Equal EOS values cannot conceal changed surface derivatives", "[surface][unit]") {
GeneralEOS model;
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(physics::make_pressure_surface_graph<GeneralFormulation>(), general_context(model, 0.0, {0.4}, {0.0}, {0.0}));
REQUIRE(cache.try_prepare(general_context(model, 0.0, {0.4}, {0.0}, {0.0})));
CHECK(cache.get<physics::keys::EOSResponse>()[0].value.value() == 0.0);
model.scale = 2.0;
const auto report = cache.try_prepare(general_context(model, 0.0, {0.4}, {0.0}, {0.0}));
REQUIRE(report);
CHECK(report->computed_count() == 2);
CHECK(cache.get<physics::keys::EOSResponse>()[0].value.value() == 0.0);
CHECK(cache.get<physics::keys::Linearization>()[0].field_derivatives.get<Entropy>() == -6.0);
}