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:
329
tests/serialize/archive.cpp
Normal file
329
tests/serialize/archive.cpp
Normal file
@@ -0,0 +1,329 @@
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <locale>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "serif/config.hpp"
|
||||
#include "serif/utils/serialize/archive.hpp"
|
||||
#include "serif/utils/serialize/solver.hpp"
|
||||
|
||||
namespace {
|
||||
namespace serialization = serif::utils::serialize;
|
||||
|
||||
/** @brief Remove a test archive on success and on an assertion failure. */
|
||||
struct File {
|
||||
std::filesystem::path path = std::filesystem::temp_directory_path() /
|
||||
("serif-archive-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".serif");
|
||||
/** @brief Keep all regression artifacts confined to their temporary lifetime. */
|
||||
~File() { std::error_code error; std::filesystem::remove(path, error); }
|
||||
};
|
||||
|
||||
/** @brief Read raw bytes so malformed archives can be tested independently of the writer. */
|
||||
std::string bytes(const File& file) {
|
||||
std::ifstream input(file.path, std::ios::binary);
|
||||
return {std::istreambuf_iterator<char>{input}, std::istreambuf_iterator<char>{}};
|
||||
}
|
||||
|
||||
/** @brief Deliberately replace an archive with a malformed test fixture. */
|
||||
void replace(const File& file, std::string_view value) {
|
||||
std::ofstream output(file.path, std::ios::binary | std::ios::trunc);
|
||||
output.write(value.data(), value.size());
|
||||
REQUIRE(output.good());
|
||||
}
|
||||
|
||||
/** @brief Change one public header field while preserving its fixed byte extent. */
|
||||
std::string header_field(std::string original, std::string_view key, std::string_view value) {
|
||||
const auto start = original.find(std::string{key} + ' ') + key.size() + 1;
|
||||
const auto end = original.find('\n', start);
|
||||
std::string header = original.substr(0, original.find("END_HEADER\n") + 11);
|
||||
header.replace(start, end - start, value);
|
||||
REQUIRE(header.size() < 512);
|
||||
header.resize(512, ' ');
|
||||
header.back() = '\n';
|
||||
return header + original.substr(512);
|
||||
}
|
||||
|
||||
/** @brief Repair the ledger checksum after an intentional structurally invalid edit. */
|
||||
std::string ledger_checksum(std::string original) {
|
||||
const std::size_t start = original.find("ledger_bytes ") + 13;
|
||||
const std::size_t count = std::stoull(original.substr(start, original.find('\n', start) - start));
|
||||
return header_field(original, "ledger_checksum", serialization::fingerprint(std::string_view{original}.substr(512, count)));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Both archive encodings preserve every binary64 bit pattern", "[serialize][archive]") {
|
||||
std::mt19937_64 random{271828};
|
||||
std::vector<double> values{0.0, -0.0, 1.0, -1.0, std::numeric_limits<double>::denorm_min(),
|
||||
std::numeric_limits<double>::min(), std::numeric_limits<double>::max(), std::numeric_limits<double>::infinity(),
|
||||
-std::numeric_limits<double>::infinity(), std::bit_cast<double>(0x7ff8123456789abcULL)};
|
||||
for (int index = 0; index < 2000; ++index) {
|
||||
values.push_back(std::bit_cast<double>(random()));
|
||||
}
|
||||
for (serialization::Format format : {serialization::Format::binary, serialization::Format::text}) {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("coefficients", values)), {.format = format}));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
CHECK(reader->format() == format);
|
||||
const auto restored = reader->read<std::vector<double>>("coefficients");
|
||||
REQUIRE(restored);
|
||||
REQUIRE(restored->size() == values.size());
|
||||
for (std::size_t index = 0; index < values.size(); ++index) {
|
||||
CHECK(std::bit_cast<std::uint64_t>((*restored)[index]) == std::bit_cast<std::uint64_t>(values[index]));
|
||||
}
|
||||
CHECK(reader->validate());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Archives carry provenance annotations and an immediately inspectable ledger", "[serialize][archive]") {
|
||||
const File file;
|
||||
const auto contents = serialization::pack(serialization::named("surface", serialization::object("harmonics",
|
||||
serialization::field("coefficients", std::vector<double>{1.0, 2.0}), serialization::field("description", "ξ, θ\n\"quoted\"\\"))));
|
||||
REQUIRE(serialization::write(file.path, contents, {.format = serialization::Format::text,
|
||||
.author = "Emily", .comment = "Restart after Newton step 4\nRetain the full surface.", .metadata = {{"study", "ESTER comparison"}}}));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
REQUIRE(reader->contains("surface/coefficients"));
|
||||
CHECK_FALSE(reader->contains("unprovided_solver_options"));
|
||||
CHECK(reader->serif_version() == SERIF_VERSION);
|
||||
CHECK(reader->read<std::string>("provenance/author") == "Emily");
|
||||
CHECK(reader->read<std::string>("provenance/metadata/study") == "ESTER comparison");
|
||||
const auto time = reader->read<std::string>("provenance/written_utc");
|
||||
REQUIRE(time);
|
||||
CHECK(time->size() == 20);
|
||||
CHECK(time->back() == 'Z');
|
||||
CHECK(reader->contains("provenance/host/architecture"));
|
||||
CHECK(reader->contains("provenance/libraries/nameof"));
|
||||
CHECK(reader->contains("provenance/libraries/magic_enum"));
|
||||
CHECK(reader->read<std::string>("surface/description") == "ξ, θ\n\"quoted\"\\");
|
||||
for (const serialization::Entry& entry : reader->ledger()) {
|
||||
if (entry.path == "surface/coefficients") {
|
||||
std::ifstream input(file.path, std::ios::binary);
|
||||
input.seekg(entry.offset);
|
||||
double first = 0.0;
|
||||
input >> first;
|
||||
CHECK(first == 1.0);
|
||||
CHECK(entry.count == 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Pack handles absence unsupported components and conflicting definitions explicitly", "[serialize][archive]") {
|
||||
const File file;
|
||||
struct CustomPhysics {};
|
||||
auto contents = serialization::pack(serialization::named("physics", CustomPhysics{}), serialization::named("number", 42));
|
||||
REQUIRE(serialization::write(file.path, contents));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
const auto unknown = reader->node("physics");
|
||||
REQUIRE(unknown);
|
||||
CHECK(unknown->type == "unsupported");
|
||||
CHECK_FALSE(reader->read<CustomPhysics>("physics"));
|
||||
CHECK_FALSE(reader->read<int>("missing"));
|
||||
const std::string original = bytes(file);
|
||||
CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("x", 1), serialization::named("x", 2))));
|
||||
CHECK(bytes(file) == original);
|
||||
CHECK(serialization::write(file.path, serialization::pack(serialization::named("x", 1), serialization::named("x", 1))));
|
||||
CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("provenance", 1))));
|
||||
CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("a/b", 1))));
|
||||
CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("zero", 0.0), serialization::named("zero", -0.0))));
|
||||
const double nan = std::bit_cast<double>(0x7ff8123456789abcULL);
|
||||
CHECK(serialization::write(file.path, serialization::pack(serialization::named("nan", nan), serialization::named("nan", nan))));
|
||||
}
|
||||
|
||||
TEST_CASE("Readers implement configurable SERiF version tolerance independently of file versions", "[serialize][archive]") {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack()));
|
||||
const std::string original = bytes(file);
|
||||
const std::string major = std::to_string(SERIF_VERSION_MAJOR);
|
||||
replace(file, header_field(original, "serif_version", '"' + major + ".999.17\""));
|
||||
CHECK(serialization::Reader::open(file.path));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::exact}));
|
||||
replace(file, header_field(original, "serif_version", '"' + std::to_string(SERIF_VERSION_MAJOR + 1) + ".0.0\""));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
CHECK(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::any}));
|
||||
std::string future = original;
|
||||
future[future.find("SERIF_ARCHIVE 1") + 14] = '2';
|
||||
replace(file, future);
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::any}));
|
||||
}
|
||||
|
||||
TEST_CASE("Indexed reads isolate requested data and report damage when that section is read", "[serialize][archive]") {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("large", std::vector<double>(10000, 1.25)),
|
||||
serialization::named("a", serialization::object("group", serialization::field("value", 7))), serialization::named("a.b", 8))));
|
||||
const auto index = serialization::Reader::open(file.path);
|
||||
REQUIRE(index);
|
||||
std::string damaged = bytes(file);
|
||||
for (const auto& entry : index->ledger()) {
|
||||
if (entry.path == "large") {
|
||||
damaged[entry.offset + 3] ^= 1;
|
||||
}
|
||||
}
|
||||
replace(file, damaged);
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
CHECK(reader->metadata());
|
||||
const auto group = reader->node("a");
|
||||
REQUIRE(group);
|
||||
CHECK(serialization::read_field<int>(*group, "value") == 7);
|
||||
CHECK_FALSE(reader->read<std::vector<double>>("large"));
|
||||
CHECK_FALSE(reader->validate());
|
||||
}
|
||||
|
||||
TEST_CASE("Malformed bounds truncation ledger and allocation requests are rejected", "[serialize][archive]") {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("value", 3.0))));
|
||||
const std::string original = bytes(file);
|
||||
for (std::size_t length : {std::size_t{0}, std::size_t{511}, std::size_t{512}, original.size() - 1}) {
|
||||
replace(file, original.substr(0, length));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
for (std::string key : {"ledger_offset", "ledger_bytes", "data_offset", "file_bytes", "entries"}) {
|
||||
replace(file, header_field(original, key, "18446744073709551615"));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
replace(file, original);
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_index_bytes = 1}));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_payload_bytes = 1}));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_entries = 1}));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_depth = 0}));
|
||||
std::string damaged = original;
|
||||
damaged[550] ^= 1;
|
||||
replace(file, damaged);
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
|
||||
TEST_CASE("Primitive schemas reject wrong extents ranges versions and representations", "[serialize][archive]") {
|
||||
CHECK_FALSE(serialization::decode<unsigned char>(serialization::encode(std::uint64_t{256})));
|
||||
CHECK_FALSE(serialization::decode<std::array<int, 2>>(serialization::encode(std::vector<int>{1})));
|
||||
CHECK_FALSE(serialization::decode<std::vector<int>>(serialization::encode(std::vector<double>{1.0})));
|
||||
CHECK_FALSE(serialization::decode<std::string>(serialization::encode(1.0)));
|
||||
auto future = serialization::encode(1.0);
|
||||
future.version = 2;
|
||||
CHECK_FALSE(serialization::decode<double>(future));
|
||||
const auto optional = serialization::decode<std::optional<int>>(serialization::encode(std::optional<int>{7}));
|
||||
REQUIRE(optional);
|
||||
CHECK(*optional == std::optional<int>{7});
|
||||
CHECK(serialization::decode<std::tuple<int, std::string>>(serialization::encode(std::tuple{7, std::string{"x"}})) == std::tuple{7, std::string{"x"}});
|
||||
for (serialization::Format format : {serialization::Format::text, serialization::Format::binary}) {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("empty", std::vector<double>{}),
|
||||
serialization::named("bounds", std::vector<std::int64_t>{std::numeric_limits<std::int64_t>::min(), std::numeric_limits<std::int64_t>::max()}),
|
||||
serialization::named("flag", true)), {.format = format}));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
CHECK(reader->read<std::vector<double>>("empty") == std::vector<double>{});
|
||||
CHECK(reader->read<bool>("flag") == true);
|
||||
CHECK(reader->read<std::vector<std::int64_t>>("bounds") == std::vector<std::int64_t>{std::numeric_limits<std::int64_t>::min(), std::numeric_limits<std::int64_t>::max()});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A valid checksum does not hide inconsistent ledger semantics", "[serialize][archive]") {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("aa", 1.0), serialization::named("bb", 2.0))));
|
||||
const std::string original = bytes(file);
|
||||
SECTION("Duplicate component paths") {
|
||||
std::string edited = original;
|
||||
edited.replace(edited.find("\"bb\" \"real\""), 4, "\"aa\"");
|
||||
replace(file, ledger_checksum(edited));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
SECTION("Overlapping payloads") {
|
||||
std::string edited = original;
|
||||
const std::size_t first = edited.find("\"aa\" \"real\" 1 4 1 ") + 18;
|
||||
const std::size_t second = edited.find("\"bb\" \"real\" 1 4 1 ") + 18;
|
||||
edited.replace(second, 20, edited.substr(first, 20));
|
||||
replace(file, ledger_checksum(edited));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
SECTION("Scalar with an array extent") {
|
||||
std::string edited = original;
|
||||
edited.replace(edited.find("\"aa\" \"real\" 1 4 1"), 17, "\"aa\" \"real\" 1 4 9");
|
||||
replace(file, ledger_checksum(edited));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
CHECK_FALSE(reader->read<double>("aa"));
|
||||
}
|
||||
SECTION("Unknown payload encoding") {
|
||||
std::string edited = original;
|
||||
edited.replace(edited.find("\"aa\" \"real\" 1 4 1"), 17, "\"aa\" \"real\" 1 9 1");
|
||||
replace(file, ledger_checksum(edited));
|
||||
CHECK_FALSE(serialization::Reader::open(file.path));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Failed archive publication retains existing destinations", "[serialize][archive]") {
|
||||
const File directory;
|
||||
REQUIRE(std::filesystem::create_directory(directory.path));
|
||||
CHECK_FALSE(serialization::write(directory.path, serialization::pack(serialization::named("value", 1))));
|
||||
CHECK(std::filesystem::is_directory(directory.path));
|
||||
CHECK(std::filesystem::is_empty(directory.path));
|
||||
CHECK_FALSE(serialization::write(directory.path / "missing" / "star.serif", serialization::pack()));
|
||||
}
|
||||
|
||||
TEST_CASE("Archive bytes do not depend on the application's numeric locale", "[serialize][archive]") {
|
||||
struct Punctuation : std::numpunct<char> {
|
||||
/** @brief Simulate a locale with comma decimals and whitespace-separated digit groups. */
|
||||
char do_decimal_point() const override { return ','; }
|
||||
/** @brief Expose accidental locale-dependent checksum formatting as a ledger parse failure. */
|
||||
char do_thousands_sep() const override { return ' '; }
|
||||
/** @brief Group every third digit in formatted numbers. */
|
||||
std::string do_grouping() const override { return "\3"; }
|
||||
};
|
||||
struct RestoreLocale {
|
||||
std::locale original = std::locale();
|
||||
/** @brief Restore the application's locale even when an assertion aborts this case. */
|
||||
~RestoreLocale() { std::locale::global(original); }
|
||||
} restore;
|
||||
const std::string expected = serialization::fingerprint("scientific data");
|
||||
std::locale::global(std::locale(std::locale::classic(), new Punctuation));
|
||||
CHECK(serialization::fingerprint("scientific data") == expected);
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("value", 12345.6789)), {.format = serialization::Format::text}));
|
||||
std::locale::global(std::locale::classic());
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
CHECK(reader->read<double>("value") == 12345.6789);
|
||||
}
|
||||
|
||||
|
||||
TEST_CASE("Previous Newton diagnostic archives remain readable without solver-owned timers", "[unit][serialize][restart]") {
|
||||
using Progress = serif::solver::NewtonProgress;
|
||||
const Progress progress{
|
||||
.accepted_metric = {.residual_norm = 0.5, .merit = 0.125},
|
||||
.correction = {0.25},
|
||||
.next_step = 0.5,
|
||||
.diagnostics = {.attempted_iterations = 1, .accepted_iterations = 1, .line_search_trials = 2,
|
||||
.rejected_trials = 1, .initial_residual_norm = 2.0, .final_residual_norm = 0.5,
|
||||
.convergence_threshold = 1.0e-8, .last_accepted_step = 0.25}
|
||||
};
|
||||
auto previous = serialization::encode(progress);
|
||||
auto& diagnostics = previous.children.at("diagnostics");
|
||||
// These are the exact observational fields written by the preceding numerical-history schema.
|
||||
diagnostics.children.at("solve_seconds") = serialization::encode(17.0);
|
||||
diagnostics.children.at("linear_solve_seconds") = serialization::encode(9.0);
|
||||
for (const auto format : {serialization::Format::binary, serialization::Format::text}) {
|
||||
const File file;
|
||||
REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("progress", previous)), {.format = format}));
|
||||
auto reader = serialization::Reader::open(file.path);
|
||||
REQUIRE(reader);
|
||||
const auto restored = reader->read<Progress>("progress");
|
||||
REQUIRE(restored);
|
||||
CHECK_FALSE(serif::solver::validate_newton_progress(*restored, 1));
|
||||
CHECK(restored->correction == progress.correction);
|
||||
CHECK(restored->next_step == progress.next_step);
|
||||
CHECK(restored->diagnostics.initial_residual_norm == progress.diagnostics.initial_residual_norm);
|
||||
CHECK(restored->diagnostics.attempted_iterations == 1);
|
||||
const auto current = serialization::encode(*restored).children.at("diagnostics");
|
||||
CHECK(serialization::decode<double>(current.children.at("solve_seconds")).value() == 0.0);
|
||||
CHECK(serialization::decode<double>(current.children.at("linear_solve_seconds")).value() == 0.0);
|
||||
}
|
||||
}
|
||||
280
tests/serialize/components.cpp
Normal file
280
tests/serialize/components.cpp
Normal file
@@ -0,0 +1,280 @@
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
|
||||
#include "serif/utils/serialize/numerics.hpp"
|
||||
|
||||
namespace {
|
||||
namespace serialization = serif::utils::serialize;
|
||||
namespace blocks = serif::discretization::blocks;
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
namespace model = serif::model;
|
||||
namespace equations = serif::equations;
|
||||
namespace dimensions = serif::dimensions;
|
||||
namespace normalization = serif::solver::normalization;
|
||||
using EOS = serif::eos::models::Polytrope;
|
||||
using Coordinates = dependencies::KeyList<blocks::density::mass, blocks::gravity::potential,
|
||||
blocks::surface_deformation::coefficients, blocks::barotropic_constant::mass_normalization>;
|
||||
using Rows = dependencies::KeyList<blocks::density::mass, blocks::gravity::potential,
|
||||
blocks::surface_deformation::shape_equilibrium, blocks::barotropic_constant::mass_normalization>;
|
||||
using Layout = blocks::SystemLayout<Coordinates, Rows>;
|
||||
|
||||
/** @brief Exercise the complete concrete barotropic equation description without a mesh. */
|
||||
[[nodiscard]] auto physical_description(const EOS& eos) {
|
||||
return model::EquilibriumEquations{
|
||||
.interior_equations = equations::EquationRegistry{equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.25}},
|
||||
equations::PrimalPoissonBalance{12.5}, equations::MassNormalization{4.0}},
|
||||
.shape_force = equations::SurfacePotentialBalance{},
|
||||
.surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.25}}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Serialized stellar physics reconstructs every concrete barotropic component", "[unit][serialize][serialize_components]") {
|
||||
auto original = model::make_stellar_model(EOS{1.5, 2.75}, physical_description(EOS{1.5, 2.75}),
|
||||
model::FixedTotalMass{dimensions::MassValue{4.0}},
|
||||
model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{0.3}, .axis = {0, 1, 0}, .center = {0.2, -0.4, 0.1}}});
|
||||
const auto node = serialization::encode(original);
|
||||
const auto restored = serialization::decode<std::remove_cvref_t<decltype(original)>>(node);
|
||||
REQUIRE(restored);
|
||||
CHECK(serialization::encode(*restored) == node);
|
||||
CHECK(restored->equation_of_state().polytropic_constant() == 2.75);
|
||||
CHECK(restored->template specification<model::FixedAngularMomentum>().center()[1] == -0.4);
|
||||
|
||||
auto invalid = node;
|
||||
invalid.children.at("eos").children.at("index") = serialization::encode(-2.0);
|
||||
const auto rejected = serialization::decode<std::remove_cvref_t<decltype(original)>>(invalid);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(rejected.error().path == "eos");
|
||||
}
|
||||
|
||||
TEST_CASE("Scalar equilibrium archives retain the density balance, primal gravity and the isobar level", "[unit][serialize][serialize_components]") {
|
||||
const EOS eos{1.5, 2.75};
|
||||
const std::array<double, 3> axis{0.0, 1.0, 0.0};
|
||||
const std::array<double, 3> center{0.2, -0.4, 0.1};
|
||||
const auto original = model::make_stellar_model(eos, model::EquilibriumEquations{
|
||||
.interior_equations = equations::EquationRegistry{equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.25}, axis, center},
|
||||
equations::PrimalPoissonBalance{12.5}, equations::MassNormalization{4.0},
|
||||
equations::AngularMomentumConstraint{0.3, axis, center}},
|
||||
.shape_force = equations::SurfacePotentialBalance{axis, center},
|
||||
.surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.25}}},
|
||||
model::FixedTotalMass{dimensions::MassValue{4.0}},
|
||||
model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{0.3}, .axis = axis, .center = center}});
|
||||
const auto node = serialization::encode(original);
|
||||
const auto restored = serialization::decode<std::remove_cvref_t<decltype(original)>>(node);
|
||||
REQUIRE(restored);
|
||||
CHECK(serialization::encode(*restored) == node);
|
||||
CHECK(restored->equations().interior_equations.template get<equations::PrimalPoissonBalance>().four_pi_G() == 12.5);
|
||||
CHECK(restored->equations().shape_force.rotation().axis_origin() == center);
|
||||
CHECK(restored->template specification<model::FixedTotalMass>().target().value() == 4.0);
|
||||
}
|
||||
|
||||
TEST_CASE("Barotropic density archives preserve surface pressure and a tilted displaced rotation axis", "[unit][serialize][serialize_components][barotropic_density]") {
|
||||
using Balance = equations::BarotropicDensityBalance<EOS>;
|
||||
const std::array<double, 3> axis{0.0, 3.0, 4.0}, center{0.2, -0.4, 0.1};
|
||||
const Balance original{EOS{2.0, 0.4}, dimensions::PressureValue{0.05}, axis, center};
|
||||
const auto node = serialization::encode(original);
|
||||
CHECK(node.type == "equation.barotropic_density");
|
||||
const auto restored = serialization::decode<Balance>(node);
|
||||
REQUIRE(restored);
|
||||
CHECK(restored->surface_pressure().value() == 0.05);
|
||||
CHECK(restored->equation_of_state().polytropic_index() == 2.0);
|
||||
CHECK(restored->equation_of_state().polytropic_constant() == 0.4);
|
||||
CHECK(restored->rotation().axis_origin() == center);
|
||||
|
||||
equations::EquationPoint<Balance::Operands> point;
|
||||
point.fields.get<Balance::Density>() = 1.44;
|
||||
point.fields.get<Balance::Potential>() = -0.7;
|
||||
point.fields.get<Balance::SurfaceLevel>() = -0.9;
|
||||
point.fields.get<Balance::AngularVelocity>() = 0.3;
|
||||
point.position = {1.2, 0.7, -0.2};
|
||||
const auto expected = original.evaluate(point), actual = restored->evaluate(point);
|
||||
// For n=2, K=0.4, P_surface=0.05: rho_surface=0.25 and A(rho)-A_surface=0.84.
|
||||
CHECK(actual.residual == Catch::Approx(1.04 - 0.09 * original.rotation().centrifugal_potential(point.position)).margin(2.0e-15));
|
||||
CHECK(actual.residual == Catch::Approx(expected.residual).margin(2.0e-15));
|
||||
for (std::size_t index = 0; index < actual.field_derivatives.values.size(); ++index) {
|
||||
CHECK(actual.field_derivatives.values[index] == Catch::Approx(expected.field_derivatives.values[index]).margin(2.0e-15));
|
||||
}
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
CHECK(restored->rotation().angular_velocity_vector()[component] == Catch::Approx(original.rotation().angular_velocity_vector()[component]).margin(2.0e-15));
|
||||
CHECK(actual.position_derivative[component] == Catch::Approx(expected.position_derivative[component]).margin(2.0e-15));
|
||||
}
|
||||
|
||||
// Deserialization must own formerly borrowed physics and reconstruct its finite-pressure reference.
|
||||
const auto borrowed_record = [&]() {
|
||||
const EOS temporary{2.0, 0.4};
|
||||
return serialization::encode(equations::BarotropicDensityBalance{model::borrow_eos(temporary), dimensions::PressureValue{0.05}, axis, center});
|
||||
}();
|
||||
using Borrowed = equations::BarotropicDensityBalance<model::EosReference<EOS>>;
|
||||
const auto reowned = serialization::decode<Borrowed>(borrowed_record);
|
||||
REQUIRE(reowned);
|
||||
CHECK(reowned->equation_of_state().get().polytropic_constant() == 0.4);
|
||||
CHECK(reowned->evaluate(point).residual == Catch::Approx(expected.residual).margin(2.0e-15));
|
||||
CHECK_FALSE(serialization::decode<equations::SurfacePotentialBalance>(node));
|
||||
auto invalid = node;
|
||||
invalid.children.at("surface_pressure") = serialization::encode(dimensions::PressureValue{-0.1});
|
||||
CHECK_FALSE(serialization::decode<Balance>(invalid));
|
||||
invalid = node;
|
||||
invalid.children.at("axis") = serialization::encode(std::array<double, 3>{0.0, 0.0, 0.0});
|
||||
CHECK_FALSE(serialization::decode<Balance>(invalid));
|
||||
}
|
||||
|
||||
TEST_CASE("Deferred model archives restore current physics without pretending to recreate a callback", "[unit][serialize][serialize_components]") {
|
||||
const double coefficient = 2.0;
|
||||
auto original = model::make_stellar_model(EOS{1.0, 1.0}, [coefficient](const auto& star) {
|
||||
return model::EquilibriumEquations{equations::EquationRegistry{equations::PrimalPoissonBalance{coefficient * star.equation_of_state().polytropic_index()}}};
|
||||
});
|
||||
const auto node = serialization::encode(original);
|
||||
CHECK(node.children.at("authored_definition").type == "unsupported");
|
||||
CHECK_FALSE(serialization::decode<std::remove_cvref_t<decltype(original)>>(node));
|
||||
const auto restored = serialization::restore_stellar_model<decltype(original)>(node);
|
||||
REQUIRE(restored);
|
||||
CHECK(restored->equations().interior_equations.template get<equations::PrimalPoissonBalance>().four_pi_G() == coefficient);
|
||||
CHECK(serialization::encode(restored->equations()) == node.children.at("resolved_equations"));
|
||||
}
|
||||
|
||||
TEST_CASE("Physical state serialization preserves coefficients and rejects mismatched coordinate schemas", "[unit][serialize][serialize_components]") {
|
||||
const Layout layout{{2, 3, 2, 1}, {2, 3, 2, 1}};
|
||||
std::vector<double> coefficients{0.0, -0.0, 1.0e-250, -1.0e200, 2.0, 0.2, -0.3, 1.5};
|
||||
const model::StellarState original{layout, coefficients};
|
||||
auto node = serialization::encode(original);
|
||||
const auto restored = serialization::decode<std::remove_cvref_t<decltype(original)>>(node);
|
||||
REQUIRE(restored);
|
||||
REQUIRE(restored->values().size() == coefficients.size());
|
||||
for (std::size_t index = 0; index < coefficients.size(); ++index) {
|
||||
CHECK(std::bit_cast<std::uint64_t>(restored->values()[index]) == std::bit_cast<std::uint64_t>(coefficients[index]));
|
||||
}
|
||||
node.children.at("layout").children.at("values").children.at("0").children.at("id") = serialization::encode(std::string{"gravity.potential"});
|
||||
CHECK_FALSE(serialization::decode<std::remove_cvref_t<decltype(original)>>(node));
|
||||
node = serialization::encode(original);
|
||||
node.children.at("coefficients") = serialization::encode(std::vector<double>{1.0});
|
||||
CHECK_FALSE(serialization::decode<std::remove_cvref_t<decltype(original)>>(node));
|
||||
}
|
||||
|
||||
TEST_CASE("Default numerical recipes retain resolved parameters rather than selecting fresh defaults", "[unit][serialize][serialize_components]") {
|
||||
using PrimalValues = dependencies::KeyList<blocks::density::mass, blocks::gravity::potential,
|
||||
blocks::barotropic_constant::mass_normalization, blocks::rotation::angular_velocity, blocks::surface_deformation::coefficients>;
|
||||
using PrimalRows = dependencies::KeyList<blocks::density::mass, blocks::gravity::potential,
|
||||
blocks::barotropic_constant::mass_normalization, blocks::rotation::angular_velocity, blocks::surface_deformation::shape_equilibrium>;
|
||||
const blocks::SystemLayout<PrimalValues, PrimalRows> primal_layout{{2, 3, 1, 1, 2}, {2, 3, 1, 1, 2}};
|
||||
const auto primal_inverse = serif::solver::preconditioner::make_primal_preconditioner(primal_layout);
|
||||
const auto restored_primal = serialization::decode<std::remove_cvref_t<decltype(primal_inverse)>>(serialization::encode(primal_inverse));
|
||||
REQUIRE(restored_primal);
|
||||
CHECK(serialization::encode(*restored_primal) == serialization::encode(primal_inverse));
|
||||
|
||||
auto options = serif::solver::SolverOptions<>{};
|
||||
options.method.maximum_iterations = 123;
|
||||
options.linear_solver.restart_length = 31;
|
||||
options.line_search.contraction = 0.4;
|
||||
const auto restored_options = serialization::decode<decltype(options)>(serialization::encode(options));
|
||||
REQUIRE(restored_options);
|
||||
CHECK(serialization::encode(*restored_options) == serialization::encode(options));
|
||||
|
||||
const auto numerical = normalization::make_normalization(normalization::NormalizationOptions{
|
||||
.scheme = normalization::LaneEmdenApproximation{.n = 2.5, .mass = dimensions::MassValue{4.}, .central_density = dimensions::DensityValue{2.}},
|
||||
.coordinates = normalization::Dimensionless{}, .gravitational_constant = dimensions::QuantityValue<dimensions::GravitationalConstant>{0.75}});
|
||||
const auto restored_numerical = serialization::decode<std::remove_cvref_t<decltype(numerical)>>(serialization::encode(numerical));
|
||||
REQUIRE(restored_numerical);
|
||||
CHECK(serialization::encode(*restored_numerical) == serialization::encode(numerical));
|
||||
const auto factors = normalization::make_diagonal_normalization({2., 4.}, {0.1, 0.3});
|
||||
REQUIRE(factors);
|
||||
auto saved_factors = serialization::encode(*factors);
|
||||
REQUIRE(serialization::decode<normalization::DiagonalNormalization>(saved_factors));
|
||||
saved_factors.children.at("state") = serialization::encode(std::vector<double>{-1., 4.});
|
||||
CHECK_FALSE(serialization::decode<normalization::DiagonalNormalization>(saved_factors));
|
||||
}
|
||||
|
||||
TEST_CASE("Surface representations retain physical centers and harmonic selection", "[unit][serialize][serialize_components]") {
|
||||
using namespace serif::geometry::deformation;
|
||||
const DomainDeformation original{SphericalHarmonicSurface{.reference_center = {0.1, -0.2, 0.3}, .maximum_degree = 8, .maximum_azimuthal_order = 3},
|
||||
PowerLawRadialExtension{.radial_power = 1.75}, FixedInfinityRadialExtension{}};
|
||||
const auto restored = serialization::decode<std::remove_cvref_t<decltype(original)>>(serialization::encode(original));
|
||||
REQUIRE(restored);
|
||||
CHECK(*restored == original);
|
||||
const auto nodal = serialization::decode<NodalRadialSurface>(serialization::encode(NodalRadialSurface{{1., 2., 3.}}));
|
||||
REQUIRE(nodal);
|
||||
CHECK(nodal->reference_center[2] == 3.0);
|
||||
}
|
||||
|
||||
TEST_CASE("Quantity serializers reject incorrect dimensions and unknown schema versions", "[unit][serialize][serialize_components]") {
|
||||
auto node = serialization::encode(dimensions::MassValue{7.0});
|
||||
CHECK_FALSE(serialization::decode<dimensions::DensityValue>(node));
|
||||
node.version = 17;
|
||||
CHECK_FALSE(serialization::decode<dimensions::MassValue>(node));
|
||||
}
|
||||
|
||||
TEST_CASE("Quadrature role controls survive serialization without compiled caches", "[unit][serialize][serialize_components]") {
|
||||
namespace quadrature = serif::discretization::quadrature;
|
||||
quadrature::RuleSet rules;
|
||||
rules.set_role_control(quadrature::QuadratureRole::projection, {.fixed_order = 12, .boost = 2});
|
||||
rules.set_fallback_control({.boost = 3});
|
||||
const quadrature::Policy original{rules};
|
||||
const auto restored = serialization::decode<quadrature::Policy>(serialization::encode(original));
|
||||
REQUIRE(restored);
|
||||
CHECK(restored->rules().role_control(quadrature::QuadratureRole::projection).fixed_order == 12);
|
||||
CHECK(restored->rules().fallback_control().boost == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Prescribed input serialization distinguishes owned values from borrowed runtime objects", "[unit][serialize][serialize_components]") {
|
||||
const auto inputs = dependencies::make_dependency_context(dependencies::own<blocks::rotation::angular_velocity>(0.3));
|
||||
const auto restored = serialization::decode<std::remove_cvref_t<decltype(inputs)>>(serialization::encode(inputs));
|
||||
REQUIRE(restored);
|
||||
CHECK(restored->template get<blocks::rotation::angular_velocity>() == 0.3);
|
||||
double prescribed = 0.4;
|
||||
const auto borrowed = dependencies::make_dependency_context(dependencies::borrow<blocks::rotation::angular_velocity>(prescribed));
|
||||
const auto node = serialization::encode(borrowed);
|
||||
CHECK(node.children.at("resources").children.at("0").children.at("reconstruction").type == "unsupported");
|
||||
CHECK_FALSE(serialization::decode<std::remove_cvref_t<decltype(borrowed)>>(node));
|
||||
}
|
||||
|
||||
TEST_CASE("Decoded numerical options reject invalid budgets and approximation controls", "[unit][serialize][serialize_components]") {
|
||||
auto options = serialization::encode(serif::solver::SolverOptions<>{});
|
||||
options.children.at("method").children.at("maximum_iterations") = serialization::encode(-3);
|
||||
const auto invalid_solver = serialization::decode<serif::solver::SolverOptions<>>(options);
|
||||
REQUIRE_FALSE(invalid_solver);
|
||||
CHECK(invalid_solver.error().path == "method");
|
||||
|
||||
using AMG = serif::solver::preconditioner::strategies::inverse::FixedAMG;
|
||||
CHECK_FALSE(serialization::decode<AMG>(serialization::encode(AMG{.cycles = 0})));
|
||||
using Surface = serif::geometry::deformation::SphericalHarmonicSurface;
|
||||
CHECK_FALSE(serialization::decode<Surface>(serialization::encode(Surface{.maximum_degree = 2, .maximum_azimuthal_order = 3})));
|
||||
using Integration = serif::utils::laneemden::IntegrationOptions;
|
||||
CHECK_FALSE(serialization::decode<Integration>(serialization::encode(Integration{.maximum_steps = 0})));
|
||||
}
|
||||
|
||||
TEST_CASE("Custom quadrature form controls remain inspectable when their descriptor cannot be reowned", "[unit][serialize][serialize_components]") {
|
||||
namespace quadrature = serif::discretization::quadrature;
|
||||
quadrature::RuleSet rules;
|
||||
rules.set_form_control({.name = "custom_mass", .trial = {.field = {"density"}, .name = "mass"}, .test = {.field = {"density"}, .name = "mass"}}, {.boost = 7});
|
||||
const auto node = serialization::encode(rules);
|
||||
CHECK(node.children.at("forms").children.at("reconstruction").type == "unsupported");
|
||||
const auto boost = serialization::decode<int>(node.children.at("forms").children.at("0").children.at("control").children.at("boost"));
|
||||
REQUIRE(boost);
|
||||
CHECK(*boost == 7);
|
||||
CHECK_FALSE(serialization::decode<quadrature::RuleSet>(node));
|
||||
}
|
||||
|
||||
TEST_CASE("Configured discretizations preserve owned normalization inputs and resolved choices", "[unit][serialize][serialize_components]") {
|
||||
auto star = model::make_stellar_model(EOS{1.5, 2.75}, physical_description(EOS{1.5, 2.75}),
|
||||
model::FixedTotalMass{dimensions::MassValue{4.0}});
|
||||
auto numerical = normalization::NormalizationOptions{
|
||||
.scheme = normalization::LaneEmdenApproximation<normalization::FromModel, dimensions::DensityValue>{.central_density = dimensions::DensityValue{2.0}},
|
||||
.sources = dependencies::make_dependency_context(dependencies::own<blocks::rotation::angular_velocity>(0.3))};
|
||||
auto original = model::make_discretization(model::DiscretizationOptions{
|
||||
.model = std::move(star), .finite_elements = std::vector<double>{0.0, 0.5, 1.0}, .normalization = std::move(numerical)});
|
||||
const auto node = serialization::encode(original);
|
||||
CHECK(node.type == "model.configured_discretization");
|
||||
const auto restored = serialization::decode<decltype(original)>(node);
|
||||
REQUIRE(restored);
|
||||
CHECK(serialization::encode(*restored) == node);
|
||||
CHECK(restored->normalization.scheme.mass == dimensions::MassValue{4.0});
|
||||
CHECK(restored->normalization.sources.template get<blocks::rotation::angular_velocity>() == 0.3);
|
||||
}
|
||||
24
tests/serialize/meson.build
Normal file
24
tests/serialize/meson.build
Normal file
@@ -0,0 +1,24 @@
|
||||
serif_serialization_tests = executable(
|
||||
'serif_serialization_tests',
|
||||
['archive.cpp', 'components.cpp', 'top.cpp'],
|
||||
dependencies: [serif_core_dep, catch2_dep],
|
||||
)
|
||||
test('serif_serialization_tests', serif_serialization_tests, suite: 'serialize', timeout: 120)
|
||||
|
||||
serif_mfem_serialization_tests = executable(
|
||||
'serif_mfem_serialization_tests',
|
||||
['mfem.cpp', 'restart.cpp', 'top_mfem.cpp'],
|
||||
dependencies: [serif_dep, catch2_dep, stroid_dep],
|
||||
include_directories: include_directories('../include'),
|
||||
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
|
||||
)
|
||||
test('serif_mfem_serialization_tests', serif_mfem_serialization_tests, args: ['~[moving_restart]'], suite: 'serialize', timeout: 300, is_parallel: false)
|
||||
test('serif_moving_restart_tests', serif_mfem_serialization_tests, args: ['[moving_restart]'], suite: 'serialize', timeout: 1800, is_parallel: false)
|
||||
|
||||
serialization_fixture = executable('serialization_fixture', 'write_fixture.cpp', dependencies: serif_core_dep)
|
||||
test('serif_python_archive_reader', python_build,
|
||||
args: [files('python_reader.py'), serialization_fixture, files('../../tools/read_serif_archive.py')],
|
||||
suite: 'serialize', timeout: 30)
|
||||
|
||||
# Optional actual TOP-reader check: run top_reader.py in a compatible TOP environment.
|
||||
top_export_fixture = executable('top_export_fixture', 'write_top_fixture.cpp', dependencies: serif_core_dep)
|
||||
308
tests/serialize/mfem.cpp
Normal file
308
tests/serialize/mfem.cpp
Normal file
@@ -0,0 +1,308 @@
|
||||
#include <bit>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/backend/mfem/serialize/configuration.hpp"
|
||||
#include "serif/backend/mfem/serialize/options.hpp"
|
||||
#include "serif/backend/mfem/discretization/element_coordinates.hpp"
|
||||
#include "serif/tests/solver/stellar_fixture.hpp"
|
||||
#include "serif/tests/nonconforming.hpp"
|
||||
|
||||
namespace {
|
||||
namespace fixture = serif::tests::solver;
|
||||
namespace archive = serif::utils::serialize;
|
||||
namespace backend = serif::backend::mfem::serialize;
|
||||
using Discretization = decltype(fixture::make_discretization(std::declval<std::shared_ptr<fixture::StellarResources>>()));
|
||||
|
||||
/** @brief Exercise simplex orientation independently of the moving spherical mesh generator. */
|
||||
struct FixedResources {
|
||||
::mfem::Mesh mesh;
|
||||
::mfem::H1_FECollection scalar_collection{fixture::Density::FunctionalSpace::order, 3};
|
||||
::mfem::H1_FECollection displacement_collection{fixture::Displacement::FunctionalSpace::order, 3};
|
||||
::mfem::H1_FECollection potential_collection{fixture::Potential::FunctionalSpace::order, 3};
|
||||
::mfem::FiniteElementSpace density_space{&mesh, &scalar_collection};
|
||||
::mfem::FiniteElementSpace displacement_space{&mesh, &displacement_collection, 3, ::mfem::Ordering::byVDIM};
|
||||
::mfem::FiniteElementSpace potential_space{&mesh, &potential_collection};
|
||||
::mfem::GridFunction compactification{&density_space};
|
||||
serif::discretization::backend::mfem::TermBinding<fixture::Density, fixture::Schema> density{density_space};
|
||||
serif::discretization::backend::mfem::TermBinding<fixture::Displacement, fixture::Schema> displacement{displacement_space};
|
||||
serif::discretization::backend::mfem::TermBinding<fixture::Potential, fixture::Schema> potential{potential_space};
|
||||
serif::mapping::DomainMapper mapper{{}, std::make_unique<serif::mapping::compactification::KelvinCompactification>(serif::utils::user_options::KelvinCompactificationOptions{})};
|
||||
|
||||
/** @brief Finalize a supported serial mesh before constructing any term binding. */
|
||||
explicit FixedResources(::mfem::Element::Type geometry) : mesh(make_mesh(geometry)) {
|
||||
compactification = 0.0;
|
||||
}
|
||||
|
||||
/** @brief Retain nontrivial support and high-order mesh coordinates in the small fixed-domain case. */
|
||||
static ::mfem::Mesh make_mesh(::mfem::Element::Type geometry) {
|
||||
auto mesh = ::mfem::Mesh::MakeCartesian3D(2, 1, 1, geometry);
|
||||
mesh.GetElement(mesh.GetNE() - 1)->SetAttribute(3);
|
||||
mesh.SetCurvature(2);
|
||||
return mesh;
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief Ensure restoration cannot accidentally borrow the application's original resources. */
|
||||
archive::Node saved_star(backend::SaveOptions options = {}) {
|
||||
const auto resources = fixture::make_resources(1.25, 0, 2);
|
||||
const auto discretization = fixture::make_discretization(resources, 2);
|
||||
return backend::save_discretization(discretization, options);
|
||||
}
|
||||
|
||||
/** @brief Keep numerical reference choices together with the model and its native resources. */
|
||||
auto make_configuration() {
|
||||
const auto resources = fixture::make_resources(1.25, 0, 2);
|
||||
const serif::solver::normalization::NormalizationOptions normalization{
|
||||
.scheme = serif::solver::normalization::LaneEmdenApproximation{
|
||||
.n = 1.0, .mass = serif::dimensions::MassValue{1.0}, .central_density = serif::dimensions::DensityValue{1.0}}
|
||||
};
|
||||
return serif::model::make_discretization(serif::model::DiscretizationOptions{
|
||||
.model = fixture::make_model(), .finite_elements = fixture::make_discretization(resources, 2), .normalization = normalization
|
||||
});
|
||||
}
|
||||
|
||||
/** @brief Compare native element coefficients after restoring their exact basis and orientation. */
|
||||
template <typename Term, typename Original, typename Restored>
|
||||
void compare_field(const Original& original, const Restored& restored) {
|
||||
const auto& before = original.template binding<Term>();
|
||||
const auto& after = restored.template binding<Term>();
|
||||
REQUIRE(before.size() == after.size());
|
||||
::mfem::Vector coefficients(before.size());
|
||||
for (int dof = 0; dof < coefficients.Size(); ++dof) {
|
||||
coefficients[dof] = 0.125 * static_cast<double>(dof - 7);
|
||||
}
|
||||
coefficients[0] = -0.0;
|
||||
const auto before_elements = before.validated_elements();
|
||||
const auto after_elements = after.validated_elements();
|
||||
for (int element = 0; element < before.mesh().GetNE(); ++element) {
|
||||
::mfem::Vector first, second;
|
||||
before_elements.element_values(element, coefficients, first);
|
||||
after_elements.element_values(element, coefficients, second);
|
||||
REQUIRE(first.Size() == second.Size());
|
||||
for (int local = 0; local < first.Size(); ++local) {
|
||||
CHECK(std::bit_cast<std::uint64_t>(first[local]) == std::bit_cast<std::uint64_t>(second[local]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Fixed MFEM discretizations retain simplex and tensor element orientation", "[unit][serialize][mfem_archive]") {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
for (const ::mfem::Element::Type geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) {
|
||||
const auto resources = std::make_shared<FixedResources>(geometry);
|
||||
const auto bindings = dependencies::make_dependency_context(dependencies::borrow<fixture::Density>(resources->density),
|
||||
dependencies::borrow<fixture::Potential>(resources->potential), dependencies::borrow<fixture::Displacement>(resources->displacement));
|
||||
const auto original = serif::model::backend::mfem::make_stellar_discretization(resources, bindings, resources->compactification, resources->mapper);
|
||||
const archive::Node record = backend::save_discretization(original);
|
||||
auto restored = backend::restore_discretization<decltype(original)>(record);
|
||||
INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message));
|
||||
REQUIRE(restored);
|
||||
CHECK(backend::save_discretization(*restored) == record);
|
||||
compare_field<fixture::Density>(original, *restored);
|
||||
compare_field<fixture::Potential>(original, *restored);
|
||||
compare_field<fixture::Displacement>(original, *restored);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("MFEM archives own exact meshes bases support and oriented coefficient interpretations", "[unit][serialize][mfem_archive]") {
|
||||
const auto resources = fixture::make_resources(1.25, 0, 2);
|
||||
const auto original = fixture::make_discretization(resources, 2);
|
||||
const archive::Node record = backend::save_discretization(original);
|
||||
auto restored = backend::restore_discretization<Discretization>(record);
|
||||
INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message));
|
||||
REQUIRE(restored);
|
||||
CHECK(backend::save_discretization(*restored) == record);
|
||||
CHECK(&restored->template binding<fixture::Density>().mesh() != resources->mesh.mesh.get());
|
||||
CHECK(restored->deformation.reference.scalar_space.FEColl() == restored->deformation.reference.displacement_space.FEColl());
|
||||
CHECK(restored->deformation.reference.displacement_space.GetOrdering() == ::mfem::Ordering::byNODES);
|
||||
CHECK(restored->template binding<fixture::Displacement>().space().GetOrdering() == ::mfem::Ordering::byVDIM);
|
||||
CHECK(restored->deformation.model == original.deformation.model);
|
||||
compare_field<fixture::Density>(original, *restored);
|
||||
compare_field<fixture::Potential>(original, *restored);
|
||||
compare_field<fixture::Displacement>(original, *restored);
|
||||
compare_field<fixture::Constant>(original, *restored);
|
||||
compare_field<fixture::Omega>(original, *restored);
|
||||
|
||||
// Nothing from the writer's resource owner survives this helper call.
|
||||
const archive::Node independent_record = saved_star();
|
||||
const auto independent = backend::restore_discretization<Discretization>(independent_record);
|
||||
REQUIRE(independent);
|
||||
CHECK(backend::save_discretization(*independent) == independent_record);
|
||||
}
|
||||
|
||||
TEST_CASE("MFEM archives reject incompatible numbering and resource associations", "[unit][serialize][mfem_archive]") {
|
||||
archive::Node record = saved_star();
|
||||
auto& resources = record.children.at("resources");
|
||||
SECTION("Mesh contents disagree with their content identity") {
|
||||
auto& native = resources.children.at("meshes").children.at("0").children.at("native");
|
||||
std::get<std::string>(native.value).append("\n");
|
||||
}
|
||||
SECTION("A matching fingerprint does not make malformed native mesh text valid") {
|
||||
auto& mesh = resources.children.at("meshes").children.at("0");
|
||||
const std::string malformed = "This is not an MFEM mesh.\n";
|
||||
mesh.children.at("native") = archive::encode(malformed);
|
||||
mesh.children.at("identity") = archive::encode(archive::fingerprint(malformed));
|
||||
const auto restored = backend::restore_discretization<Discretization>(record);
|
||||
REQUIRE_FALSE(restored);
|
||||
CHECK(restored.error().message.starts_with("Cannot reconstruct saved MFEM resources:"));
|
||||
return;
|
||||
}
|
||||
SECTION("A collection's basis changes without changing its nominal polynomial order") {
|
||||
const auto space = archive::decode<std::uint64_t>(record.children.at("bindings").children.at("0").children.at("space"));
|
||||
REQUIRE(space);
|
||||
const auto collection_index = archive::decode<std::uint64_t>(
|
||||
resources.children.at("spaces").children.at(std::to_string(*space)).children.at("collection"));
|
||||
REQUIRE(collection_index);
|
||||
auto& collection = resources.children.at("collections").children.at(std::to_string(*collection_index));
|
||||
const auto family = archive::decode<std::string>(collection.children.at("family"));
|
||||
REQUIRE(family);
|
||||
REQUIRE(*family == "H1");
|
||||
collection.children.at("closed_basis") = archive::encode(static_cast<int>(::mfem::BasisType::ClosedUniform));
|
||||
}
|
||||
SECTION("An element's signed local numbering changes") {
|
||||
auto& numbering = resources.children.at("spaces").children.at("0").children.at("element_numbering");
|
||||
std::get<std::vector<std::int64_t>>(numbering.value)[1] = -100;
|
||||
}
|
||||
SECTION("A term is connected to a missing space") {
|
||||
record.children.at("bindings").children.at("0").children.at("space") = archive::encode(std::uint64_t{900});
|
||||
}
|
||||
SECTION("A term's reduced map is changed") {
|
||||
auto& reduction = record.children.at("bindings").children.at("0").children.at("coordinates").children.at("full_to_reduced");
|
||||
std::get<std::vector<std::int64_t>>(reduction.value)[0] = 999;
|
||||
}
|
||||
SECTION("A deformation companion is not a displacement space") {
|
||||
record.children.at("reference").children.at("displacement_space") = record.children.at("reference").children.at("scalar_space");
|
||||
}
|
||||
SECTION("Native MFEM version differs") {
|
||||
resources.children.at("mfem_version") = archive::encode(std::string{"different"});
|
||||
}
|
||||
CHECK_FALSE(backend::restore_discretization<Discretization>(record));
|
||||
}
|
||||
|
||||
TEST_CASE("An omitted MFEM mesh must be resolved by its exact saved identity", "[unit][serialize][mfem_archive]") {
|
||||
const auto resources = fixture::make_resources(1.25, 0, 2);
|
||||
const auto original = fixture::make_discretization(resources, 2);
|
||||
const archive::Node record = backend::save_discretization(original, {.embed_mesh = false});
|
||||
CHECK_FALSE(backend::restore_discretization<Discretization>(record));
|
||||
std::map<std::string, std::string, std::less<>> meshes;
|
||||
for (const ::mfem::Mesh* mesh : {resources->mesh.mesh.get(), resources->mesh.reference_mesh.get()}) {
|
||||
const std::string text = backend::mesh_text(*mesh);
|
||||
meshes.emplace(archive::fingerprint(text), text);
|
||||
}
|
||||
auto restored = backend::restore_discretization<Discretization>(record, [&](std::string_view identity) -> archive::Result<std::string> {
|
||||
const auto found = meshes.find(identity);
|
||||
if (found == meshes.end()) {
|
||||
return std::unexpected(archive::Error{"mesh", "Unknown mesh identity."});
|
||||
}
|
||||
return found->second;
|
||||
});
|
||||
REQUIRE(restored);
|
||||
CHECK(backend::save_discretization(*restored, {.embed_mesh = false}) == record);
|
||||
CHECK_FALSE(backend::restore_discretization<Discretization>(record, [](std::string_view) -> archive::Result<std::string> { return std::string{"wrong mesh"}; }));
|
||||
}
|
||||
|
||||
TEST_CASE("MFEM assembly options retain quadrature controls and reject unknown contraction modes", "[unit][serialize][mfem_archive]") {
|
||||
namespace quadrature = serif::discretization::quadrature;
|
||||
using Options = serif::operators::backend::mfem::VolumeOptions;
|
||||
|
||||
quadrature::RuleSet rules;
|
||||
rules.set_role_control(quadrature::QuadratureRole::discretization, {.fixed_order = 9, .boost = 2});
|
||||
const Options options{.quadrature_policy = quadrature::Policy{rules}, .contraction_mode = serif::operators::backend::mfem::ContractionMode::dense};
|
||||
archive::Node record = archive::encode(options);
|
||||
auto restored = archive::decode<Options>(record);
|
||||
REQUIRE(restored);
|
||||
CHECK(archive::encode(*restored) == record);
|
||||
record.children.at("contraction") = archive::encode(500);
|
||||
CHECK_FALSE(archive::decode<Options>(record));
|
||||
}
|
||||
|
||||
TEST_CASE("Configured MFEM models restore their full numerical prescription with independent ownership", "[unit][serialize][mfem_archive]") {
|
||||
using Configuration = decltype(make_configuration());
|
||||
|
||||
archive::Node record;
|
||||
std::weak_ptr<const fixture::StellarResources> original_owner;
|
||||
{
|
||||
const auto original = make_configuration();
|
||||
original_owner = original.finite_elements.owner;
|
||||
record = archive::encode(original);
|
||||
}
|
||||
REQUIRE(original_owner.expired());
|
||||
REQUIRE(record.type == "model.configured_discretization");
|
||||
const auto restored = backend::restore_configuration<Configuration>(record);
|
||||
INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message));
|
||||
REQUIRE(restored);
|
||||
const archive::Node saved_again = archive::encode(*restored);
|
||||
CHECK(saved_again.children.at("finite_elements") == record.children.at("finite_elements"));
|
||||
CHECK(saved_again.children.at("normalization") == record.children.at("normalization"));
|
||||
CHECK(saved_again.children.at("preconditioner") == record.children.at("preconditioner"));
|
||||
CHECK(saved_again.children.at("model").children.at("resolved_equations") == record.children.at("model").children.at("resolved_equations"));
|
||||
|
||||
SECTION("Unknown configuration versions cannot be restored") {
|
||||
record.version = 2;
|
||||
}
|
||||
SECTION("Missing numerical choices cannot silently become defaults") {
|
||||
record.children.erase("normalization");
|
||||
}
|
||||
SECTION("Unsupported preconditioners cannot silently become no preconditioning") {
|
||||
record.children.at("preconditioner") = archive::Node{.type = "unsupported", .value = std::string{"Custom preconditioner has no serializer."}};
|
||||
}
|
||||
CHECK_FALSE(backend::restore_configuration<Configuration>(record));
|
||||
}
|
||||
|
||||
TEST_CASE("MFEM archives refuse native parsing when the application selects abort on error", "[unit][serialize][mfem_archive]") {
|
||||
const archive::Node record = saved_star();
|
||||
struct RestoreErrorAction {
|
||||
::mfem::ErrorAction previous{::mfem::get_error_action()};
|
||||
/** @brief Restore the application's prior native error policy even if an assertion stops the test. */
|
||||
~RestoreErrorAction() { ::mfem::set_error_action(previous); }
|
||||
} restore_error_action;
|
||||
::mfem::set_error_action(::mfem::MFEM_ERROR_ABORT);
|
||||
const auto restored = backend::restore_discretization<Discretization>(record);
|
||||
REQUIRE_FALSE(restored);
|
||||
CHECK(restored.error().message.find("requires MFEM_ERROR_THROW") != std::string::npos);
|
||||
CHECK(::mfem::get_error_action() == ::mfem::MFEM_ERROR_ABORT);
|
||||
}
|
||||
|
||||
TEST_CASE("Nonconforming STROID archives retain independent coordinates after original owners expire", "[unit][serialize][mfem_archive][nonconforming]") {
|
||||
archive::Node record;
|
||||
std::weak_ptr<const void> original_owner;
|
||||
const auto restored = [&] {
|
||||
const auto resources = std::make_shared<fixture::StellarResources>(serif::tests::nonconforming::mesh(2, 0, 2));
|
||||
original_owner = resources;
|
||||
const auto original = fixture::make_discretization(resources, 2);
|
||||
REQUIRE(resources->potential_space.GetTrueVSize() < resources->potential_space.GetVSize());
|
||||
record = backend::save_discretization(original);
|
||||
auto result = backend::restore_discretization<Discretization>(record);
|
||||
if (!result) {
|
||||
INFO(result.error().path << ": " << result.error().message);
|
||||
}
|
||||
REQUIRE(result);
|
||||
compare_field<fixture::Density>(original, *result);
|
||||
compare_field<fixture::Potential>(original, *result);
|
||||
compare_field<fixture::Displacement>(original, *result);
|
||||
CHECK(&result->template binding<fixture::Potential>().mesh() != resources->mesh.mesh.get());
|
||||
return std::move(*result);
|
||||
}();
|
||||
CHECK(original_owner.expired());
|
||||
const auto& potential = restored.template binding<fixture::Potential>();
|
||||
REQUIRE(potential.space().GetTrueVSize() < potential.space().GetVSize());
|
||||
CHECK(potential.space().GetConformingProlongation() != nullptr);
|
||||
CHECK(restored.deformation.reference.logical_mesh.Nonconforming());
|
||||
CHECK(backend::save_discretization(restored) == record);
|
||||
mfem::Vector coefficients(potential.size());
|
||||
for (int index = 0; index < coefficients.Size(); ++index) {
|
||||
coefficients[index] = 0.125 * (index - 7);
|
||||
}
|
||||
const mfem::Vector local = potential.scatter(coefficients);
|
||||
const mfem::Vector independent = potential.gather(local);
|
||||
REQUIRE(independent.Size() == coefficients.Size());
|
||||
for (int index = 0; index < coefficients.Size(); ++index) {
|
||||
CHECK(independent[index] == coefficients[index]);
|
||||
}
|
||||
}
|
||||
53
tests/serialize/python_reader.py
Normal file
53
tests/serialize/python_reader.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Verify the public format through an independent Python implementation."""
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
"""Check native/text equality and CSV output using files produced by C++."""
|
||||
sys.dont_write_bytecode = True
|
||||
writer, reader_path = sys.argv[1:]
|
||||
spec = importlib.util.spec_from_file_location("serif_archive", reader_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory(prefix="serif-python-reader-") as directory:
|
||||
subprocess.run([writer, directory], check=True)
|
||||
representations = []
|
||||
for encoding in ("binary", "text"):
|
||||
path = Path(directory) / f"{encoding}.serif"
|
||||
with module.Archive(path) as archive:
|
||||
values = archive.read("values")
|
||||
assert len(values) == 5
|
||||
assert math.copysign(1.0, values[0]) < 0
|
||||
assert values[1] == 1.2345678901234567
|
||||
assert values[2] == float.fromhex("0x0.0000000000001p-1022")
|
||||
assert values[3] == math.inf
|
||||
assert struct.pack("<d", values[4]).hex() == "bc9a78563412f87f"
|
||||
assert archive.read("integers") == [-(1 << 63), (1 << 63) - 1]
|
||||
assert archive.read("label") == 'ξ\n"quote"\\end'
|
||||
assert archive.read("empty") == []
|
||||
assert archive.read("provenance/author") == "Cross-language regression"
|
||||
representations.append(struct.pack("<5d", *values))
|
||||
csv = subprocess.run([sys.executable, reader_path, str(path), "--read", "values", "--csv"], check=True, capture_output=True, text=True).stdout
|
||||
assert csv.startswith("index,value\n0,-0.0\n")
|
||||
damaged = bytearray(path.read_bytes())
|
||||
damaged[550] ^= 1
|
||||
path.write_bytes(damaged)
|
||||
try:
|
||||
module.Archive(path)
|
||||
except ValueError as error:
|
||||
assert "ledger" in str(error)
|
||||
else:
|
||||
raise AssertionError("Corrupted ledger was accepted")
|
||||
assert representations[0] == representations[1]
|
||||
print("Python reader: binary/text exact values, provenance, CSV and corruption checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
134
tests/serialize/restart.cpp
Normal file
134
tests/serialize/restart.cpp
Normal file
@@ -0,0 +1,134 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "mfem/general/communication.hpp"
|
||||
#include "serif/backend/mfem/serialize/configuration.hpp"
|
||||
#include "serif/solver/equilibrium.hpp"
|
||||
#include "serif/backend/mfem/solver/seed/state.hpp"
|
||||
#include "serif/solver/seed/polytrope.hpp"
|
||||
#include "serif/tests/solver/stellar_fixture.hpp"
|
||||
#include "serif/utils/serialize/archive.hpp"
|
||||
#include "serif/utils/serialize/solver.hpp"
|
||||
|
||||
namespace {
|
||||
namespace serialization = serif::utils::serialize;
|
||||
namespace backend_serialization = serif::backend::mfem::serialize;
|
||||
namespace solver = serif::solver;
|
||||
namespace fixture = serif::tests::solver;
|
||||
|
||||
/** @brief Keep independently running tests from sharing an archive and remove it on every exit path. */
|
||||
struct TemporaryArchive {
|
||||
std::filesystem::path path = std::filesystem::temp_directory_path() /
|
||||
("serif-newton-restart-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".serif");
|
||||
/** @brief Test failures must not leave temporary scientific files behind. */
|
||||
~TemporaryArchive() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("A moving stellar checkpoint survives destruction, file loading and Newton continuation", "[serialize][restart][moving_restart]") {
|
||||
if (!::mfem::Mpi::IsInitialized()) {
|
||||
::mfem::Mpi::Init();
|
||||
}
|
||||
using Model = decltype(fixture::make_model());
|
||||
std::weak_ptr<const void> original_resources;
|
||||
const auto saved = [&] {
|
||||
const Model model = fixture::make_model();
|
||||
const auto radial = solver::seed::make_polytropic_seed(model);
|
||||
REQUIRE(radial);
|
||||
const auto finite_elements = serif::model::make_stellar_discretization(model, {.radius = radial->scales.radius},
|
||||
serif::geometry::deformation::DomainDeformation<serif::geometry::deformation::SphericalHarmonicSurface>{
|
||||
.surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}});
|
||||
original_resources = finite_elements.owner;
|
||||
const auto initial = solver::seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial);
|
||||
REQUIRE(initial);
|
||||
const auto scales = solver::normalization::prepare_stellar_reference_scales(
|
||||
serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0});
|
||||
REQUIRE(scales);
|
||||
const solver::normalization::NormalizationOptions normalization{.scheme = *scales};
|
||||
const auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{
|
||||
.model = model, .finite_elements = finite_elements, .normalization = normalization});
|
||||
const solver::SolverOptions options{.method = {.relative_tolerance = 1.0e-8, .absolute_tolerance = 1.0e-10,
|
||||
.maximum_iterations = 15, .linear_solve = {.relative_tolerance = 1.0e-3, .maximum_iterations = 200}}};
|
||||
const auto uninterrupted = solver::solve_equilibrium(configuration, *initial, options);
|
||||
INFO(uninterrupted.message());
|
||||
REQUIRE(uninterrupted.converged());
|
||||
const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) {
|
||||
return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve;
|
||||
};
|
||||
const auto interrupted = solver::solve_equilibrium(configuration, *initial, options, stop);
|
||||
INFO(interrupted.message());
|
||||
REQUIRE(interrupted.status() == solver::SolveStatus::stopped);
|
||||
REQUIRE(interrupted.has_checkpoint());
|
||||
CHECK(interrupted.diagnostics().accepted_iterations == 1);
|
||||
const auto checkpoint = interrupted.checkpoint();
|
||||
CHECK(std::ranges::any_of(checkpoint.state.template get<fixture::Shape>(), [](double value) { return value != 0.0; }));
|
||||
auto contents = serialization::pack(configuration, options, interrupted);
|
||||
return std::tuple{std::move(contents), uninterrupted.state(), uninterrupted.diagnostics(),
|
||||
std::type_identity<std::remove_cvref_t<decltype(configuration)>>{}, std::type_identity<std::remove_cvref_t<decltype(options)>>{}};
|
||||
}();
|
||||
REQUIRE(original_resources.expired());
|
||||
using State = std::remove_cvref_t<decltype(std::get<1>(saved))>;
|
||||
using Checkpoint = solver::NewtonCheckpoint<State>;
|
||||
using Configuration = typename std::remove_cvref_t<decltype(std::get<3>(saved))>::type;
|
||||
using Options = typename std::remove_cvref_t<decltype(std::get<4>(saved))>::type;
|
||||
const TemporaryArchive archive;
|
||||
const auto written = serialization::write(archive.path, std::get<0>(saved), {.author = "SERiF regression", .comment = "Accepted moving-star Newton boundary"});
|
||||
REQUIRE(written);
|
||||
auto reader = serialization::Reader::open(archive.path);
|
||||
REQUIRE(reader);
|
||||
const auto checkpoint = reader->read<Checkpoint>("solution/checkpoint");
|
||||
if (!checkpoint) {
|
||||
INFO(checkpoint.error().path << ": " << checkpoint.error().message);
|
||||
}
|
||||
REQUIRE(checkpoint);
|
||||
const TemporaryArchive text_archive;
|
||||
REQUIRE(serialization::write(text_archive.path, serialization::pack(*checkpoint), {.format = serialization::Format::text}));
|
||||
auto text_reader = serialization::Reader::open(text_archive.path);
|
||||
REQUIRE(text_reader);
|
||||
const auto text_checkpoint = text_reader->read<Checkpoint>();
|
||||
REQUIRE(text_checkpoint);
|
||||
CHECK(serialization::encode(*text_checkpoint) == serialization::encode(*checkpoint));
|
||||
const auto configuration_node = reader->node("configuration");
|
||||
REQUIRE(configuration_node);
|
||||
const auto configuration = backend_serialization::restore_configuration<Configuration>(*configuration_node);
|
||||
if (!configuration) {
|
||||
INFO(configuration.error().path << ": " << configuration.error().message);
|
||||
}
|
||||
REQUIRE(configuration);
|
||||
const auto options = reader->read<Options>();
|
||||
REQUIRE(options);
|
||||
const auto resumed = solver::resume_equilibrium(*configuration, *checkpoint, *options);
|
||||
INFO(resumed.message());
|
||||
REQUIRE(resumed.converged());
|
||||
const State& expected = std::get<1>(saved);
|
||||
REQUIRE(resumed.state().values().size() == expected.values().size());
|
||||
for (std::size_t index = 0; index < expected.values().size(); ++index) {
|
||||
CHECK(resumed.state().values()[index] == Catch::Approx(expected.values()[index]).margin(2.0e-10).epsilon(2.0e-10));
|
||||
}
|
||||
const solver::SolveDiagnostics& diagnostics = std::get<2>(saved);
|
||||
CHECK(resumed.diagnostics().initial_residual_norm == diagnostics.initial_residual_norm);
|
||||
CHECK(resumed.diagnostics().convergence_threshold == diagnostics.convergence_threshold);
|
||||
CHECK(resumed.diagnostics().accepted_iterations == diagnostics.accepted_iterations);
|
||||
CHECK(resumed.diagnostics().line_search_trials == diagnostics.line_search_trials);
|
||||
CHECK(resumed.diagnostics().rejected_trials == diagnostics.rejected_trials);
|
||||
CHECK(resumed.diagnostics().total_linear_iterations == diagnostics.total_linear_iterations);
|
||||
CHECK(reader->contains("solution/model"));
|
||||
CHECK(reader->contains("solution/reference_scales"));
|
||||
CHECK(reader->contains("solution/coordinates"));
|
||||
CHECK(reader->read<Checkpoint>("solution/checkpoint"));
|
||||
auto corrupted = serialization::encode(*checkpoint);
|
||||
corrupted.children.at("progress").children.at("correction") = serialization::encode(std::vector<double>{});
|
||||
CHECK_FALSE(serialization::decode<Checkpoint>(corrupted));
|
||||
corrupted = serialization::encode(*checkpoint);
|
||||
corrupted.children.at("strategy") = serialization::encode(std::string{"unknown_stateful_strategy"});
|
||||
CHECK_FALSE(serialization::decode<Checkpoint>(corrupted));
|
||||
}
|
||||
267
tests/serialize/top.cpp
Normal file
267
tests/serialize/top.cpp
Normal file
@@ -0,0 +1,267 @@
|
||||
#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);
|
||||
}
|
||||
366
tests/serialize/top_mfem.cpp
Normal file
366
tests/serialize/top_mfem.cpp
Normal file
@@ -0,0 +1,366 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <numbers>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/backend/mfem/discretization/field_values.hpp"
|
||||
#include "serif/backend/mfem/serialize/resources.hpp"
|
||||
#include "serif/backend/mfem/serialize/top.hpp"
|
||||
#include "serif/backend/mfem/solver/seed/state.hpp"
|
||||
#include "serif/model/finite_elements.hpp"
|
||||
#include "serif/solver/seed/polytrope.hpp"
|
||||
#include "serif/tests/solver/stellar_fixture.hpp"
|
||||
#include "../sandbox/stellar_rayfem.hpp"
|
||||
|
||||
namespace {
|
||||
namespace fixture = serif::tests::solver;
|
||||
namespace serialization = serif::utils::serialize;
|
||||
namespace native_archive = serif::backend::mfem::serialize;
|
||||
namespace seed = serif::solver::seed;
|
||||
|
||||
/** @brief Supply the exact zero displacement and accepted shape used by a manufactured display state. */
|
||||
struct StationaryDisplayCache {
|
||||
struct ShapeSnapshot { std::span<const double> parameters; } linearization;
|
||||
::mfem::Vector displacement;
|
||||
|
||||
/** @brief Expose only the two accepted-state records consumed by the display adapter. */
|
||||
template <typename Key>
|
||||
[[nodiscard]] const auto& get() const {
|
||||
if constexpr (std::same_as<Key, serif::geometry::deformation::keys::Linearization>) {
|
||||
return linearization;
|
||||
} else {
|
||||
static_assert(std::same_as<Key, serif::geometry::deformation::backend::mfem::volume_displacement_keys::Coefficients>);
|
||||
return displacement;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief Keep successful exports and rejected partial files within a test's lifetime. */
|
||||
struct ExportDirectory {
|
||||
std::filesystem::path path = std::filesystem::temp_directory_path() /
|
||||
("serif-top-mfem-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
~ExportDirectory() { std::error_code error; std::filesystem::remove_all(path, error); }
|
||||
};
|
||||
|
||||
/** @brief Small sampling grids test the adapter; their coarse P2 density is not a seismic accuracy benchmark. */
|
||||
serialization::TopExportOptions small_export() {
|
||||
return {.radial_points = 12, .maximum_degree = 4, .angular_points = 12, .azimuthal_points = 4,
|
||||
.relative_symmetry_tolerance = 3.0e-2, .relative_surface_tolerance = 1.0e-6,
|
||||
.relative_surface_projection_tolerance = 1.0e-3};
|
||||
}
|
||||
|
||||
/** @brief Evaluate the original FE field at the center independently of the exporter and seed scales. */
|
||||
template <typename Discretization, typename State>
|
||||
double central_density(const Discretization& finite_elements, const State& state) {
|
||||
const auto& binding = finite_elements.template binding<fixture::Density>();
|
||||
const auto values = state.template get<fixture::Density>();
|
||||
::mfem::Vector reduced(static_cast<int>(values.size()));
|
||||
std::ranges::copy(values, reduced.GetData());
|
||||
::mfem::GridFunction density(&binding.space());
|
||||
density = binding.scatter(reduced);
|
||||
::mfem::Vector center(3);
|
||||
center = 0.0;
|
||||
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
|
||||
if (!binding.supports(element)) continue;
|
||||
::mfem::InverseElementTransformation inverse(binding.mesh().GetElementTransformation(element));
|
||||
::mfem::IntegrationPoint point;
|
||||
if (inverse.Transform(center, point) == ::mfem::InverseElementTransformation::Inside)
|
||||
return density.GetValue(element, point);
|
||||
}
|
||||
FAIL("The independently sampled stellar center must lie in a material element.");
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** @brief Input preservation includes signed zero, as well as all physical and reference mesh coordinates. */
|
||||
template <typename State>
|
||||
void check_state_unchanged(const State& state, const std::vector<double>& original) {
|
||||
REQUIRE(state.values().size() == original.size());
|
||||
CHECK(std::ranges::equal(state.values(), original, [](double a, double b) {
|
||||
return std::bit_cast<std::uint64_t>(a) == std::bit_cast<std::uint64_t>(b);
|
||||
}));
|
||||
}
|
||||
|
||||
/** @brief Read the TOP scalar file independently of the serializer's returned report. */
|
||||
std::array<double, 9> scalar_file(const std::filesystem::path& directory) {
|
||||
std::ifstream input(directory / "lambda_eps");
|
||||
REQUIRE(input.good());
|
||||
std::array<double, 9> values{};
|
||||
for (double& value : values) {
|
||||
REQUIRE(static_cast<bool>(input >> value));
|
||||
REQUIRE(std::isfinite(value));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("TOP exports the current deformed FE star without changing its state or either mesh", "[serialize][top_export][mfem]") {
|
||||
constexpr double G = 0.7, K = 0.3, mass = 1.4;
|
||||
const auto model = fixture::make_model(1.0, K, mass, 0.03, G);
|
||||
const auto radial = seed::make_polytropic_seed(model);
|
||||
REQUIRE(radial);
|
||||
const auto resources = fixture::make_resources(radial->scales.radius.value());
|
||||
const auto finite_elements = fixture::make_discretization(resources, 0);
|
||||
auto state = seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial);
|
||||
REQUIRE(state);
|
||||
// This is a valid manufactured state, not a claimed equilibrium. A nonzero
|
||||
// monopole makes using the undeformed source mesh detectably wrong.
|
||||
state->template get<fixture::Shape>()[0] = 0.02 * radial->scales.radius.value();
|
||||
const double rho_c = central_density(finite_elements, *state);
|
||||
REQUIRE(rho_c > 0.0);
|
||||
const std::vector<double> original_state(state->values().begin(), state->values().end());
|
||||
const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh);
|
||||
const auto original_reference = native_archive::mesh_text(*resources->mesh.reference_mesh);
|
||||
const ExportDirectory output;
|
||||
const auto result = serialization::export_top(output.path, model, finite_elements, *state, small_export());
|
||||
INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message));
|
||||
check_state_unchanged(*state, original_state);
|
||||
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
|
||||
CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference);
|
||||
REQUIRE(result);
|
||||
CHECK(result->equatorial_radius > radial->scales.radius.value() * 1.003);
|
||||
CHECK(result->polar_radius == Catch::Approx(result->equatorial_radius).epsilon(5.0e-4));
|
||||
CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * G * rho_c)).epsilon(2.0e-10));
|
||||
// At n=1 the density cancels: this tests recovery of both physical G and K.
|
||||
CHECK(result->lambda == Catch::Approx(4.0 * std::numbers::pi * G * std::pow(result->equatorial_radius, 2) / (2.0 * K)).epsilon(2.0e-12));
|
||||
const auto written = scalar_file(output.path);
|
||||
CHECK(written[0] == result->lambda);
|
||||
CHECK(written[2] == result->alpha);
|
||||
CHECK(written[4] == 1.0);
|
||||
CHECK(written[8] == small_export().radial_points);
|
||||
CHECK(written[7] / std::sqrt(3.0 * written[2]) ==
|
||||
Catch::Approx(state->template get<fixture::Omega>()[0] / result->angular_frequency_unit).epsilon(2.0e-12));
|
||||
CHECK(std::filesystem::file_size(output.path / "enthalpy_et_der") > 0);
|
||||
CHECK(std::filesystem::file_size(output.path / "domains_boundaries") > 0);
|
||||
// The EOS and current density determine the export. A different unsatisfied
|
||||
// mass prescription must not change TOP's background normalization.
|
||||
const auto other_mass = fixture::make_model(1.0, K, 3.0 * mass, 0.03, G);
|
||||
const ExportDirectory other_output;
|
||||
const auto other = serialization::export_top(other_output.path, other_mass, finite_elements, *state, small_export());
|
||||
INFO((other ? "Changed-prescription export succeeded" : other.error().path + ": " + other.error().message));
|
||||
REQUIRE(other);
|
||||
CHECK(other->alpha == result->alpha);
|
||||
CHECK(other->rotation_over_kepler == result->rotation_over_kepler);
|
||||
check_state_unchanged(*state, original_state);
|
||||
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
|
||||
CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference);
|
||||
}
|
||||
|
||||
TEST_CASE("TOP sampling honors a tilted rotation axis on a surface and equator refined true-DOF mesh", "[serialize][top_export][mfem][nonconforming]") {
|
||||
namespace model = serif::model;
|
||||
namespace deformation = serif::geometry::deformation;
|
||||
const auto base = fixture::make_model();
|
||||
const auto star = model::make_stellar_model(base.equation_of_state(), base.equations(),
|
||||
model::FixedTotalMass{serif::dimensions::MassValue{1.0}},
|
||||
model::FixedAngularMomentum{model::FixedAngularMomentum::Parameters{
|
||||
.angular_momentum = serif::dimensions::AngularMomentumValue{0.04}, .axis = {1.0, 2.0, 3.0}}});
|
||||
const auto& axis = star.specification<model::FixedAngularMomentum>().axis();
|
||||
const auto radial = seed::make_polytropic_seed(star);
|
||||
REQUIRE(radial);
|
||||
const auto finite_elements = model::make_stellar_discretization(star,
|
||||
{.radius = radial->scales.radius, .geometry_order = 6,
|
||||
.surface_refinement = {.levels = 1, .equatorial_levels = 1, .axis = axis}},
|
||||
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
|
||||
.surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}});
|
||||
const auto& binding = finite_elements.template binding<fixture::Density>();
|
||||
REQUIRE(binding.space().GetTrueVSize() < binding.space().GetVSize());
|
||||
REQUIRE(binding.constrained());
|
||||
auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
|
||||
REQUIRE(state);
|
||||
// A positive quadrupolar perturbation makes the chosen axis observable:
|
||||
// treating this field as axisymmetric about z must not silently pass.
|
||||
const double radius = radial->scales.radius.value();
|
||||
::mfem::FunctionCoefficient prescribed_density([&](const ::mfem::Vector& position) {
|
||||
const double r = position.Norml2();
|
||||
if (r >= radius) return 0.0;
|
||||
const double x = std::numbers::pi * r / radius;
|
||||
const double profile = (x == 0.0 ? 1.0 : std::sin(x) / x) * radial->scales.central_density.value();
|
||||
double axial = 0.0;
|
||||
for (int component = 0; component < 3; ++component) axial += axis[component] * position[component];
|
||||
return profile * (1.0 + 0.6 * (3.0 * axial * axial - r * r) / (radius * radius));
|
||||
});
|
||||
::mfem::GridFunction density(&binding.space());
|
||||
density.ProjectCoefficient(prescribed_density);
|
||||
const auto reduced = binding.gather(density);
|
||||
std::copy_n(reduced.GetData(), reduced.Size(), state->template get<fixture::Density>().begin());
|
||||
const auto trace = serif::discretization::backend::mfem::select_boundary_dofs<
|
||||
serif::discretization::domain::StellarSurfaceBoundary>(binding);
|
||||
for (const int dof : trace.reduced_dofs()) state->template get<fixture::Density>()[dof] = 0.0;
|
||||
const std::vector<double> original_state(state->values().begin(), state->values().end());
|
||||
const auto original_mesh = native_archive::mesh_text(binding.mesh());
|
||||
const ExportDirectory output;
|
||||
const auto result = serialization::export_top(output.path, star, finite_elements, *state, small_export());
|
||||
INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message));
|
||||
check_state_unchanged(*state, original_state);
|
||||
CHECK(native_archive::mesh_text(binding.mesh()) == original_mesh);
|
||||
REQUIRE(result);
|
||||
CHECK(result->polar_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4));
|
||||
CHECK(result->equatorial_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4));
|
||||
CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * central_density(finite_elements, *state))).epsilon(2.0e-10));
|
||||
const auto written = scalar_file(output.path);
|
||||
CHECK(written[7] / std::sqrt(3.0 * written[2]) ==
|
||||
Catch::Approx(state->template get<fixture::Omega>()[0] / result->angular_frequency_unit).epsilon(2.0e-12));
|
||||
const auto wrong_axis = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.04);
|
||||
const ExportDirectory wrong_output;
|
||||
const auto rejected = serialization::export_top(wrong_output.path, wrong_axis, finite_elements, *state, small_export());
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK_FALSE(std::filesystem::exists(wrong_output.path / "lambda_eps"));
|
||||
}
|
||||
|
||||
TEST_CASE("TOP rejects a finite pressure stellar boundary before publishing vacuum polytrope files", "[serialize][top_export][mfem]") {
|
||||
const auto vacuum = fixture::make_model();
|
||||
const auto radial = seed::make_polytropic_seed(vacuum);
|
||||
REQUIRE(radial);
|
||||
const auto resources = fixture::make_resources(radial->scales.radius.value());
|
||||
const auto finite_elements = fixture::make_discretization(resources, 0);
|
||||
const auto state = seed::backend::mfem::make_polytropic_state(vacuum, finite_elements, *radial);
|
||||
REQUIRE(state);
|
||||
const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01);
|
||||
const std::vector<double> original_state(state->values().begin(), state->values().end());
|
||||
const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh);
|
||||
const ExportDirectory output;
|
||||
const auto result = serialization::export_top(output.path, pressured, finite_elements, *state, small_export());
|
||||
REQUIRE_FALSE(result);
|
||||
CHECK(result.error().message.find("pressure") != std::string::npos);
|
||||
CHECK_FALSE(std::filesystem::exists(output.path / "lambda_eps"));
|
||||
check_state_unchanged(*state, original_state);
|
||||
CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh);
|
||||
}
|
||||
|
||||
TEST_CASE("FieldValues samples reconstructed density and its correctly mapped gradient", "[serialize][mfem][reconstruction]") {
|
||||
namespace discretization = serif::discretization;
|
||||
namespace backend = discretization::backend::mfem;
|
||||
using Density = discretization::blocks::density::mass;
|
||||
using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
|
||||
constexpr double exponent = 4.0 / 3.0;
|
||||
|
||||
::mfem::Mesh mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, ::mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0);
|
||||
for (int face = 0; face < mesh.GetNBE(); ++face) {
|
||||
auto& transformation = *mesh.GetBdrElementTransformation(face);
|
||||
::mfem::Vector center(3);
|
||||
transformation.Transform(::mfem::Geometries.GetCenter(transformation.GetGeometryType()), center);
|
||||
mesh.GetBdrElement(face)->SetAttribute(center[0] == 0.0 ? 1 : 2);
|
||||
}
|
||||
::mfem::H1_FECollection collection(discretization::blocks::SpatialTermTraits<Density>::order, 3);
|
||||
::mfem::FiniteElementSpace space(&mesh, &collection);
|
||||
backend::TermBinding<Density, Schema> ordinary(space);
|
||||
auto plan = std::make_shared<backend::ReconstructionPlan>(ordinary, 1,
|
||||
std::make_shared<discretization::reconstruction::SurfacePowerProfile>(exponent));
|
||||
backend::TermBinding<Density, Schema> reconstructed(space, plan);
|
||||
::mfem::FunctionCoefficient profile([](const ::mfem::Vector& position) {
|
||||
return std::pow(position[0] / 2.0, exponent) * (1.0 + position[1]) * (2.0 - position[2]);
|
||||
});
|
||||
::mfem::GridFunction nodal(&space);
|
||||
nodal.ProjectCoefficient(profile);
|
||||
const ::mfem::Vector coefficients = reconstructed.gather(nodal);
|
||||
backend::FieldValues<Density> values(reconstructed, coefficients);
|
||||
backend::FieldValues<Density> polynomial(ordinary, coefficients);
|
||||
CHECK_THROWS(backend::FieldValues<discretization::blocks::displacement::vector>(ordinary, coefficients));
|
||||
::mfem::IntegrationPoint point;
|
||||
point.Set3(0.2, 0.3, 0.4);
|
||||
const double expected = std::pow(point.x, exponent) * 1.9 * 0.4;
|
||||
CHECK(values.value(0, point) == Catch::Approx(expected).margin(2.0e-13));
|
||||
CHECK(std::abs(polynomial.value(0, point) - expected) > 1.0e-4);
|
||||
CHECK(polynomial.value(0, point) == Catch::Approx(nodal.GetValue(0, point)).margin(2.0e-13));
|
||||
const auto gradient = values.gradient(0, point);
|
||||
CHECK(gradient[0] == Catch::Approx(exponent * std::pow(point.x, exponent - 1.0) * 1.9 * 0.4 / 2.0).margin(2.0e-13));
|
||||
CHECK(gradient[1] == Catch::Approx(std::pow(point.x, exponent) * 0.4).margin(2.0e-13));
|
||||
CHECK(gradient[2] == Catch::Approx(-std::pow(point.x, exponent) * 1.9).margin(2.0e-13));
|
||||
serif::mapping::MappingPointContext dilation;
|
||||
dilation.inverse_mapping_jacobian.SetSize(3);
|
||||
dilation.inverse_mapping_jacobian = 0.0;
|
||||
for (int axis = 0; axis < 3; ++axis) dilation.inverse_mapping_jacobian(axis, axis) = 0.5;
|
||||
const auto physical_gradient = values.gradient(0, point, dilation);
|
||||
for (int axis = 0; axis < 3; ++axis) CHECK(physical_gradient[axis] == Catch::Approx(0.5 * gradient[axis]).margin(2.0e-13));
|
||||
CHECK(values.value(0, point, dilation) == Catch::Approx(expected).margin(2.0e-13));
|
||||
const auto& points = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 8);
|
||||
::mfem::Vector batch;
|
||||
values.values(0, points, batch);
|
||||
REQUIRE(batch.Size() == points.GetNPoints());
|
||||
for (int sample = 0; sample < points.GetNPoints(); ++sample) {
|
||||
const auto& at = points.IntPoint(sample);
|
||||
CHECK(batch[sample] == Catch::Approx(std::pow(at.x, exponent) * (1.0 + 3.0 * at.y) * (2.0 - 4.0 * at.z)).margin(2.0e-12));
|
||||
}
|
||||
point.x = 0.0;
|
||||
CHECK(values.value(0, point) == Catch::Approx(0.0).margin(2.0e-13));
|
||||
for (double component : values.gradient(0, point)) CHECK(component == Catch::Approx(0.0).margin(2.0e-13));
|
||||
}
|
||||
|
||||
TEST_CASE("TOP samples the reconstructed field between its density nodes", "[serialize][top_export][mfem][reconstruction]") {
|
||||
namespace model = serif::model;
|
||||
namespace deformation = serif::geometry::deformation;
|
||||
namespace discretization = serif::discretization;
|
||||
namespace backend = discretization::backend::mfem;
|
||||
const auto star = fixture::make_model(4.0 / 3.0);
|
||||
const auto radial = seed::make_polytropic_seed(star);
|
||||
REQUIRE(radial);
|
||||
const auto finite_elements = model::make_stellar_discretization(star,
|
||||
{.radius = radial->scales.radius, .geometry_order = 6},
|
||||
deformation::DomainDeformation<deformation::SphericalHarmonicSurface>{
|
||||
.surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}},
|
||||
discretization::reconstruction::SurfacePower<fixture::Density>{.density_exponent = 4.0 / 3.0});
|
||||
const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
|
||||
REQUIRE(state);
|
||||
const auto& binding = finite_elements.template binding<fixture::Density>();
|
||||
const auto density = state->template get<fixture::Density>();
|
||||
::mfem::Vector coefficients(static_cast<int>(density.size()));
|
||||
std::ranges::copy(density, coefficients.GetData());
|
||||
backend::FieldValues<fixture::Density> values(binding, coefficients);
|
||||
::mfem::GridFunction polynomial(&binding.space());
|
||||
polynomial = binding.scatter(coefficients);
|
||||
const std::vector<double> displacement(finite_elements.deformation.reference.displacement_space.GetVSize(), 0.0);
|
||||
using Binding = std::remove_cvref_t<decltype(binding)>;
|
||||
auto temporary_binding = std::make_unique<Binding>(binding.space(), binding.reconstruction());
|
||||
serif::backend::mfem::serialize::detail::TopModelSampler sampler(*temporary_binding, density,
|
||||
finite_elements.deformation.reference, displacement, {0.0, 0.0, 1.0}, {0.0, 0.0, 0.0});
|
||||
temporary_binding.reset();
|
||||
int selected = -1;
|
||||
for (int element = 0; element < binding.mesh().GetNE(); ++element) {
|
||||
if (binding.reconstruction_element(element) != nullptr) { selected = element; break; }
|
||||
}
|
||||
REQUIRE(selected >= 0);
|
||||
::mfem::IntegrationPoint point;
|
||||
point.Set3(0.27, 0.41, 0.69);
|
||||
::mfem::Vector position(3);
|
||||
binding.mesh().GetElementTransformation(selected)->Transform(point, position);
|
||||
const double radius = position.Norml2();
|
||||
const double expected = values.value(selected, point);
|
||||
REQUIRE(std::abs(expected - polynomial.GetValue(selected, point)) > 1.0e-8 * radial->scales.central_density.value());
|
||||
const auto exported = sampler.density(radius, position[2] / radius, std::atan2(position[1], position[0]));
|
||||
INFO((exported ? "Sample succeeded" : exported.error().message));
|
||||
REQUIRE(exported);
|
||||
CHECK(*exported == Catch::Approx(expected).epsilon(2.0e-10));
|
||||
|
||||
// Rayfem receives a display approximation sampled from the same physical field.
|
||||
// This checks the parent/submesh coordinate correspondence without opening a viewer.
|
||||
serif::visualization::backend::mfem::StellarFrame frame(finite_elements);
|
||||
StationaryDisplayCache cache{.linearization = {state->template get<fixture::Shape>()},
|
||||
.displacement = ::mfem::Vector(finite_elements.template binding<fixture::Displacement>().size())};
|
||||
cache.displacement = 0.0;
|
||||
frame.update(*state, cache);
|
||||
const auto fields = frame.fields();
|
||||
REQUIRE(fields.front().name == "density (sampled)");
|
||||
const auto& display = *fields.front().gf;
|
||||
CHECK(display.FESpace()->GetMaxElementOrder() == binding.space().GetMaxElementOrder() + 3);
|
||||
const auto& parent_elements = static_cast<const ::mfem::SubMesh&>(frame.mesh()).GetParentElementIDMap();
|
||||
bool displayed = false;
|
||||
for (int element = 0; element < parent_elements.Size(); ++element) {
|
||||
if (parent_elements[element] != selected) continue;
|
||||
displayed = true;
|
||||
const auto& nodes = display.FESpace()->GetFE(element)->GetNodes();
|
||||
for (int node = 0; node < nodes.GetNPoints(); ++node) {
|
||||
const auto& at = nodes.IntPoint(node);
|
||||
CHECK(display.GetValue(element, at) == Catch::Approx(values.value(selected, at)).margin(3.0e-12 * radial->scales.central_density.value()));
|
||||
}
|
||||
}
|
||||
REQUIRE(displayed);
|
||||
}
|
||||
78
tests/serialize/top_reader.py
Normal file
78
tests/serialize/top_reader.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Optional integration test: run with the recovered TOP Python environment.
|
||||
|
||||
Usage: python tests/serialize/top_reader.py build/tests/serialize/top_export_fixture
|
||||
The compiled eq_poly_ester_gamma2 module must be available to top.load().
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import top
|
||||
|
||||
|
||||
def read_model(directory):
|
||||
dati = directory / 'dati'
|
||||
dati.write_text('nt = 1\nlres = 40\niparity = 0\niCor = 1\n'
|
||||
'dertype = CHEB\norderFD = 2\nmattype = FULL\n'
|
||||
'nsol = 1\nshift = 0.6\nrootdir = ./\nm = 0\ntagdir = test\n')
|
||||
os.chdir(directory)
|
||||
solver = top.load('eq_poly_ester_gamma2')
|
||||
solver.read_dati(str(dati))
|
||||
model = solver.init_model(str(directory) + '/')
|
||||
return solver, model
|
||||
|
||||
|
||||
def verify(directory, deformed):
|
||||
solver, model = read_model(directory)
|
||||
radius, theta = solver.get_grid()
|
||||
z = solver.get_zeta()[:, None]
|
||||
mu = np.cos(theta)[None, :]
|
||||
if deformed:
|
||||
rs = 1.0 - 0.2 * mu**2
|
||||
a = 0.5 * (5.0*z**3 - 3.0*z**5)
|
||||
expected_radius = 0.8*z + a*(rs-0.8)
|
||||
p2 = 0.5*(3.0*mu**2-1.0)
|
||||
h = (1-z*z)*(1+0.1*z*z*p2)
|
||||
hz = -2*z + 0.1*p2*(2*z-4*z**3)
|
||||
hzz = -2 + 0.1*p2*(2-12*z*z)
|
||||
else:
|
||||
expected_radius = np.broadcast_to(z, radius.shape)
|
||||
h = np.broadcast_to(np.sinc(z), radius.shape)
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
hz = np.pi*np.cos(np.pi*z)/(np.pi*z) - np.sin(np.pi*z)/(np.pi*z*z)
|
||||
hzz = -np.pi*np.sin(np.pi*z)/z - 2*np.cos(np.pi*z)/(z*z) + 2*np.sin(np.pi*z)/(np.pi*z**3)
|
||||
hz[0] = 0.0
|
||||
hzz[0] = -np.pi**2/3
|
||||
hz = np.broadcast_to(hz, radius.shape)
|
||||
hzz = np.broadcast_to(hzz, radius.shape)
|
||||
np.testing.assert_allclose(radius, expected_radius, rtol=0, atol=2e-12)
|
||||
np.testing.assert_allclose(model['hh'], h, rtol=0, atol=3e-12)
|
||||
np.testing.assert_allclose(model['hhz'], hz, rtol=0, atol=3e-9)
|
||||
np.testing.assert_allclose(model['hhzz'], hzz, rtol=0, atol=2e-5)
|
||||
np.testing.assert_allclose(model['lambda'], np.pi**2, rtol=3e-13)
|
||||
np.testing.assert_allclose(model['omga'], 0.2/np.pi if deformed else 0.0, rtol=3e-13, atol=1e-15)
|
||||
if not deformed:
|
||||
result = solver.run_arncheb(0.6)
|
||||
assert result.nsol == 1
|
||||
np.testing.assert_allclose(result.valps[0], 0.6168010124, rtol=0, atol=2e-8)
|
||||
return float(result.valps[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(__doc__)
|
||||
executable = Path(sys.argv[1]).resolve()
|
||||
original_directory = Path.cwd()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='serif-top-reader-') as tmp:
|
||||
directory = Path(tmp)
|
||||
subprocess.run([str(executable), str(directory)], check=True)
|
||||
frequency = verify(directory / 'spherical', False)
|
||||
verify(directory / 'deformed', True)
|
||||
print(json.dumps({'reader_checks': 'passed', 'radial_frequency_top_units': frequency}))
|
||||
finally:
|
||||
os.chdir(original_directory)
|
||||
27
tests/serialize/write_fixture.cpp
Normal file
27
tests/serialize/write_fixture.cpp
Normal file
@@ -0,0 +1,27 @@
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <print>
|
||||
|
||||
#include "serif/utils/serialize/archive.hpp"
|
||||
|
||||
/** @brief Produce independent C++ fixtures for the dependency-free Python data reader. */
|
||||
int main(int argc, char** argv) {
|
||||
namespace serialization = serif::utils::serialize;
|
||||
if (argc != 2) {
|
||||
return 2;
|
||||
}
|
||||
const auto contents = serialization::pack(serialization::named("values", std::vector<double>{-0.0, 1.2345678901234567,
|
||||
std::numeric_limits<double>::denorm_min(), std::numeric_limits<double>::infinity(),
|
||||
std::bit_cast<double>(0x7ff8123456789abcULL)}), serialization::named("empty", std::vector<double>{}),
|
||||
serialization::named("integers", std::vector<std::int64_t>{std::numeric_limits<std::int64_t>::min(), std::numeric_limits<std::int64_t>::max()}),
|
||||
serialization::named("label", std::string{"ξ\n\"quote\"\\end"}));
|
||||
for (serialization::Format format : {serialization::Format::binary, serialization::Format::text}) {
|
||||
const auto path = std::filesystem::path{argv[1]} / (format == serialization::Format::binary ? "binary.serif" : "text.serif");
|
||||
const auto result = serialization::write(path, contents, {.format = format, .author = "Cross-language regression"});
|
||||
if (!result) {
|
||||
std::println(stderr, "{}: {}", result.error().path, result.error().message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
57
tests/serialize/write_top_fixture.cpp
Normal file
57
tests/serialize/write_top_fixture.cpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <numbers>
|
||||
#include <print>
|
||||
|
||||
#include "serif/utils/serialize/top.hpp"
|
||||
|
||||
/** @brief Write analytic inputs for an optional test against TOP's actual Fortran reader. */
|
||||
int main(int argc, char** argv) {
|
||||
namespace io = serif::utils::serialize;
|
||||
if (argc != 2) {
|
||||
std::println(stderr, "Usage: top_export_fixture OUTPUT_DIRECTORY");
|
||||
return 2;
|
||||
}
|
||||
const std::filesystem::path directory{argv[1]};
|
||||
std::filesystem::create_directories(directory);
|
||||
io::TopPolytropeSource source{
|
||||
.polytropic_index = 1.0, .gravitational_constant = 1.0, .mass = 1.0,
|
||||
.angular_velocity = 0.0, .central_density = std::numbers::pi / 4.0, .central_enthalpy = 1.0,
|
||||
.surface_radius = [](double, double) -> io::Result<double> { return 1.0; },
|
||||
.enthalpy = [](double radius, double, double) -> io::Result<double> {
|
||||
if (radius == 0.0) return 1.0;
|
||||
if (radius == 1.0) return 0.0;
|
||||
return std::sin(std::numbers::pi * radius) / (std::numbers::pi * radius);
|
||||
}
|
||||
};
|
||||
const io::TopExportOptions options{.radial_points = 64, .maximum_degree = 8, .angular_points = 24, .azimuthal_points = 8};
|
||||
const auto spherical = io::export_top(directory / "spherical", source, options);
|
||||
if (!spherical) {
|
||||
std::println(stderr, "{}: {}", spherical.error().path, spherical.error().message);
|
||||
return 1;
|
||||
}
|
||||
source.angular_velocity = 0.2;
|
||||
source.surface_radius = [](double mu, double) -> io::Result<double> { return 1.0 - 0.2 * mu * mu; };
|
||||
source.enthalpy = [](double radius, double mu, double) -> io::Result<double> {
|
||||
const double surface = 1.0 - 0.2 * mu * mu;
|
||||
if (radius == 0.0) return 1.0;
|
||||
if (radius >= surface) return 0.0;
|
||||
double lo = 0.0, hi = 1.0;
|
||||
for (int i = 0; i < 60; ++i) {
|
||||
const double z = 0.5 * (lo + hi);
|
||||
const double a = 0.5 * (5.0 * z * z * z - 3.0 * std::pow(z, 5));
|
||||
if (0.8 * z + a * (surface - 0.8) < radius) lo = z;
|
||||
else hi = z;
|
||||
}
|
||||
const double z = 0.5 * (lo + hi);
|
||||
const double p2 = 0.5 * (3.0 * mu * mu - 1.0);
|
||||
return (1.0 - z * z) * (1.0 + 0.1 * z * z * p2);
|
||||
};
|
||||
const auto deformed = io::export_top(directory / "deformed", source, options);
|
||||
if (!deformed) {
|
||||
std::println(stderr, "{}: {}", deformed.error().path, deformed.error().message);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user