test(tests): added test suite

The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

@@ -0,0 +1,654 @@
#include <cmath>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <numbers>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <format>
#include <memory>
#include <print>
#include "mfem/fem/intrules.hpp"
#include "serif/runtime.hpp"
#include "serif/backend/mfem/serialize/discretization.hpp"
#include "serif/backend/mfem/serialize/top.hpp"
#include "serif/utils/serialize/serialize.hpp"
#include "serif/equations/barotropic_density.hpp"
#include "serif/model/finite_elements.hpp"
#include "serif/solver/equilibrium.hpp"
#include "serif/solver/preconditioner/default_preconditioners.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/sparse_direct.hpp"
#include "serif/solver/seed/state.hpp"
#include "serif/utils/laneemden/solution.hpp"
#include "polytropic_model.hpp"
#include "stellar_rayfem.hpp"
#include "serif/tests/solver/virial.hpp"
#include "libRayfem/init.hpp"
namespace {
// Defaults: the n=2, J=0.1 star (Omega/Omega_K ~0.70, flattening ~0.22), the fastest rotator in the ESTER/TOP grid
// that converges from the spherical seed. L=12 resolves its surface: all 33 reference modes match within 0.55 PLATO
// sigma (L=8 leaves 3.65 sigma). --angular-momentum 0.05 --surface-degree 8 gives the original benchmark star.
struct Arguments {
bool visualize = true;
bool visualize_requested = false;
double angular_momentum = 0.1;
double polytropic_index = 2.0;
std::optional<double> polytropic_constant;
bool surface_power_density = true;
int surface_degree = 12;
int mesh_refinement = 0;
int surface_refinement = 1;
int equatorial_refinement = 1;
double equatorial_band_degrees = 10.0;
int radial_surface_refinement = 3;
/// Compactified infinity surface, in stellar radii. The vacuum map folds once the equatorial bulge reaches
/// 1/(F - 1) stellar radii, so the fastest rotators need F below the library default of 4.
double infinity_radius = 4.0;
/// Converged archive (profile_solve --save-state) with this n, K and discretization to start Newton from.
std::filesystem::path seed_state;
/// Fixed volume quadrature order; empty uses SERiF's automatic rule (~35 on this mesh). Order 11 matches
/// the automatic rule's ESTER/TOP agreement for the benchmark star at ~1/14 of the memory.
std::optional<int> quadrature_order = 11;
/// Potential-block inverse: sparse LU or three fixed BoomerAMG cycles (SERiF's default recipe). Empty (auto)
/// uses LU when this build has a sparse_direct backend that works on this machine.
std::optional<bool> potential_inverse_lu;
std::array<double, 3> rotation_axis{0.0, 0.0, 1.0};
/// Tidally locked companion on +x: its mass (G = M = 1) and separation; zero mass means a single star.
double companion_mass = 0.0;
double separation = 0.0;
/// The star spins about its own axis at the rate J sets and feels only the companion's tide (asynchronous Roche).
bool asynchronous = false;
/// Continuation: solve first at separation_start, then step geometrically to separation, seeding each solve.
double separation_start = 0.0;
int separation_steps = 0;
/// Converged binary surface radius on a (theta, phi) grid, for plotting.
std::filesystem::path surface_table;
bool mesh_only = false;
int viewer_port = 5555;
std::string viewer;
std::filesystem::path top_export;
serif::utils::serialize::TopExportOptions top_options;
};
Arguments parse_arguments(int argc, char** argv) {
Arguments args;
for (int i = 1; i < argc; ++i) {
const std::string_view option(argv[i]);
if (option == "--visualize") { args.visualize = args.visualize_requested = true; continue; }
if (option == "--no-visualize") { args.visualize = false; continue; }
if (option == "--surface-power-density") { args.surface_power_density = true; continue; }
if (option == "--ordinary-density") { args.surface_power_density = false; continue; }
if (option == "--mesh-only") { args.mesh_only = true; continue; }
if (option == "--asynchronous") { args.asynchronous = true; continue; }
if (option == "--rotation-axis") {
for (double& component : args.rotation_axis) {
if (++i == argc) throw std::invalid_argument("--rotation-axis requires three components: X Y Z");
const std::string value(argv[i]);
std::size_t consumed = 0;
component = std::stod(value, &consumed);
if (consumed != value.size() || !std::isfinite(component))
throw std::invalid_argument("Rotation-axis components must be finite numbers");
}
continue;
}
if (++i == argc) throw std::invalid_argument("Missing value for " + std::string(option));
const std::string value(argv[i]);
std::size_t consumed = 0;
if (option == "--export-top") { args.top_export = value; continue; }
if (option == "--surface-table") { args.surface_table = value; continue; }
if (option == "--seed-state") { args.seed_state = value; continue; }
if (option == "--quadrature-order" && value == "auto") { args.quadrature_order.reset(); continue; }
if (option == "--potential-inverse") {
if (value != "lu" && value != "amg" && value != "auto") throw std::invalid_argument("--potential-inverse takes auto, lu or amg");
if (value == "auto") args.potential_inverse_lu.reset();
else args.potential_inverse_lu = value == "lu";
continue;
}
if (option == "--viewer") { args.viewer = value; args.visualize = args.visualize_requested = true; continue; }
if (option == "--angular-momentum") args.angular_momentum = std::stod(value, &consumed);
else if (option == "--polytropic-index") args.polytropic_index = std::stod(value, &consumed);
else if (option == "--K") args.polytropic_constant = std::stod(value, &consumed);
else if (option == "--surface-degree") args.surface_degree = std::stoi(value, &consumed);
else if (option == "--mesh-refinement") args.mesh_refinement = std::stoi(value, &consumed);
else if (option == "--surface-refinement") args.surface_refinement = std::stoi(value, &consumed);
else if (option == "--equatorial-refinement") args.equatorial_refinement = std::stoi(value, &consumed);
else if (option == "--equatorial-band-degrees") args.equatorial_band_degrees = std::stod(value, &consumed);
else if (option == "--infinity-radius") args.infinity_radius = std::stod(value, &consumed);
else if (option == "--radial-surface-refinement") args.radial_surface_refinement = std::stoi(value, &consumed);
else if (option == "--quadrature-order") args.quadrature_order = std::stoi(value, &consumed);
else if (option == "--top-radial-points") args.top_options.radial_points = std::stoi(value, &consumed);
else if (option == "--top-degree") args.top_options.maximum_degree = std::stoi(value, &consumed);
else if (option == "--top-angular-points") args.top_options.angular_points = std::stoi(value, &consumed);
else if (option == "--top-azimuthal-points") args.top_options.azimuthal_points = std::stoi(value, &consumed);
else if (option == "--viewer-port") args.viewer_port = std::stoi(value, &consumed);
else if (option == "--companion-mass") args.companion_mass = std::stod(value, &consumed);
else if (option == "--separation") args.separation = std::stod(value, &consumed);
else if (option == "--separation-start") args.separation_start = std::stod(value, &consumed);
else if (option == "--separation-steps") args.separation_steps = std::stoi(value, &consumed);
else throw std::invalid_argument("Unknown option: " + std::string(option));
if (consumed != value.size()) throw std::invalid_argument("Invalid value for " + std::string(option));
}
if (!std::isfinite(args.polytropic_index) || args.polytropic_index < 1.0 || args.polytropic_index >= 3.0)
throw std::invalid_argument("Use a polytropic index in [1,3) for this vacuum benchmark");
if (!std::isfinite(args.angular_momentum) || args.surface_degree < 0 ||
args.viewer_port < 1 || args.viewer_port > 65534)
throw std::invalid_argument("Use finite angular momentum, nonnegative surface degree and viewer port 1..65534");
if (args.polytropic_constant && (!std::isfinite(*args.polytropic_constant) || *args.polytropic_constant <= 0.0))
throw std::invalid_argument("K must be finite and positive");
if (args.quadrature_order && *args.quadrature_order < 0)
throw std::invalid_argument("The volume quadrature order must be nonnegative, or auto");
if (args.mesh_refinement < 0 || args.surface_refinement < 0 || args.equatorial_refinement < 0 || args.radial_surface_refinement < 0)
throw std::invalid_argument("Mesh, surface, equatorial and radial surface refinement counts must be nonnegative");
if (!std::isfinite(args.equatorial_band_degrees) || args.equatorial_band_degrees <= 0.0 || args.equatorial_band_degrees > 35.0)
throw std::invalid_argument("The equatorial band half-angle must satisfy 0 < degrees <= 35");
if (!std::isfinite(args.infinity_radius) || args.infinity_radius <= 1.0)
throw std::invalid_argument("The infinity radius must be a finite number of stellar radii above 1");
if (std::ranges::all_of(args.rotation_axis, [](double x) { return x == 0.0; }))
throw std::invalid_argument("The rotation axis must be nonzero");
if (!std::isfinite(args.companion_mass) || !std::isfinite(args.separation) || args.companion_mass < 0.0 ||
(args.companion_mass > 0.0) != (args.separation > 0.0))
throw std::invalid_argument("--companion-mass and --separation go together: a positive mass at a separation of several stellar radii");
if (args.companion_mass > 0.0 && (args.rotation_axis != std::array<double, 3>{0.0, 0.0, 1.0} || !args.top_export.empty() || !args.seed_state.empty()))
throw std::invalid_argument("A companion orbits about the default z axis; TOP export and seed states are single-star only");
if (args.separation_steps < 0 || (args.separation_steps > 0 && !(args.companion_mass > 0.0 && std::isfinite(args.separation_start) && args.separation_start > 0.0)) ||
(!args.surface_table.empty() && args.companion_mass <= 0.0))
throw std::invalid_argument("--separation-steps needs a companion and a positive --separation-start; --surface-table needs a companion");
if (args.asynchronous && args.companion_mass <= 0.0)
throw std::invalid_argument("--asynchronous needs a companion");
if (args.mesh_only && args.visualize_requested)
throw std::invalid_argument("--mesh-only reports mesh statistics without solving; omit --visualize");
if (args.mesh_only) args.visualize = false;
return args;
}
/**
* @brief K that gives the nonrotating polytrope of index n a radius of 1 when G = M = 1.
* @note Lane-Emden scaling: R = xi_1 alpha and M = 4 pi alpha^3 rho_c (-xi_1^2 theta'(xi_1)), with
* alpha^2 = (n+1) K rho_c^(1/n-1) / (4 pi G). For n = 1 this is 2/pi.
*/
double unit_radius_polytropic_constant(const double n) {
// K is a model constant, so integrate well below the seed solver's default step.
const auto lane_emden = serif::utils::laneemden::solve(n, {.step = 1.0e-4, .maximum_steps = 1000000});
if (!lane_emden || !lane_emden->surface)
throw std::runtime_error("Cannot find the Lane-Emden surface for n = " + std::to_string(n));
const double xi = lane_emden->surface->xi;
const double alpha = 1.0 / xi;
const double central_density = 1.0 / (4.0 * std::numbers::pi * alpha * alpha * alpha * (-xi * xi * lane_emden->surface->derivative));
return 4.0 * std::numbers::pi * alpha * alpha * std::pow(central_density, 1.0 - 1.0 / n) / (n + 1.0);
}
/** @brief Resolve --potential-inverse auto against the compiled sparse_direct backend. */
bool use_lu(const Arguments& args) {
return args.potential_inverse_lu.value_or(serif::solver::preconditioner::backend::mfem::sparse_direct_available());
}
void print_options(const Arguments& args, const double K) {
std::println("complete_sandbox options (G = M = 1; K gives a nonrotating radius of 1 unless --K is set):");
std::println(" polytropic index n {}", args.polytropic_index);
std::println(" polytropic constant K {:.17g}{}", K, args.polytropic_constant ? " (--K)" : " (computed from Lane-Emden)");
if (args.companion_mass > 0.0)
std::println(" companion mass {} at separation {} on +x, {}{}", args.companion_mass, args.separation,
args.asynchronous ? "tide only (asynchronous)" : "tidally locked (Omega from force balance)",
args.separation_steps ? std::format("; continued from {} in {} steps", args.separation_start, args.separation_steps) : std::string{});
if (args.companion_mass <= 0.0 || args.asynchronous)
std::println(" angular momentum J {}", args.angular_momentum);
std::println(" rotation axis ({}, {}, {})", args.rotation_axis[0], args.rotation_axis[1], args.rotation_axis[2]);
std::println(" surface harmonic degree {}", args.surface_degree);
std::println(" density representation {}", args.surface_power_density ? "SurfacePower t^n on surface-adjacent cells" : "ordinary");
std::println(" mesh refinement {}", args.mesh_refinement);
std::println(" surface refinement {}", args.surface_refinement);
std::println(" equatorial refinement {} (band half-angle {} degrees)", args.equatorial_refinement, args.equatorial_band_degrees);
std::println(" radial surface refinement {}", args.radial_surface_refinement);
std::println(" volume quadrature {}", args.quadrature_order
? std::format("fixed order {} ({} points per hexahedron)", *args.quadrature_order,
::mfem::IntRules.Get(::mfem::Geometry::CUBE, *args.quadrature_order).GetNPoints())
: std::string{"automatic (SERiF's per-form rule)"});
namespace inverse_backend = serif::solver::preconditioner::backend::mfem;
std::println(" potential inverse {}{}", use_lu(args) ? std::format("sparse LU ({})", inverse_backend::sparse_direct_backend()) : std::string{"3 fixed BoomerAMG cycles"},
args.potential_inverse_lu ? "" : " (auto)");
std::println(" visualization {}", args.visualize ? std::format("RayFEM on port {}", args.viewer_port) : "off");
std::println(" TOP export {}", args.top_export.empty() ? "off" : std::format("{} ({} radial, degree {}, {} angular, {} azimuthal)",
args.top_export.string(), args.top_options.radial_points, args.top_options.maximum_degree,
args.top_options.angular_points, args.top_options.azimuthal_points));
std::fflush(stdout);
}
}
/**
* @brief One RayFEM connection shared by every continuation step, so all their frames form one sequence.
* @note Every step uses the same reference mesh, so it is sent once and frame indices keep increasing across steps.
*/
struct LiveStream {
RayFEM::Client client;
std::uint32_t next_frame = 0;
bool mesh_sent = false;
bool enabled = true;
bool continuation = false;
int step = 0;
/** @brief Start or reuse the viewer on the requested port. */
static std::unique_ptr<LiveStream> open(const Arguments& args) {
RayFEM::WindowParams window{.port = args.viewer_port, .executable = args.viewer};
#ifdef SERIF_RAYFEM_EXECUTABLE
const char* viewer_override = std::getenv("RAYFEM_EXECUTABLE");
if (window.executable.empty() && (!viewer_override || !*viewer_override))
window.executable = SERIF_RAYFEM_EXECUTABLE;
#endif
const auto viewer = RayFEM::init(window);
if (!viewer) throw std::runtime_error("Cannot initialize RayFEM: " + viewer.error);
if (viewer.status == RayFEM::InitStatus::Started)
std::println("Started RayFEM: {}", viewer.executable);
else
std::println("Reusing RayFEM on port {}", args.viewer_port);
auto stream = std::unique_ptr<LiveStream>(new LiveStream{RayFEM::Client("127.0.0.1", args.viewer_port)});
// Frames go out from a background thread: a send returns once the frame is copied,
// so Newton never waits for the viewer or the network.
stream->client.startQueue();
return stream;
}
};
/**
* @brief Build, solve and report one star; Binary adds the tidally locked companion and changes the model's type.
* @note seed (same discretization) replaces the spherical start; converged receives the solved coordinates.
* With a stream, accepted iterates are sent to RayFEM, tagged with the stream's continuation step and the separation.
*/
template <bool Binary>
int solve_star(const Arguments& args, const double K, std::vector<double>* converged, const std::vector<double>* seed,
LiveStream* stream) {
namespace model = serif::model;
namespace equations = serif::equations;
namespace dimensions = serif::dimensions;
namespace solver = serif::solver;
namespace normalization = solver::normalization;
// 1. Describe the star with M=G=1, the requested n and J, and K (by default a nonrotating radius of 1).
// Deferred equations read the same owned prescriptions used by continuation.
// A tidally locked companion sits on +x: rotation is about the barycenter, and Omega's row fixes the star's center of mass.
const double companion_mass = args.companion_mass, separation = args.separation;
const auto stellar_equations = [=](const auto& physics) {
const auto eos = model::borrow_eos(physics.equation_of_state());
const auto& rotation = physics.template specification<model::FixedAngularMomentum>();
const double mass = physics.template specification<model::FixedTotalMass>().target().value();
std::optional<equations::PointMassCompanion> companion;
auto center = rotation.center(); // the star stays at the specification's center; a binary rotates about the barycenter
if (companion_mass > 0.0) companion.emplace(companion_mass, equations::RigidRotation::Vector{separation, 0.0, 0.0}, !Binary);
if constexpr (Binary) center[0] += companion_mass * separation / (mass + companion_mass);
auto omega_row = [&] {
if constexpr (Binary) return equations::BarycenterConstraint<0, serif::discretization::blocks::rotation::angular_velocity>{mass};
else return equations::AngularMomentumConstraint{rotation.target().value(), rotation.axis(), rotation.center()};
}();
return model::EquilibriumEquations{
.interior_equations = equations::EquationRegistry{
equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.0}, rotation.axis(), center, companion},
equations::PrimalPoissonBalance{4.0 * std::numbers::pi},
equations::MassNormalization{mass},
std::move(omega_row)
},
.shape_force = equations::SurfacePotentialBalance{rotation.axis(), center, companion},
.surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.0}}
};
};
const double n = args.polytropic_index;
const auto stellar_model = model::make_stellar_model(serif::eos::models::Polytrope{n, K}, stellar_equations,
model::FixedTotalMass{dimensions::MassValue{1.0}}, model::FixedAngularMomentum{model::FixedAngularMomentum::Parameters{
.angular_momentum = dimensions::AngularMomentumValue{Binary ? 0.0 : args.angular_momentum}, .axis = args.rotation_axis}});
const auto& rotation = stellar_model.template specification<model::FixedAngularMomentum>();
// 2. Obtain a radial initial guess while preserving the EOS and the target mass.
const auto radial = solver::seed::make_polytropic_seed(stellar_model);
if (!radial) {
std::println(stderr, "Cannot prepare the radial star: {}", radial.error());
return 1;
}
// 3. Derive field spaces from their term types and own all numerical resources.
// Surface harmonics remain an explicit approximation choice.
namespace deformation = serif::geometry::deformation;
namespace reconstruction = serif::discretization::reconstruction;
using Density = serif::discretization::blocks::density::mass;
const deformation::DomainDeformation<deformation::SphericalHarmonicSurface> moving_boundary{
.surface = {.maximum_degree = args.surface_degree}, .stellar_interior = {.radial_power = 2.0}, .vacuum = {}};
const auto finite_elements = model::make_stellar_discretization(stellar_model,
{.radius = radial->scales.radius, .infinity_radius_fraction = args.infinity_radius, .refinement = args.mesh_refinement,
.surface_refinement = {.levels = args.surface_refinement, .equatorial_levels = args.equatorial_refinement,
.equatorial_half_angle_degrees = args.equatorial_band_degrees, .radial_levels = args.radial_surface_refinement,
.axis = rotation.axis()}}, moving_boundary,
reconstruction::SurfacePower<Density>{.density_exponent = args.surface_power_density ? n : 1.0});
const auto& mesh = *finite_elements.owner->resources.meshes.front();
using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema;
const int surface_attribute = Schema::boundary_id<serif::discretization::domain::StellarSurfaceBoundary>();
int surface_faces = 0;
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary)
surface_faces += mesh.GetBdrAttribute(boundary) == surface_attribute;
std::println("Mesh: {} elements, {} stellar surface faces; rotation axis = ({}, {}, {})",
mesh.GetNE(), surface_faces, rotation.axis()[0], rotation.axis()[1], rotation.axis()[2]);
if (args.mesh_only) return 0;
auto initial_state = solver::seed::make_polytropic_state(stellar_model, finite_elements, *radial);
if (!initial_state) {
std::println(stderr, "Cannot project the radial star: {}", initial_state.error());
return 1;
}
// Start the orbit at Kepler's rate for two point masses.
const double kepler_omega = std::sqrt((1.0 + companion_mass) / (separation * separation * separation));
if constexpr (Binary) initial_state->template get<serif::discretization::blocks::rotation::angular_velocity>()[0] = kepler_omega;
if (seed) std::ranges::copy(*seed, initial_state->values().begin());
if (!args.seed_state.empty()) {
// Continuation: replace the spherical seed with a converged star of the same n, K and discretization.
namespace archive = serif::utils::serialize;
using State = std::remove_cvref_t<decltype(*initial_state)>;
const auto saved = archive::Reader::open(args.seed_state);
if (!saved) throw std::runtime_error(saved.error().message);
const auto loaded = saved->read<State>("state");
const auto old_n = saved->read<double>("n"), old_K = saved->read<double>("K"), old_J = saved->read<double>("J");
const auto converged = saved->read<bool>("converged");
const auto geometry = saved->node("finite_elements");
if (!loaded || !old_n || !old_K || !old_J || !converged || !*converged || *old_n != n || *old_K != K || !geometry ||
*geometry != serif::backend::mfem::serialize::save_discretization(finite_elements) || loaded->layout() != initial_state->layout())
throw std::runtime_error("--seed-state must be a converged archive with the same n, K, discretization and layout");
std::ranges::copy(loaded->values(), initial_state->values().begin());
std::println("Seeded from {} (converged at J = {})", args.seed_state.string(), *old_J);
}
// Deduced here: clang does not deduce these class templates inside the generic lambda below.
const auto normalization_options = normalization::NormalizationOptions{
.scheme = normalization::LaneEmdenApproximation{.n = n, .central_density = radial->scales.central_density}};
// 4-6. Solve and report with the chosen potential inverse; each choice is its own preconditioner type.
const auto solve_and_report = [&](const auto potential_inverse) -> int {
// 4. Freeze sensible numerical scales independently of the physical prescriptions.
auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = stellar_model,
.finite_elements = finite_elements,
.normalization = normalization::make_normalization(normalization_options),
.preconditioner = solver::preconditioner::make_primal_preconditioner(initial_state->layout(), potential_inverse)
});
// 5. Select numerical work limits and observe accepted stars without managing evaluators or caches.
const auto observe = [](const solver::SolveEvent& event, const auto&, const auto&) {
if (event.kind == solver::EventKind::initial) {
std::println("Initial normalized residual = {}", event.metric->residual_norm);
} else if (event.kind == solver::EventKind::accepted) {
std::println("Newton step {}: normalized residual = {}, accepted step = {}", event.iteration + 1, event.metric->residual_norm, event.step);
}
std::fflush(stdout);
};
const solver::SolverOptions options{
.method = {.relative_tolerance = 1.0e-8, .absolute_tolerance = 1.0e-10, .maximum_iterations = 30,
.linear_solve = {.relative_tolerance = 0.03, .maximum_iterations = 200}},
.linear_solver = {.restart_length = 160}
};
solver::VolumeOptions volume_options{};
if (args.quadrature_order) {
namespace quadrature = serif::discretization::quadrature;
quadrature::RuleSet rules;
rules.set_role_control(quadrature::QuadratureRole::discretization, {.fixed_order = *args.quadrature_order});
volume_options.quadrature_policy = quadrature::Policy{std::move(rules)};
}
const auto solution = [&] {
if (!stream)
return solver::solve_equilibrium(std::move(configuration), std::move(*initial_state), options, observe, volume_options);
auto problem = solver::backend::mfem::make_observed_stellar_problem(std::move(configuration), *initial_state,
options.strategies, volume_options, serif::utils::dependencies::DependencyContext<>{},
serif::utils::dependencies::NoGraphObservers{});
serif::visualization::backend::mfem::StellarFrame frame(*problem.binding().discretization);
auto& client = stream->client;
constexpr std::uint64_t mesh_id = 1;
// Frames are Newton iterates of one equilibrium, not moments in time.
RayFEM::FrameSchema schema;
schema.values = {
{"Newton step", RayFEM::ValueType::Integer, true, "", "Accepted Newton steps so far; 0 is the initial state"},
{"residual", RayFEM::ValueType::Real, false, "", "Normalized nonlinear residual"},
{"step length", RayFEM::ValueType::Real, false, "", "Accepted damped Newton step length (1 is a full step)"},
{"Omega", RayFEM::ValueType::Real, false, "", "Angular velocity of the rigid rotation"},
{"virial ratio", RayFEM::ValueType::Real, false, "", "(2T + 3 integral of P)/|W|; 1 in equilibrium"},
{"virial error", RayFEM::ValueType::Real, false, "", "(2T + W + 3 integral of P)/|W| at the finest quadrature; 0 in equilibrium"},
{"mass", RayFEM::ValueType::Real, false, "M", "Integrated mass"},
{"virial", RayFEM::ValueType::Text, false, "", "Why the vacuum virial could not be measured"}};
schema.tags = {{"initial", "The seeded state before any Newton step"},
{"resumed", "A state resumed from a checkpoint"},
{"accepted", "An accepted Newton iterate"},
{"converged", "Meets the solver's residual tolerances"}};
schema.has_time = false;
schema.primary = "Newton step";
if (stream->continuation) {
// Newton steps restart in each continuation step, so frames are ordered by step.
schema.values.insert(schema.values.begin(), {
{"continuation step", RayFEM::ValueType::Integer, true, "", "Continuation step; 0 is the widest separation"},
{"separation", RayFEM::ValueType::Real, true, "R0", "Orbital separation of the companion"}});
schema.primary = "continuation step";
}
// RayFEM orients the view and measures flattening about this axis; a binary rotates about the barycenter.
auto axis_origin = rotation.center();
if constexpr (Binary) {
const double mass = stellar_model.template specification<model::FixedTotalMass>().target().value();
axis_origin[0] += companion_mass * separation / (mass + companion_mass);
}
const auto& spin = rotation.axis();
schema.axes = {{"rotation", {spin[0], spin[1], spin[2]}, {axis_origin[0], axis_origin[1], axis_origin[2]},
Binary ? "Orbital rotation axis through the barycenter" : "Rigid rotation axis"}};
// A locked binary's barycenter moves with the separation, so each step re-registers the schema.
if (stream->enabled && !client.registerSchema(schema)) throw std::runtime_error(client.getLastError());
if (stream->enabled && !stream->mesh_sent) {
if (!client.sendMesh(mesh_id, frame.mesh())) throw std::runtime_error(client.getLastError());
stream->mesh_sent = true;
}
double initial_residual = 0;
const auto live_observe = [&](const solver::SolveEvent& event, const auto& state, const auto& system) {
observe(event, state, system);
if (!stream->enabled || (event.kind != solver::EventKind::initial && event.kind != solver::EventKind::resumed &&
event.kind != solver::EventKind::accepted)) return;
try {
// StandardNewton has just evaluated this state. The cache is unsafe at finished
// after a rejected trial, so only initial/resumed/accepted events are streamed.
frame.update(state, problem.evaluator().cache());
const std::uint32_t frame_index = stream->next_frame;
RayFEM::FrameMetadata metadata{.frame_index = frame_index, .sim_time = 0.0};
const double residual = event.metric->residual_norm;
if (event.kind != solver::EventKind::accepted) initial_residual = residual;
const int newton_step = event.kind == solver::EventKind::accepted ? event.iteration + 1 : 0;
if (stream->continuation)
std::snprintf(metadata.label, sizeof(metadata.label), "step %d a=%.4g Newton %d |R|=%.3g",
stream->step, separation, newton_step, residual);
else
std::snprintf(metadata.label, sizeof(metadata.label), "n=%.3g J=%.3g Newton %u |R|=%.3g",
n, args.angular_momentum, frame_index, residual);
const double omega = state.template get<serif::discretization::blocks::rotation::angular_velocity>()[0];
metadata.values = {{"Newton step", std::int64_t{newton_step}}, {"residual", residual}, {"Omega", omega}};
if (stream->continuation) {
metadata.values.push_back({"continuation step", std::int64_t{stream->step}});
metadata.values.push_back({"separation", separation});
}
if (event.kind == solver::EventKind::accepted) metadata.values.push_back({"step length", event.step});
// The physical check, independent of the algebraic residual.
const auto& axis = rotation.axis();
const auto virial = companion_mass > 0.0 ? std::expected<serif::tests::solver::VacuumVirialAssessment, std::string>{
std::unexpect, "the vacuum virial omits the companion's potential"} :
serif::tests::solver::vacuum_virial(*problem.binding().discretization, state, stellar_model.equation_of_state(),
equations::RigidRotation{{omega*axis[0], omega*axis[1], omega*axis[2]}, rotation.center()});
if (virial) {
const auto& sample = virial->samples.back();
metadata.values.push_back({"virial ratio", (2.0*sample.kinetic_energy + 3.0*sample.pressure_integral)/std::abs(sample.potential_energy)});
metadata.values.push_back({"virial error", sample.energy_virial()});
metadata.values.push_back({"mass", sample.mass});
} else metadata.values.push_back({"virial", virial.error().substr(0, 1000)});
metadata.tags = {event.kind == solver::EventKind::initial ? "initial" : event.kind == solver::EventKind::resumed ? "resumed" : "accepted"};
if (residual <= options.method.absolute_tolerance || residual <= options.method.relative_tolerance*initial_residual)
metadata.tags.push_back("converged");
const auto send_start = std::chrono::steady_clock::now();
if (!client.sendFrame(mesh_id, frame.fields(), metadata, &frame.displacement()))
throw std::runtime_error(client.getLastError());
const std::chrono::duration<double, std::milli> send_time = std::chrono::steady_clock::now() - send_start;
std::println("RayFEM frame {} (queued in {:.2f} ms): interior elements {}, max nodal displacement {}, virial {}",
frame_index, send_time.count(), frame.mesh().GetNE(), frame.displacement().Normlinf(),
virial ? std::format("ratio {:.12g} error {:.3g}", (2.0*virial->samples.back().kinetic_energy +
3.0*virial->samples.back().pressure_integral)/std::abs(virial->samples.back().potential_energy),
virial->samples.back().energy_virial()) : "unavailable: " + virial.error());
++stream->next_frame;
std::fflush(stdout);
} catch (const std::exception& error) {
stream->enabled = false;
std::println(stderr, "Live visualization disabled: {}", error.what());
}
};
auto result = solver::solve_newton(problem, std::move(*initial_state), options, live_observe);
// The last frames, the converged state among them, may still be queued.
if (!client.flush(60000)) std::println(stderr, "RayFEM: queued frames were still unsent after 60 s");
if (const auto queue = client.queueStats(); queue.failed)
std::println(stderr, "RayFEM: {} queued packets were not sent", queue.failed);
return result;
}();
if (!solution.converged()) {
std::println(stderr, "Stellar solve stopped: {}", solution.message());
if (solution.diagnostics().last_linear_solve) {
const auto& linear = *solution.diagnostics().last_linear_solve;
std::println(stderr, "Last linear solve: {} iterations, true relative residual {}", linear.iterations, linear.relative_true_residual_norm);
}
return 1;
}
// The result retains the physical definition, accepted coefficients and their matching system.
using Omega = serif::discretization::blocks::rotation::angular_velocity;
std::println("Converged after {} steps: Omega = {}, normalized residual = {}", solution.diagnostics().accepted_iterations,
solution.state().template get<Omega>()[0], *solution.diagnostics().final_residual_norm);
if (companion_mass > 0.0) {
// Radii from the star's center toward the companion (+x), away from it, along the orbit (+y) and along the axis.
namespace deformation_backend = serif::geometry::deformation::backend::mfem;
const auto& state = solution.state();
const auto inputs = deformation_backend::make_deformation_inputs(finite_elements.deformation.model,
finite_elements.deformation.reference, state.template get<serif::discretization::blocks::surface_deformation::coefficients>());
using Surface = std::remove_cvref_t<decltype(finite_elements.deformation.model.surface)>;
auto cache = serif::utils::dependencies::make_computation_cache<serif::geometry::deformation::DeformationFailure>(
deformation_backend::make_deformation_graph<Surface>(), inputs);
if (!cache.try_prepare(inputs, serif::utils::dependencies::KeyList<serif::geometry::deformation::keys::VolumeDisplacement>{}))
throw std::runtime_error("Cannot reconstruct the converged surface");
serif::backend::mfem::serialize::detail::TopModelSampler sampler(finite_elements.template binding<Density>(),
state.template get<Density>(), finite_elements.deformation.reference,
cache.template get<serif::geometry::deformation::keys::VolumeDisplacement>(), rotation.axis(), {0.0, 0.0, 0.0});
const auto radius = [&](double mu, double phi) { return sampler.surface_radius(mu, phi).value(); };
if constexpr (Binary)
std::println("Orbit: Omega / Omega_Kepler(point masses) = {:.9f}", solution.state().template get<Omega>()[0] / kepler_omega);
else
std::println("Spin: Omega_spin / Omega_orbit(Kepler, point masses) = {:.6f}", solution.state().template get<Omega>()[0] / kepler_omega);
std::println("Radii: toward companion {:.6f}, away {:.6f}, along orbit {:.6f}, polar {:.6f}",
radius(0.0, 0.0), radius(0.0, std::numbers::pi), radius(0.0, 0.5 * std::numbers::pi), radius(1.0, 0.0));
if (!args.surface_table.empty()) {
std::ofstream table(args.surface_table);
table << "# theta_deg phi_deg radius (star center at the origin, companion at +x, separation " << separation << ")\n";
for (int i = 0; i <= 90; ++i) {
for (int j = 0; j < 90; ++j) {
const double theta = 2.0 * i, phi = 4.0 * j;
table << theta << ' ' << phi << ' ' << radius(std::cos(theta * std::numbers::pi / 180.0), phi * std::numbers::pi / 180.0) << '\n';
}
}
std::println("Surface table written to {}", args.surface_table.string());
}
}
if (converged) converged->assign(solution.state().values().begin(), solution.state().values().end());
if (!args.top_export.empty()) {
const auto exported = serif::utils::serialize::export_top(args.top_export, finite_elements, solution, args.top_options);
if (!exported) {
std::println(stderr, "TOP export failed at {}: {}", exported.error().path, exported.error().message);
return 1;
}
std::println("TOP model written to {}: {} radial points, degree {}, angular frequency unit {}",
exported->directory.string(), exported->radial_points, exported->maximum_degree, exported->angular_frequency_unit);
std::println("TOP projection: surface error {}, enthalpy angular truncation {}, surface symmetry error {}, enthalpy symmetry error {}",
exported->maximum_relative_surface_projection_error, exported->maximum_relative_enthalpy_projection_error,
exported->maximum_relative_surface_symmetry_error,
exported->maximum_relative_enthalpy_symmetry_error);
}
return 0;
};
if (use_lu(args)) return solve_and_report(solver::preconditioner::strategies::inverse::SparseDirect{});
return solve_and_report(solver::preconditioner::strategies::inverse::FixedAMG{.cycles = 3});
}
/** @brief Solve one star, or continue a binary inward in separation, seeding each solve with the previous star. */
template <bool Binary>
int run(const Arguments& args, const double K) {
const auto stream = args.visualize ? LiveStream::open(args) : nullptr;
if (args.separation_steps > 0) {
if (stream) stream->continuation = true;
std::vector<double> state;
for (int step = 0; step <= args.separation_steps; ++step) {
Arguments stage = args;
stage.separation = args.separation_start * std::pow(args.separation / args.separation_start, static_cast<double>(step) / args.separation_steps);
if (step < args.separation_steps) stage.surface_table.clear();
if (stream) stream->step = step;
std::println("Continuation step {} of {}: separation {}", step, args.separation_steps, stage.separation);
const std::vector<double> seed = state;
if (const int status = solve_star<Binary>(stage, K, &state, step ? &seed : nullptr, stream.get()); status != 0) return status;
}
return 0;
}
return solve_star<Binary>(args, K, nullptr, nullptr, stream.get());
}
/** @brief Show the complete route from physical equations to an owning converged stellar solution. */
int main(int argc, char** argv) try {
if (argc == 2 && std::string_view(argv[1]) == "--help") {
std::println("Usage: complete_sandbox [--angular-momentum J] [--surface-degree L]\n"
" [--polytropic-index N] [--K K] [--surface-power-density | --ordinary-density]\n"
" [--mesh-refinement N] [--surface-refinement N]\n"
" [--equatorial-refinement N] [--equatorial-band-degrees D]\n"
" [--radial-surface-refinement N] [--quadrature-order N | auto]\n"
" [--potential-inverse auto | lu | amg]\n"
" [--rotation-axis X Y Z] [--mesh-only] [--infinity-radius F] [--seed-state FILE]\n"
" [--visualize | --no-visualize] [--viewer PATH] [--viewer-port PORT]\n"
" [--export-top DIRECTORY] [--top-radial-points N] [--top-degree L]\n"
" [--top-angular-points N] [--top-azimuthal-points N]\n"
" [--companion-mass MC --separation A [--separation-start A0 --separation-steps N]\n"
" [--surface-table FILE]]\n"
"Defaults: the n=2, J=0.1 rotator (Omega/Omega_K ~0.70) compared with ESTER/TOP: n=2, J=0.1, L=12, SurfacePower density,\n"
"surface refinement 1, equatorial refinement 1 with a 10 degree band, radial surface refinement 3,\n"
"axis=(0,0,1), streamed live to RayFEM. G=M=1 and, unless --K is given, K makes the nonrotating radius 1.\n"
"--angular-momentum 0.05 --surface-degree 8 gives the slower n=2, J=0.05 benchmark star.\n"
"--surface-power-density (default) selects t^n reconstruction on surface-adjacent density elements;\n"
"--ordinary-density selects the plain finite-element density.\n"
"Radial surface refinement halves only the stellar-side surface shell, radially, per level.\n"
"--quadrature-order fixes the volume quadrature order (default 11); auto uses SERiF's automatic rule.\n"
"--potential-inverse lu factors the potential block exactly (build option sparse_direct); amg uses SERiF's\n"
"3-cycle BoomerAMG; auto (default) uses lu when this build and machine support it, else amg.\n"
"Surface degree controls harmonics; refinement controls mesh cells.\n"
"Surface refinement adds isotropic interface layers; equatorial refinement adds latitude splits.\n"
"Equatorial rings conservatively cover the requested band (0 < D <= 35 degrees).\n"
"--infinity-radius places compactified infinity at F stellar radii (default 4); the vacuum map folds once the\n"
"equatorial bulge reaches 1/(F-1), so stars flatter than ~0.33 need F < 4.\n"
"--seed-state starts Newton from a converged profile_solve --save-state archive with the same n, K and\n"
"discretization (any J): continuation for fast rotators that do not converge from the spherical seed.\n"
"--mesh-only prints mesh counts and exits (no viewer). Viewer defaults: enabled, port 5555.\n"
"Live frames show initial and accepted Newton states, not physical time.\n"
"--export-top writes a converged polytrope to a new directory for TOP.\n"
"--companion-mass MC --separation A adds a tidally locked point-mass companion on +x: rotation is about the\n"
"barycenter and Omega follows from force balance (the star's center of mass stays at the origin); J is unused.\n"
"--separation-start A0 --separation-steps N solves first at A0 (from the spherical seed), then at N separations\n"
"stepping geometrically to A, each seeded by the previous star; every step streams to one RayFEM sequence,\n"
"tagged with its continuation step and separation. Close or massive\n"
"companions need this: from a sphere Newton stalls well inside the Roche lobe. Use --infinity-radius 3 near it.\n"
"--asynchronous instead spins the star about its own axis at the rate --angular-momentum sets, with only the\n"
"companion's tide acting (its uniform pull removed): the asynchronous Roche approximation.\n"
"--surface-table FILE writes the converged binary's surface radius on a (theta, phi) grid (phi = 0 faces the companion).\n"
"TOP defaults: 64 radial points, maximum even degree 16, 48 angular and 8 azimuthal samples.");
return 0;
}
const auto args = parse_arguments(argc, argv);
serif::initialize();
const double K = args.polytropic_constant.value_or(unit_radius_polytropic_constant(args.polytropic_index));
print_options(args, K);
return args.companion_mass > 0.0 && !args.asynchronous ? run<true>(args, K) : run<false>(args, K);
} catch (const std::exception& error) {
std::println(stderr, "{}", error.what());
return 1;
}