Files
MeanField_SERiF/tests/serialize/top.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

268 lines
14 KiB
C++

#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <limits>
#include <numbers>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/utils/serialize/top.hpp"
namespace {
namespace serialization = serif::utils::serialize;
constexpr double pi = std::numbers::pi;
const double monopole_basis = 1/std::sqrt(4*pi);
struct Directory {
std::filesystem::path path;
Directory() {
static std::atomic<unsigned> next{0};
path = std::filesystem::temp_directory_path()/("serif-top-test-"+
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+"-"+std::to_string(next++));
}
~Directory() { std::error_code error; std::filesystem::remove_all(path, error); }
};
serialization::TopPolytropeSource sphere() {
return {.polytropic_index=1., .gravitational_constant=1., .mass=4./pi, .angular_velocity=0.,
.central_density=1., .central_enthalpy=4./pi,
.surface_radius=[](double, double)->serialization::Result<double> { return 1.; },
.enthalpy=[](double radius, double, double)->serialization::Result<double> {
if (radius==0.) { return 4./pi; }
if (radius==1.) { return 0.; }
return (4./pi)*std::sin(pi*radius)/(pi*radius);
}};
}
serialization::TopExportOptions small_options() {
return {.radial_points=32, .maximum_degree=4, .angular_points=12, .azimuthal_points=4};
}
struct Data {
int nr{}, degree{};
std::vector<double> scales, surface;
std::vector<std::vector<double>> h, hz, hzz;
};
Data read(const std::filesystem::path& path) {
Data result;
std::ifstream scales(path/"lambda_eps");
double value;
while (scales >> value) { result.scales.push_back(value); }
REQUIRE(result.scales.size()==9);
std::ifstream surface(path/"domains_boundaries");
surface >> result.degree;
for (int l=0; l<=result.degree; ++l) { surface >> value; CHECK(value==0.); }
for (int l=0; l<=result.degree; ++l) { surface >> value; result.surface.push_back(value); }
std::ifstream fields(path/"enthalpy_et_der");
int count;
fields >> result.nr >> count;
REQUIRE(count==result.degree+1);
std::string line;
std::getline(fields, line); std::getline(fields, line);
int index;
for (int k=0; k<=result.degree/2; ++k) {
fields >> index; REQUIRE(index==k+1);
for (int i=0; i<result.nr; ++i) { fields >> value; }
}
auto block = [&]() {
std::getline(fields, line); std::getline(fields, line);
std::vector<std::vector<double>> values(result.degree+1, std::vector<double>(result.nr));
for (int l=0; l<=result.degree; l+=2) {
fields >> index; REQUIRE(index==l);
for (double& sample : values[l]) { fields >> sample; REQUIRE(std::isfinite(sample)); }
}
return values;
};
result.h=block(); result.hz=block(); result.hzz=block();
REQUIRE(fields.good());
return result;
}
bool close(double actual, double expected, double tolerance) {
return std::abs(actual-expected)<=tolerance*std::max(1., std::abs(expected));
}
}
TEST_CASE("TOP export reproduces the analytic n1 sphere and Chebyshev derivatives", "[serialize][top]") {
const Directory directory;
const auto result=serialization::export_top(directory.path, sphere(), small_options());
if (!result) { INFO(result.error().path << ": " << result.error().message); }
REQUIRE(result);
CHECK(close(result->lambda, pi*pi, 2e-14));
CHECK(close(result->alpha, pi*pi/3, 2e-14));
CHECK(close(result->angular_frequency_unit, std::sqrt(4*pi), 2e-14));
CHECK(std::abs(result->flattening)<1e-14);
CHECK(result->maximum_relative_surface_projection_error<1e-13);
CHECK(result->minimum_enthalpy==0.);
const auto data=read(directory.path);
CHECK(close(data.surface[0], 1/monopole_basis, 2e-14));
CHECK(close(data.h[0][0]*monopole_basis, 1., 2e-14));
CHECK(data.h[0].back()==0.);
CHECK(std::abs(data.hz[0][0]*monopole_basis)<1e-11);
CHECK(close(data.hzz[0][0]*monopole_basis, -pi*pi/3, 1e-8));
CHECK(close(data.hz[0].back()*monopole_basis, -1., 1e-11));
CHECK(close(data.hzz[0].back()*monopole_basis, 2., 1e-8));
for (int i=0; i<data.nr; ++i) {
const double z=.5*(1-std::cos(pi*i/(data.nr-1)));
const double expected=z==0. ? 1. : std::sin(pi*z)/(pi*z);
CHECK(close(data.h[0][i]*monopole_basis, expected, 2e-14));
for (int l=2; l<=data.degree; l+=2) { CHECK(std::abs(data.h[l][i])<1e-13); }
}
CHECK(std::filesystem::exists(directory.path/"metadata.txt"));
}
TEST_CASE("TOP export projects distorted geometry and derivatives at fixed surface coordinate", "[serialize][top]") {
const Directory directory;
auto radius=[](double mu) { return 2*(1-.12*(3*mu*mu-1)/2); };
serialization::TopPolytropeSource source{
.polytropic_index=1., .gravitational_constant=2., .mass=7., .angular_velocity=.31,
.central_density=3., .central_enthalpy=5.,
.surface_radius=[&](double mu, double)->serialization::Result<double> { return radius(mu); },
.enthalpy=[&](double r, double mu, double)->serialization::Result<double> {
const double surface=radius(mu);
if (r==0.) { return 5.; }
if (r==surface) { return 0.; }
double lower=0., upper=1.;
for (int step=0; step<60; ++step) {
const double z=(lower+upper)/2, a=(5*z*z*z-3*std::pow(z,5))/2;
if (1.76*z+a*(surface-1.76)<r) { lower=z; } else { upper=z; }
}
const double z=(lower+upper)/2;
return 5*(1-z*z)*(1+.1*z*z*(3*mu*mu-1)/2);
}};
const auto result=serialization::export_top(directory.path, source, small_options());
if (!result) { INFO(result.error().message); }
REQUIRE(result);
CHECK(close(result->source_equatorial_radius, 2.12, 1e-14));
CHECK(close(result->equatorial_radius, 2.12, 1e-14));
CHECK(close(result->polar_radius, 1.76, 1e-14));
CHECK(result->maximum_relative_surface_projection_error<1e-13);
CHECK(close(result->rotation_over_kepler/std::sqrt(3*result->alpha), .31/std::sqrt(24*pi), 1e-14));
const auto data=read(directory.path);
CHECK(close(data.surface[0], 2/(2.12*monopole_basis), 1e-13));
CHECK(close(data.surface[2], -.24*std::sqrt(4*pi/5)/2.12, 1e-13));
for (int i=0; i<data.nr; ++i) {
const double z=.5*(1-std::cos(pi*i/(data.nr-1)));
CHECK(close(data.h[0][i]*monopole_basis, 1-z*z, 1e-12));
CHECK(close(data.h[2][i]/std::sqrt(4*pi/5), .1*z*z*(1-z*z), 1e-12));
CHECK(close(data.hz[0][i]*monopole_basis, -2*z, 1e-10));
CHECK(close(data.hz[2][i]/std::sqrt(4*pi/5), .1*(2*z-4*z*z*z), 1e-10));
CHECK(close(data.hzz[0][i]*monopole_basis, -2., 2e-8));
CHECK(close(data.hzz[2][i]/std::sqrt(4*pi/5), .1*(2-12*z*z), 2e-8));
}
}
TEST_CASE("TOP dimensionless outputs are invariant under consistent physical unit changes", "[serialize][top]") {
const Directory first, second;
auto source=sphere(); source.angular_velocity=.2;
auto scaled=source;
scaled.gravitational_constant*=2;
scaled.central_density*=5;
scaled.mass*=135;
scaled.central_enthalpy*=90;
scaled.angular_velocity*=std::sqrt(10.);
scaled.surface_radius=[](double, double)->serialization::Result<double> { return 3.; };
scaled.enthalpy=[source](double r, double mu, double phi)->serialization::Result<double> { return 90*source.enthalpy(r/3,mu,phi).value(); };
const auto a=serialization::export_top(first.path,source,small_options());
const auto b=serialization::export_top(second.path,scaled,small_options());
REQUIRE(a); REQUIRE(b);
CHECK(close(a->lambda,b->lambda,1e-13)); CHECK(close(a->alpha,b->alpha,1e-13));
CHECK(close(a->rotation,b->rotation,1e-13)); CHECK(close(a->rotation_over_kepler,b->rotation_over_kepler,1e-13));
CHECK(close(b->angular_frequency_unit/a->angular_frequency_unit,std::sqrt(10.),1e-13));
}
TEST_CASE("TOP rejects unsupported backgrounds and reports sampler failures before publication", "[serialize][top]") {
const Directory directory;
auto source=sphere(); auto options=small_options();
SECTION("odd angular degree") { options.maximum_degree=3; }
SECTION("undersampled angular projection") { options.angular_points=4; }
SECTION("invalid resolution") { options.radial_points=1; }
SECTION("invalid tolerance") { options.relative_surface_tolerance=-1; }
SECTION("missing callback") { source.enthalpy={}; }
SECTION("invalid units") { source.gravitational_constant=0; }
SECTION("singular vacuum index") { source.polytropic_index=.5; }
SECTION("nonfinite rotation") { source.angular_velocity=std::numeric_limits<double>::quiet_NaN(); }
SECTION("invalid surface") { source.surface_radius=[](double,double)->serialization::Result<double> { return -1.; }; }
SECTION("nonaxisymmetric surface") { source.surface_radius=[](double,double phi)->serialization::Result<double> { return 1+.1*std::cos(phi); }; }
SECTION("north south asymmetry") { source.surface_radius=[](double mu,double)->serialization::Result<double> { return 1+.1*mu; }; }
SECTION("surface projection loss") { source.surface_radius=[](double mu,double)->serialization::Result<double> { return 1+.1*(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16; }; }
SECTION("degenerate vacuum surface slope") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return 4/pi*std::pow(1-r*r,2); }; }
SECTION("finite pressure surface") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return 4/pi; }; }
SECTION("negative interior") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return r==0. ? 4/pi : -.01; }; }
SECTION("zero interior") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return r==0. ? 4/pi : 0.; }; }
SECTION("nonaxisymmetric enthalpy") { source.enthalpy=[](double r,double,double phi)->serialization::Result<double> { return 4/pi*(1-r*r)*(1+.1*r*std::cos(phi)); }; }
SECTION("nonfinite enthalpy") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return std::numeric_limits<double>::infinity(); }; }
SECTION("sampler error path") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return std::unexpected(serialization::Error{"element/7","Sample unavailable"}); }; }
const auto result=serialization::export_top(directory.path,source,options);
REQUIRE_FALSE(result);
CHECK_FALSE(result.error().path.empty());
CHECK_FALSE(std::filesystem::exists(directory.path));
}
TEST_CASE("TOP export never replaces destinations including a destination created during sampling", "[serialize][top]") {
const Directory directory;
auto source=sphere();
SECTION("existing empty directory") { std::filesystem::create_directory(directory.path); }
SECTION("existing nonempty directory") { std::filesystem::create_directory(directory.path); std::ofstream(directory.path/"preserved") << "original"; }
SECTION("existing file") { std::ofstream(directory.path) << "original"; }
SECTION("publication race") {
source.roundoff_density_clamps=[&]() {
std::filesystem::create_directory(directory.path);
std::ofstream(directory.path/"preserved") << "original";
return std::size_t{0};
};
}
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE_FALSE(result);
CHECK(std::filesystem::exists(directory.path));
CHECK_FALSE(std::filesystem::exists(directory.path/"lambda_eps"));
if (std::filesystem::exists(directory.path/"preserved")) {
std::ifstream input(directory.path/"preserved"); std::string text; input >> text; CHECK(text=="original");
}
bool temporary_remains=false;
for (const auto& entry : std::filesystem::directory_iterator(directory.path.parent_path())) {
temporary_remains |= entry.path().filename().string().starts_with("."+directory.path.filename().string()+".top-tmp-");
}
CHECK_FALSE(temporary_remains);
}
TEST_CASE("TOP records explicit adapter roundoff corrections", "[serialize][top]") {
const Directory directory;
auto source=sphere(); source.roundoff_density_clamps=[] { return std::size_t{3}; };
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE(result); CHECK(result->roundoff_density_clamps==3);
}
TEST_CASE("TOP records and removes only a tolerated surface defect", "[serialize][top]") {
const Directory directory;
auto source=sphere();
const auto original=source.enthalpy;
source.enthalpy=[original](double r,double mu,double phi)->serialization::Result<double> {
if (r==1.) { return (4/pi)*1e-8; }
return original(r,mu,phi);
};
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE(result);
CHECK(close(result->maximum_relative_surface_enthalpy,1e-8,1e-15));
const auto data=read(directory.path);
for (int l=0;l<=data.degree;l+=2) { CHECK(data.h[l].back()==0.); }
}
TEST_CASE("TOP separates even enthalpy truncation from symmetry error", "[serialize][top]") {
const Directory truncated, resolved;
auto source=sphere();
source.enthalpy=[](double r,double mu,double)->serialization::Result<double> {
const double p6=(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16;
return (4/pi)*(1-r*r)*(1+.1*std::pow(r,6)*p6);
};
auto options=small_options();
const auto low=serialization::export_top(truncated.path,source,options);
REQUIRE(low);
CHECK(low->maximum_relative_enthalpy_symmetry_error<1e-14);
CHECK(low->maximum_relative_enthalpy_projection_error>1e-3);
options.maximum_degree=6;
const auto high=serialization::export_top(resolved.path,source,options);
REQUIRE(high);
CHECK(high->maximum_relative_enthalpy_symmetry_error<1e-14);
CHECK(high->maximum_relative_enthalpy_projection_error<1e-13);
}