Files
MeanField_SERiF/tests/solver/ester_validation.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
2026-11-09 13:53:17 -05:00

293 lines
17 KiB
C++

#include <algorithm>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <format>
#include <fstream>
#include <iomanip>
#include <numeric>
#include <print>
#include <string>
#include <string_view>
#include <system_error>
#include <type_traits>
#include <stroid/IO/mesh.h>
#include "mfem/general/communication.hpp"
#include "serif/solver/equilibrium.hpp"
#include "serif/solver/seed/state.hpp"
#include "serif/tests/solver/archive.hpp"
#include "serif/tests/solver/physical_summary.hpp"
#include "serif/tests/solver/virial_output.hpp"
namespace {
/** @brief Reject partial numerical arguments rather than silently selecting different physics. */
template <typename Value>
[[nodiscard]] Value argument(std::string_view text) {
Value value{};
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(), "Invalid numerical argument '{}'.", text);
return value;
}
/** @brief Keep failed-solve diagnostics valid JSON when a measurement is not finite. */
[[nodiscard]] std::string number(double value) {
return std::isfinite(value) ? std::format("{:.17g}", value) : "null";
}
/**
* @brief Keep the selected equations separate from the meanings and ordering of their shared coordinates.
* @note Bump the version if equation semantics change without changing the registered equation names.
*/
template <typename Model>
[[nodiscard]] std::string formulation_identity(const Model& star) {
const auto description = serif::model::resolve_equilibrium_equations(star);
std::string identity = "formulation=stellar_equations_v1";
description.interior_equations.for_each([&]<typename Equation>(const Equation&) {
identity += ';';
identity += Equation::name;
});
identity += ";shape=";
identity += std::remove_cvref_t<decltype(description.shape_force)>::name;
return identity;
}
}
/**
* @brief Solve the scalar formulation from a radial seed and measure every archived physical request.
* @note The historical archive supplies mesh topology and sampling requests, never physical field values.
* An optional current-layout checkpoint is independently reevaluated at the declared physics with zero Newton updates.
* Its preparation record must identify the same Kelvin map, mesh, formulation and coefficient layout.
* Legacy or unmarked coordinates require a fresh solve; no convergence status is imported.
* Physical n, K and J are explicit inputs; M=G=1 and P_surface=0 match the reference family.
* Outputs distinguish algebraic convergence from the independent 1e-6 vacuum virial acceptance gate.
* accepted_state.txt remains an algebraic checkpoint; only physical_accepted_state.txt passes both checks.
*/
int main(int argc, char** argv) {
using namespace serif::utils::errors;
if (argc != 9 && argc != 10) {
std::println(stderr, "Usage: ester_validation REPORT CASE OUTPUT n K J NEWTON_CAP KRYLOV_CAP [RECHECK_STATE]");
return 2;
}
#ifdef MFEM_USE_MPI
mfem::Mpi::Init(argc, argv);
#else
std::println(stderr, "ESTER validation requires the default Hypre/MPI stellar inverse.");
return 2;
#endif
namespace fixture = serif::tests::solver;
namespace archive = fixture::archive;
namespace solver = serif::solver;
namespace model = serif::model;
namespace normalization = solver::normalization;
const std::filesystem::path report = argv[1], output = argv[3];
const std::string name = argv[2];
const double n = argument<double>(argv[4]), K = argument<double>(argv[5]), J = argument<double>(argv[6]);
const int updates = argument<int>(argv[7]), krylov = argument<int>(argv[8]);
const bool recheck = argc == 10;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!recheck || updates == 0,
"A saved-state recheck must use zero Newton updates; its convergence is evaluated afresh, never imported.");
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(n) && n >= 1.0 && n < 3.0 &&
std::isfinite(K) && K > 0.0 && std::isfinite(J) && J >= 0.0 && updates >= 0 && updates <= 30 &&
krylov > 0 && krylov <= 600 && !std::filesystem::exists(output),
"Require 1<=n<3, positive K, nonnegative J, bounded iteration limits, and a fresh output directory.");
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::filesystem::create_directories(output), "Cannot create fresh output directory.");
std::ofstream progress(output / "preparation.txt");
progress << std::setprecision(17) << "archive=" << std::quoted(report.string()) << "\nrequest_case=" << std::quoted(name)
<< "\nn=" << n << "\nK=" << K << "\nM=1\nG=1\nJ=" << J << "\nP_surface=0\nnewton_cap=" << updates
<< "\nkrylov_cap=" << krylov << '\n';
// A last stage without 'complete' is an incomplete run, including an exception or process failure.
const auto stage = [&](std::string_view value) {
progress << "stage=" << value << '\n';
progress.flush();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(progress.good(), "Cannot preserve preparation progress.");
};
stage("metadata");
const auto source = report / "inputs" / name;
const auto metadata = archive::metadata(source);
const auto parameter = [&](const char* key) { return std::stod(metadata.at(key)); };
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(parameter("central_phase") == 0.0 && parameter("Psurf") == 0.0 &&
parameter("M") == 1.0 && parameter("G") == 1.0 && parameter("R_reference") == 1.0 &&
parameter("surface_degree") == 4.0 && parameter("rotation_axis_x") == 0.0 && parameter("rotation_axis_y") == 0.0 &&
parameter("rotation_axis_z") == 1.0 && parameter("rotation_center_x") == 0.0 &&
parameter("rotation_center_y") == 0.0 && parameter("rotation_center_z") == 0.0,
"The reference requests must use the documented fixed-M/G vacuum branch and rotation convention.");
stage("mesh_load");
auto mesh = stroid::IO::LoadStroidMesh((source / "mesh.smesh").string());
if (!mesh) {
stage("failed_mesh_load");
std::println(stderr, "Cannot load the reference mesh: {}", mesh.error());
return 2;
}
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(mesh->config.r_star.has_value() && mesh->config.r_infinity.has_value() &&
mesh->config.r_star.value() == 1.0 && mesh->config.r_infinity.value() == 5.0,
"The bounded archive study requires mesh reference radii one and five.");
// Match the existing exterior coordinate's mesh radii. Record this changed map before any state import.
constexpr std::string_view mapping_identity = "mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=5";
progress << mapping_identity << '\n';
stage("mapping_identity");
if (recheck) {
const auto preparation_path = std::filesystem::path(argv[9]).parent_path() / "preparation.txt";
std::ifstream preparation(preparation_path);
archive::require_preparation_identity(preparation, mapping_identity);
}
const double reference_radius = mesh->config.r_star.value();
const double infinity_radius = mesh->config.r_infinity.value();
const auto resources = std::make_shared<fixture::StellarResources>(std::move(*mesh), reference_radius, infinity_radius);
const auto finite_elements = fixture::make_discretization(resources, 4);
const auto star = fixture::make_model(n, K, 1.0, J, 1.0);
const std::string formulation = formulation_identity(star);
progress << formulation << '\n';
stage("formulation_identity");
if (recheck) {
std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt");
archive::require_preparation_identity(preparation, formulation);
}
stage("radial_seed");
const auto radial = solver::seed::make_polytropic_seed(star, {.integration = {.step = 0.001}});
if (!radial) {
stage("failed_radial_seed");
std::println(stderr, "Radial seed failed: {}", radial.error());
return 2;
}
stage("radial_fit");
const auto fitted = solver::seed::fit_radial_seed(finite_elements, *radial);
if (!fitted) {
stage("failed_radial_fit");
std::println(stderr, "Reference-surface fit failed: {}", fitted.error());
return 2;
}
stage("state_projection");
auto initial = solver::seed::make_polytropic_state(star, finite_elements, *fitted);
if (!initial) {
stage("failed_state_projection");
std::println(stderr, "Radial projection failed: {}", initial.error());
return 2;
}
stage("surface_basis");
const auto basis_source = std::filesystem::exists(source / "surface_basis.csv") ? source : report / "inputs" / "n1_J0p05";
const double basis_error = archive::verify_surface_basis(basis_source, finite_elements, *initial);
const std::string coordinates = archive::coordinate_identity(initial->layout(), archive::mesh_identity(source / "mesh.smesh"));
progress << coordinates << '\n';
stage("coordinate_identity");
if (recheck) {
stage("saved_state_recheck");
std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt");
archive::require_preparation_identity(preparation, coordinates);
const auto values = archive::read_coefficients(argv[9], initial->values().size());
std::copy(values.begin(), values.end(), initial->values().begin());
progress << "recheck_state=" << std::quoted(std::filesystem::absolute(argv[9]).string()) << '\n';
progress.flush();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(progress.good(), "Cannot record the saved-state input.");
}
archive::save_coefficients(output / "initial_state.txt", initial->values());
std::ofstream seed_summary(output / "seed.json");
seed_summary << "{\"radial_radius\":" << number(radial->scales.radius.value())
<< ",\"radial_mass\":" << number(radial->scales.mass.value())
<< ",\"radial_central_density\":" << number(radial->scales.central_density.value())
<< ",\"fitted_radius\":" << number(fitted->radius.value()) << ",\"fitted_mass\":" << number(fitted->mass.value()) << "}\n";
seed_summary.close();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(seed_summary.good(), "Cannot write radial-seed diagnostics.");
stage("normalization");
const auto reference = normalization::prepare_stellar_reference_scales(serif::dimensions::MassValue{1.0},
serif::dimensions::LengthValue{1.0}, serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{1.0});
if (!reference) {
stage("failed_normalization");
std::println(stderr, "Normalization failed: {}", reference.error());
return 2;
}
auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = star, .finite_elements = finite_elements,
.normalization = normalization::NormalizationOptions{.scheme = *reference, .coordinates = normalization::Dimensionless{},
.gravitational_constant = serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{1.0}}
});
const solver::SolverOptions options{
.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-10, .maximum_iterations = updates,
.linear_solve = {.relative_tolerance = 0.003, .absolute_tolerance = 0.0, .maximum_iterations = krylov}},
.linear_solver = {.restart_length = 160, .print_level = 1}
};
const auto observe = [&](const solver::SolveEvent& event, const auto& state, const auto& system) {
std::println("event {}, iteration {}, trial {}, residual {}, step {}: {}", static_cast<int>(event.kind), event.iteration,
event.trial, event.metric ? number(event.metric->residual_norm) : "unmeasured", event.step, event.message);
if (system) {
std::size_t offset = 0;
for (const auto size : state.layout().residual_sizes) {
const auto first = system->residual().begin() + offset;
std::println("row offset {}, count {}, norm {}", offset, size, std::sqrt(std::inner_product(first, first + size, first, 0.0)));
offset += size;
}
}
if (event.kind == solver::EventKind::accepted) {
archive::save_coefficients(output / "progress_state.txt", state.values());
}
std::fflush(stdout);
};
stage("nonlinear_solve");
const auto started = std::chrono::steady_clock::now();
const auto result = solver::solve_equilibrium(std::move(configuration), std::move(*initial), options, observe);
const double seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
const auto& diagnostics = result.diagnostics();
stage("vacuum_virial");
std::expected<fixture::VacuumVirialAssessment, std::string> virial = std::unexpected("The nonlinear solve returned no final state.");
if (result.has_state()) {
archive::save_coefficients(output / "last_state.txt", result.state().values());
if (result.converged()) {
archive::save_coefficients(output / "accepted_state.txt", result.state().values());
}
// The driver enforces the physical z axis and origin above; M=R_reference=G=1 fixes the units.
const double omega = result.state().template get<fixture::Omega>()[0];
const serif::equations::RigidRotation rotation{{0.0, 0.0, omega}};
virial = fixture::vacuum_virial(finite_elements, result.state(), star.equation_of_state(), rotation);
}
const bool physical_accepted = result.converged() && virial && virial->passes();
fixture::write_vacuum_virial(output, virial, result.converged());
std::ofstream summary(output / "solve.json");
summary << std::setprecision(17) << "{\"n\":" << n << ",\"K\":" << K << ",\"M\":1,\"G\":1,\"J\":" << J
<< ",\"P_surface\":0,\"formulation\":\"density_primal\",\"initial_kind\":\""
<< (recheck ? "saved_state_recheck" : "fitted_radial_seed") << "\",\"sample_units\":\"canonical\""
<< ",\"formulation_identity\":" << std::quoted(formulation)
<< ",\"seed_case\":\"" << name << "\""
<< ",\"converged\":" << (result.converged() ? "true" : "false") << ",\"status\":" << static_cast<int>(result.status())
<< ",\"converged_meaning\":\"algebraic\",\"accepted_state_meaning\":\"algebraic_checkpoint\""
<< ",\"physical_accepted\":" << (physical_accepted ? "true" : "false")
<< ",\"vacuum_virial_valid\":" << (virial ? "true" : "false")
<< ",\"energy_virial_signed\":" << (virial ? number(virial->samples.back().energy_virial()) : "null")
<< ",\"force_virial_signed\":" << (virial ? number(virial->samples.back().force_virial()) : "null")
<< ",\"virial_quadrature_allowance\":" << (virial ? number(virial->quadrature_allowance) : "null")
<< ",\"virial_tolerance\":1e-6,\"virial_integration_allowance_limit\":1e-7"
<< ",\"newton_cap\":" << updates << ",\"krylov_cap\":" << krylov
<< ",\"nonlinear_tolerance\":1e-10,\"linear_relative_tolerance\":0.003,\"restart_length\":160"
<< ",\"accepted_iterations\":" << diagnostics.accepted_iterations << ",\"attempted_iterations\":" << diagnostics.attempted_iterations
<< ",\"krylov_iterations\":" << diagnostics.total_linear_iterations << ",\"rejected_trials\":" << diagnostics.rejected_trials
<< ",\"last_step\":" << number(diagnostics.last_accepted_step) << ",\"solve_seconds\":" << number(seconds)
<< ",\"initial_residual\":" << number(diagnostics.initial_residual_norm.value_or(-1.0))
<< ",\"final_residual\":" << number(diagnostics.final_residual_norm.value_or(-1.0))
<< ",\"basis_error\":" << number(basis_error) << ",\"basis_source\":\"" << basis_source.filename().string() << "\"";
if (diagnostics.last_linear_solve) {
summary << ",\"last_linear_relative_residual\":" << number(diagnostics.last_linear_solve->relative_true_residual_norm);
}
summary << "}\n";
summary.close();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(summary.good(), "Cannot write complete solve diagnostics.");
std::println("Termination: {}", result.message());
stage("physical_sampling");
if (result.has_state()) {
if (physical_accepted) {
archive::save_coefficients(output / "physical_accepted_state.txt", result.state().values());
}
archive::sample(report / "meanfield_runs" / name / "samples.csv", output / "samples.csv", finite_elements, result.state(), n, K);
archive::sample_summary(output, finite_elements, result.state(), report / "meanfield_runs" / name / "surface.csv");
}
if (physical_accepted) { stage("complete_physical_accepted"); }
else if (result.converged()) { stage("complete_algebraically_converged_physical_rejected"); }
else { stage("complete_unconverged"); }
std::println("Physical acceptance: {} (full vacuum virial tolerance 1e-6)", physical_accepted ? "passed" : "rejected");
return physical_accepted ? 0 : 1;
}