feat(utils/serialize): System for serializing anything that can be serialized in SERiF (note LLM's made signifigant contributions to this module)

There are loads of things (solutions, models, solver descriptions, mesh's, etc..) which need to be exported from SERiF and then either reimported into SERiF or into some other program. This module is responsible for all of that. Generally with the goal of feeling like the fmt / new format module in C++23 and on (e.g. by specializing formatting structs)

Note that LLM's made signifigant contributions to this module (ChatGPT 6 astra max and Claude Opus 5.5 Medium). All code has been reviewd manually
This commit is contained in:
2026-11-09 13:43:31 -05:00
parent c854fcc590
commit 7ab8641f51
17 changed files with 3284 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# Scientific component records
These serializers describe physics and numerical choices separately from a file format. `physics.hpp`, `equations.hpp`, `model.hpp`, and `numerics.hpp` are backend independent. Each component has a stable schema name and version; nested components use the same `Serializer<T>` protocol as user extensions.
## Supported components
- Polytropic EOS parameters; fixed mass, central density and angular momentum prescriptions; isobaric surfaces.
- Concrete barotropic equation leaves, sums, differences, scales, equation registries and explicit equilibrium compositions. EOS references are restored with an owning immutable EOS, rather than a saved address.
- Stellar models and physical stellar states. State records retain both state/residual coordinate orders, sizes and exact floating-point coefficients, including scalar and surface coordinates. Built-in coordinate IDs are explicit rather than compiler-generated names. Custom coordinates register `TermIdentity<T>` or provide `T::stable_id`.
- Nodal radial and normalized real spherical-harmonic surface descriptions, their centers and harmonic cutoffs, and interior/vacuum extension prescriptions. These descriptions accompany the state coefficients; a layout alone cannot determine which surface basis was used. The MFEM serializer supplies the associated reference mesh and surface ownership.
- Standard Newton, line-search and FGMRES controls; built-in normalization selections, Lane–Emden controls, resolved reference scales, explicit normalization plans and frozen diagonal factors.
- Configured discretizations (archive entry `configuration`) combine the model, finite-element description, normalization and preconditioner. The MFEM `restore_configuration` helper reconstructs their mesh ownership and resolved model composition.
- Composed block, gravity, surface, coupled and bordered preconditioner recipes, including their physical coordinate selections and nested numerical controls. Prepared matrices and inverse caches are reconstructed by numerical preparation.
- Owned prescribed-input contexts whose physical keys, values and tracking policies have serializers. Borrowed inputs retain inspectable values and report the need to reconstruct and rebind their owner.
- Quadrature role and fallback controls. Form-specific overrides remain inspectable, but their borrowed runtime descriptors require a separate owning descriptor registry before automatic reconstruction can be supported.
Plain option records use an explicit member list in `StructSerializer<T>`; it is not object-memory serialization or automatic reflection. Existing `validate()` methods run after reconstruction, and supported bare numerical recipes additionally check their parameter domains. Matrix-, mesh- and solution-dependent validity still belongs to numerical preparation.
## Authored definitions and restored physics
`decode<Model>(record)` reconstructs the original compiled model type only when its authored equation definition has a loader. Arbitrary lambda captures, function pointers, runtime EOS views and custom computation closures cannot be recreated merely from type names or numeric fingerprints. They produce explicit unsupported records; register a named callable with a serializer when reconstruction is required.
A stellar model also saves its resolved concrete equilibrium equations when they are available. `restore_stellar_model<OriginalModel>(record)` returns an owning `RestoredStellarModel<OriginalModel>` with that explicit composition. It can reproduce the saved current physics without retaining the original callback. It does **not** recreate a program that regenerates equations for different EOS parameters or continuation stages. The original authored-definition limitation remains visible in the archive.
Template loading validates a schema that is already compiled into the application. It does not instantiate arbitrary new C++ types from strings in a file. A runtime loader needs an explicit application catalog of supported compiled formulations.
## Reproducibility boundaries
Archive the solve configuration alongside the result: a solved star does not retain every original solver option, prescribed input, integration policy or preconditioner recipe. Preserve requested choices and resolved choices, including the preconditioner composition, reference scales and actual normalization factors. Saving only “use the default” permits a future library version to choose differently.
Quantity tags identify physical meanings, not SI, CGS or code-unit scales. The enclosing archive must provide the unit convention and provenance; normalization reference values do not supply a complete unit system. The polytropic constant's units also depend on its index.
Stored numeric coefficients and public surface parameters round-trip without formatting loss. Validated constructors may recompute derived values, including rotation-axis normalization. Scientific reconstruction therefore does not promise bit-for-bit object memory, derived arithmetic across compiler/library versions, or an identical floating-point trajectory. Exact solver restart has a separate checkpoint contract and supported-policy checks.

View File

@@ -0,0 +1,205 @@
# Stellar archives
An archive contains the information supplied to `pack(...)`. Each component describes its own scientific data through an external `Serializer<T>` specialization. The archive handles files, encoding, indexing and provenance. The model, computational graph and numerical algorithms do not depend on this module.
```cpp
#include "serif/utils/serialize/serialize.hpp"
#include "serif/backend/mfem/serialize/configuration.hpp"
namespace serialization = serif::utils::serialize;
auto contents = serialization::pack(stellar_model, discretization, solution);
auto written = serialization::write("star.serif", contents, {
.author = "Observer name",
.comment = "Equilibrium at the end of the rotation sequence",
.metadata = {{"units", "G=M=R=1 code units"}, {"study", "rotation sequence"}}
});
```
`write` and reader operations return `std::expected<T, Error>`. An error has a component `path` and an explanatory `message`. Check that result before using it. The sandbox demonstrates one way to keep error handling separate from the scientific workflow.
`pack(solution)` is valid too. A result supplies its available model, accepted physical coefficients, diagnostics, normalization factors/reference scales, and supported Newton checkpoint. It cannot supply a mesh, a preconditioner recipe or settings that the result never retained. Passing the configured discretization and solver options preserves those additional inputs. Custom prescribed inputs, fixed displacement and assembly options should also be supplied when used.
`pack` immediately owns encoded data. Changing a model or destroying its resources after packing does not change that snapshot. `named("comparison_star", value)` chooses a name when multiple objects have the same default role. Conflicting objects with the same name are an error; identical represented objects are deduplicated. An unregistered type appears as an `unsupported` entry containing an explanation. A partial archive remains useful for analysis, but does not claim full reconstruction.
## Read only the information needed
```cpp
auto opened = serialization::Reader::open("star.serif");
if (!opened) {
// Report opened.error().path and opened.error().message.
return;
}
auto& reader = *opened;
const auto& available = reader.ledger();
auto author = reader.read<std::string>("provenance/author");
auto coefficients = reader.read<std::vector<double>>("solution/state/coefficients");
auto checkpoint = reader.read<Checkpoint>();
```
Opening reads the fixed header and the ledger, not the coefficient or mesh payloads. `node(path)` returns an owned component subtree for inspection or a specialized reconstruction routine. `read<T>(path)` then applies `Serializer<T>::load`. Every selected section is length-checked and checksum-checked before use. `validate()` reads and checks all sections. A `Reader` owns an open stream and should not be shared between concurrent reads without external synchronization.
SERiF compatibility defaults to the same major version. Set `ReadOptions::version_tolerance` to `exact` to require the same version string or `any` for explicit recovery/inspection across major versions. These choices do not disable file-format checks, component-schema checks, coordinate validation or MFEM representation checks. The separate file-format version is currently **1**.
## Text and binary
Choose `.format = Format::text` for readable named sections, one number per line in array sections, and quoted strings. Text uses the classic locale and `max_digits10`; it preserves finite double values, including signed zero. Nonfinite doubles use `bits:` followed by their 16 hexadecimal IEEE-754 digits, preserving NaN payloads too. This is a sectioned scientific text format, not a rectangular CSV table.
The default binary encoding stores numerical arrays as little-endian fixed-width values. It never writes C++ structure padding, addresses, native `size_t` layout or virtual tables. Both formats represent the same component tree and preserve binary64 coefficient values exactly. A full model description is distinct from bitwise process memory: derived caches are rebuilt, and different hardware or library versions may produce different subsequent arithmetic.
`tools/read_serif_archive.py` is a standard-library-only data inspector for either format:
```sh
python tools/read_serif_archive.py star.serif
python tools/read_serif_archive.py star.serif --read solution/state/coefficients --csv
```
The first command lists available fields and their offsets. The second exports ordinary `index,value` CSV for analysis. This inspector does not instantiate physical models or apply the C++ model loader's version and formulation compatibility policy.
## Header, ledger and data sections
The first **512 bytes** are an ASCII header beginning with `SERIF_ARCHIVE 1`. Its fields specify encoding, SERiF version, UTC write time, ledger offset/length, data offset, total file length, entry count and ledger checksum. Remaining header bytes are whitespace. All offsets are absolute **byte offsets**, including in text mode; files are always opened in binary stream mode to avoid newline translation.
The ledger starts immediately after the header. It has one row per component or field:
```text
# path schema version kind count offset bytes checksum
```
Paths and schema names are quoted. Offsets and byte lengths have 20 decimal digits, allowing the complete ledger size to be known before payload positions are assigned. Rows are sorted by path; parent components precede their fields. A component-directory row has no payload. Text-mode data sections have visible `# path` labels preceding the indexed payload. Binary payloads are contiguous.
| Kind | Representation |
| --- | --- |
| 0 | Component directory |
| 1 | Boolean, one byte in binary |
| 2 | Signed 64-bit integer |
| 3 | Unsigned 64-bit integer |
| 4 | IEEE-754 binary64 real |
| 5 | String bytes, UTF-8 by convention |
| 6 | Binary64 array |
| 7 | Signed 64-bit integer array |
| 8 | Unsigned 64-bit integer array |
Binary numeric values occupy eight bytes except booleans. Counts give array extents or unescaped string byte lengths; scalar counts are one. `fnv1a64-v1` fingerprints detect accidental corruption and identify exact serialized resources. They are not cryptographic authentication. Readers enforce configurable index, payload, entry-count and nesting limits; raise those limits explicitly for unusually large models.
Writes use an exclusively created temporary file beside the destination. Only a complete, successfully closed file replaces the destination. A failed write retains the previous archive. This prevents partial publication; it is not a guarantee against filesystem or hardware failure after publication.
## Provenance and meshes
Every archive includes `provenance`: UTC time; SERiF version generated directly from Meson's project version through `serif/config.hpp.in`; compiler/build information; host OS, kernel, machine and architecture information where exposed; available library versions; author, comment and user metadata. The MFEM component adds the backend configuration, MFEM source information and exposed dependency versions. Runtime MPI information is queried only if MPI is already initialized. Missing platform facilities are not guessed.
Metadata keys use the same simple field-name alphabet as other component names: letters, digits, underscores, periods and hyphens. Author, comment and metadata values can contain Unicode, punctuation and line breaks. Record the unit convention explicitly: physical dimension tags do not identify SI, CGS or a particular code-unit conversion.
MFEM discretizations embed native reference meshes by default, including the separate logical mesh when deformation needs it. Use `save_discretization(finite_elements, {.embed_mesh = false})` to retain mesh identities and representation metadata while obtaining native mesh text from a caller-supplied resolver during restoration. A mismatched supplied mesh is rejected. The mesh payload itself uses enough decimal digits to recover stored coordinates; coefficients are saved in their original global/reduced numbering, with space, support and signed element-numbering information for validation.
## Resume an accepted Newton step
An observer may return `SolveControl::stop`. The request takes effect at a completed accepted boundary. `result.checkpoint()` contains the accepted state, original convergence reference, next line-search step, carried normalized correction and cumulative accepted history. It contains no live Jacobian, inverse or computational graph cache.
Save the checkpoint together with the model/discretization recipe, normalization, solver options and any additional physical inputs. After loading, `resume_equilibrium(...)` reconstructs and qualifies that state, rebuilds preparation, checks the represented residual against the saved boundary, and continues with the original convergence reference and iteration budget. Increasing the iteration budget is an explicit solver-option change.
Exact accepted-boundary checkpoint support currently covers `StandardNewton`, `NormalizedResidualMetric` and `FGMRES`. Other strategies continue to solve normally but explicitly report that their private history is not checkpointable. This is not yet a continuation-controller checkpoint. Restoring coefficients alone is a useful new initial guess, but does not preserve Newton history.
See `tests/sandbox/serialization_sandbox.cpp` for the complete save/load/resume example, [COMPONENTS.md](COMPONENTS.md) for the supported scientific descriptions and custom-type rules, and the [MFEM serializer notes](../../backend/mfem/serialize/README.md) for backend reconstruction requirements.
## Add a component
Specialize `Serializer<T>` with a stable `key`, `save(const T&) -> Node`, and `load(const Node&) -> Result<T>`. `object`, `field`, `read_object` and `member<T>` compose existing serializers. Plain configuration aggregates can use `StructSerializer<T>` and an explicit `member_field` list. Keep physics validation with the component; existing `validate()` methods are reused during reconstruction. Schema versions default to one; an explicit `Serializer<T>::version` selects another supported version. When a schema changes, increase that version and add an explicit migration from the inspected old `Node` if needed. New formats do not require changing component serializers.
## Export a solved polytrope to TOP
The TOP exporter is a separate format writer in this serialization module. It
accepts a stellar model together with the mesh and accepted physical state that
realize it. The MFEM sampling adapter remains an explicit include:
```cpp
#include "serif/backend/mfem/serialize/top.hpp"
namespace io = serif::utils::serialize;
const auto exported = io::export_top("top-model", stellar_model, finite_elements,
solution.state(), io::TopExportOptions{.radial_points = 64, .maximum_degree = 16});
// Equivalent when the result retains the same physical model:
// const auto exported = io::export_top("top-model", finite_elements, solution, options);
if (!exported) {
// Report exported.error().path and exported.error().message.
}
```
`StellarModel` describes physics; it deliberately does not contain a mesh or
solution. No normalization factors should be applied before export: accepted
state coefficients already represent physical quantities. Exporting an accepted
state does not certify equilibrium convergence. The complete sandbox exports
only after its solve converges:
```sh
./build/tests/sandbox/complete_sandbox --angular-momentum 0.2 --surface-degree 8 \
--surface-refinement 1 --equatorial-refinement 3 --export-top top-model
```
Use `--top-radial-points`, `--top-degree`, `--top-angular-points`, and
`--top-azimuthal-points` to control export sampling independently of the FE mesh.
Defaults are 64 Chebyshev radial points, even degree 16, 48 Gauss-Legendre angular
points and 8 azimuthal samples. The parent directory must exist and the destination must not already exist. A failed
export preserves any existing destination, and a successful directory is
published only after every file has been written.
The current adapter supports the density formulation with the `Polytrope` EOS,
`BarotropicDensityBalance`, `PrimalPoissonBalance`, `FixedAngularMomentum`, a
solved uniform angular velocity and a zero-pressure moving surface. Unsupported
physics, finite-pressure boundaries, invalid geometry and material negative
densities return errors. A future realistic-EOS/finite-pressure interface needs
a corresponding TOP reader; this writer does not pretend those models are
vacuum polytropes.
The adapter reconstructs deformation using the existing geometry graph, samples
the actual deformed finite-element surface and density, and accounts for
nonconforming true DOFs. It uses the prescribed rotation axis and center. The
source mesh and state remain unchanged. Central density is sampled and total
mass is integrated from the state. Enthalpy is derived from the EOS only while
writing TOP's external format; it does not become a SERiF primary field.
TOP represents a 2D, equatorially symmetric background. The exporter averages
over azimuth and equatorial reflection at common surface-fitted radial
coordinates, and projects both geometry and enthalpy onto normalized spherical
harmonics. This is a measured approximation to the 3D FE representation. The
returned `TopExportReport` and `metadata.txt` record source/projected radii,
physical scales, symmetry and surface-projection discrepancies, sampled enthalpy
angular truncation, surface enthalpy, mapping validity and tiny negative-density
roundoff clamps. Defaults reject symmetry and surface-projection errors above
1e-3 and surface enthalpy above 1e-6 of its central value. Enthalpy angular
truncation is reported relative to central enthalpy, without a rejection limit;
it is distinct from the symmetry discrepancy. Geometry and projection checks
are sampled diagnostics, not global error bounds. These are export admissibility limits, not seismic accuracy
targets. Negative density is clamped only within 128 machine epsilons of the
central density; larger negatives are rejected.
Output files are `lambda_eps`, `enthalpy_et_der`, `domains_boundaries`, plus
`metadata.txt`. Radial derivatives are with respect to TOP's mapping coordinate,
computed from the projected Chebyshev samples. No Bernoulli reconstruction,
extra equilibrium solve or interior smoothing is performed. Finite-element
interpolation noise can therefore affect those derivatives and the modes; vary
export resolution at fixed FE solution before attributing a frequency change to
the stellar equilibrium. For n > 1, deriving enthalpy from FE density can amplify
surface regularity defects; accepting an export does not establish derivative or
mode convergence. The actual-solver integration benchmark currently covers n=1.
Use TOP's recovered `poly_ester` reader and `eq_poly_ester_gamma2` for the
selected n=1, Gamma1=2 benchmark. The files describe the equilibrium; the TOP
equation module supplies Gamma1. TOP frequencies are angular frequencies divided
by `sqrt(4*pi*G*rho_c)`; `angular_frequency_unit` in the report supplies that
physical conversion in the input model's unit system. Divide by 2*pi to obtain
cyclic frequency.
The backend-independent overload in `serif/utils/serialize/top.hpp` accepts a
`TopPolytropeSource` with physical surface-radius and enthalpy callbacks, allowing
other sampling backends without an MFEM dependency. Analytic source and MFEM
regressions are part of the serialization test suite. An optional test against
the actual recovered TOP reader is available after building `top_export_fixture`:
```sh
source /Users/tboudreaux/Programming/top/activate-top.sh
python tests/serialize/top_reader.py build/tests/serialize/top_export_fixture
```
That optional check requires TOP's compiled `eq_poly_ester_gamma2` module. TOP is
not a SERiF build dependency.

View File

@@ -0,0 +1,131 @@
#pragma once
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "serif/utils/serialize/record.hpp"
namespace serif::utils::serialize {
/** @brief Both encodings share the same component schemas and indexed, sectioned file layout. */
enum class Format { binary, text };
/** @brief Application-version compatibility is separate from file-format and component-schema compatibility. */
enum class VersionTolerance { same_major, exact, any };
/** @brief Annotations belong to the written observation, not to the stellar physics. */
struct WriteOptions {
Format format{Format::binary};
std::string author;
std::string comment;
std::map<std::string, std::string> metadata;
};
/** @brief Bound resource use when inspecting an unfamiliar archive; limits may be raised explicitly. */
struct ReadOptions {
VersionTolerance version_tolerance{VersionTolerance::same_major};
std::uint64_t maximum_index_bytes{64 * 1024 * 1024};
std::uint64_t maximum_payload_bytes{1024ULL * 1024 * 1024};
std::uint64_t maximum_entries{1000000};
std::uint64_t maximum_depth{128};
};
/** @brief The ledger locates each independent field without decoding the rest of the archive. */
struct Entry {
std::string path;
std::string type;
std::uint32_t version{1};
std::uint32_t kind{0};
std::uint64_t count{0};
std::uint64_t offset{0};
std::uint64_t bytes{0};
std::string checksum;
};
/** @brief A pack owns its data immediately, so subsequent model changes cannot alter a pending write. */
struct Pack {
Node root{.type = "pack"};
std::optional<Error> error;
};
/** @brief Assign an explicit top-level name, for example when storing two stars in the same archive. */
template <typename T>
[[nodiscard]] Field named(std::string name, const T& value) { return field(std::move(name), value); }
/** @brief Insert a component while rejecting conflicting definitions of the same named object. */
void add_component(Pack& pack, Field component);
/** @brief Capture every supplied component; unregistered nested types remain visible as unsupported entries. */
template <typename... T>
[[nodiscard]] Pack pack(const T&... values) {
Pack result;
std::size_t index = 0;
([&] {
if constexpr (std::same_as<std::remove_cvref_t<T>, Field>) {
add_component(result, values);
} else if constexpr (requires { Serializer<std::remove_cvref_t<T>>::key; }) {
add_component(result, field(std::string{Serializer<std::remove_cvref_t<T>>::key}, values));
} else {
add_component(result, field("component_" + std::to_string(index), values));
}
++index;
}(), ...);
return result;
}
/** @brief Write a complete temporary file and atomically replace the destination only after it closes successfully. */
[[nodiscard]] Result<void> write(const std::filesystem::path& path, const Pack& contents, const WriteOptions& options = {});
/** @brief Inspect the ledger first, then seek directly to whichever components are actually needed. */
class Reader {
public:
/** @brief Validate the header, compatibility policy, and index without reading coefficient payloads. */
[[nodiscard]] static Result<Reader> open(const std::filesystem::path& path, ReadOptions options = {});
/** @brief Inspect all available components, including unsupported or incomplete descriptions. */
[[nodiscard]] const std::vector<Entry>& ledger() const noexcept { return m_entries; }
/** @brief Read the SERiF version reported by the writer. */
[[nodiscard]] const std::string& serif_version() const noexcept { return m_version; }
/** @brief Inspect the on-disk numeric encoding. */
[[nodiscard]] Format format() const noexcept { return m_format; }
/** @brief Check whether a named component or field is present without decoding it. */
[[nodiscard]] bool contains(std::string_view path) const;
/** @brief Read a subtree, verifying each selected payload before returning owned data. */
[[nodiscard]] Result<Node> node(std::string_view path) const;
/** @brief Read timestamp, host information, library versions, and user annotations. */
[[nodiscard]] Result<Node> metadata() const { return node("provenance"); }
/** @brief Decode a component using its registered default name, or an explicit archive path. */
template <typename T>
[[nodiscard]] Result<T> read(std::string_view path = {}) const {
if (path.empty()) {
if constexpr (requires { Serializer<T>::key; }) {
path = Serializer<T>::key;
} else {
return std::unexpected(Error{"", "Specify the archive path for a type without a default component name."});
}
}
auto saved = node(path);
if (!saved) {
return std::unexpected(saved.error());
}
auto value = decode<T>(*saved);
if (!value) {
value.error().path = std::string{path} + (value.error().path.empty() ? "" : "/" + value.error().path);
}
return value;
}
/** @brief Check every payload, useful before transferring responsibility for an archive to another researcher. */
[[nodiscard]] Result<void> validate() const;
private:
mutable std::ifstream m_file;
std::vector<Entry> m_entries;
std::string m_version;
Format m_format{Format::binary};
ReadOptions m_options;
};
}

View File

@@ -0,0 +1,72 @@
#pragma once
#include <string_view>
#include <tuple>
#include <utility>
#include "serif/utils/serialize/record.hpp"
namespace serif::utils::serialize {
/** @brief Name a plain configuration member without adding persistence methods to its owner. */
template <typename Owner, typename ValueT>
struct NamedMember {
using Value = ValueT;
std::string_view name;
Value Owner::* pointer;
};
/** @brief Deduce a member's stored type from its ordinary C++ declaration. */
template <typename Owner, typename Value>
[[nodiscard]] constexpr auto member_field(std::string_view name, Value Owner::* pointer) {
return NamedMember<Owner, Value>{name, pointer};
}
/** @brief Compose an explicitly listed aggregate; the field list is also its constructor order. */
template <typename T>
struct StructSerializer {
/** @brief Delegate each named field to its own serializer. */
[[nodiscard]] static Node save(const T& value) {
return std::apply([&](const auto&... fields) {
return object(Serializer<T>::key, field(std::string{fields.name}, value.*fields.pointer)...);
}, Serializer<T>::members);
}
/** @brief Reconstruct only after every named field has decoded successfully. */
[[nodiscard]] static Result<T> load(const Node& node) {
auto result = std::apply([&](const auto&... fields) {
return read_object(node, Serializer<T>::key, [](auto&&... values) {
return T{std::forward<decltype(values)>(values)...};
}, member<typename std::remove_cvref_t<decltype(fields)>::Value>(fields.name)...);
}, Serializer<T>::members);
if (!result) { return result; }
if constexpr (requires { result->validate(); }) { result->validate(); }
if constexpr (requires { Serializer<T>::valid(*result); }) {
if (!Serializer<T>::valid(*result)) {
return std::unexpected(Error{"", "Saved parameters lie outside the valid domain for " + std::string{Serializer<T>::key}});
}
}
return result;
}
};
/** @brief Encode enumerations by declared stable spellings, never compiler-dependent integers. */
template <typename T>
struct EnumSerializer {
/** @brief Look up the explicitly registered scientific or numerical choice. */
[[nodiscard]] static Node save(T value) {
for (const auto& [choice, name] : Serializer<T>::choices) {
if (choice == value) { return {.type = std::string{Serializer<T>::key}, .value = std::string{name}}; }
}
return {.type = "unsupported", .value = std::string{"The enumeration value is not registered."}};
}
/** @brief Reject unknown choices instead of silently selecting a default. */
[[nodiscard]] static Result<T> load(const Node& node) {
const auto* name = std::get_if<std::string>(&node.value);
if (node.type == Serializer<T>::key && name != nullptr && node.children.empty()) {
for (const auto& [choice, spelling] : Serializer<T>::choices) {
if (*name == spelling) { return choice; }
}
}
return std::unexpected(Error{"", "Unknown saved enumeration choice for " + std::string{Serializer<T>::key}});
}
};
}

View File

@@ -0,0 +1,145 @@
#pragma once
#include "serif/utils/serialize/physics.hpp"
#include "serif/equations/equations.hpp"
#include "serif/equations/registry.hpp"
namespace serif::utils::serialize {
template <int Axis, typename Row>
struct Serializer<equations::BarycenterConstraint<Axis, Row>> {
using T = equations::BarycenterConstraint<Axis, Row>;
static constexpr std::string_view key = std::same_as<Row, discretization::blocks::frame_acceleration::component<Axis>>
? std::array<std::string_view, 3>{"equation.barycenter.x", "equation.barycenter.y", "equation.barycenter.z"}[Axis]
: std::array<std::string_view, 3>{"equation.orbital_barycenter.x", "equation.orbital_barycenter.y", "equation.orbital_barycenter.z"}[Axis];
/** @brief Preserve both the normalization mass and desired barycenter. */
[[nodiscard]] static Node save(const T& value) { return object(key, field("target_mass", value.target_mass()), field("center", value.center())); }
/** @brief Validate the selected normalization and center. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](double mass, std::array<double, 3> center) { return T{mass, center}; },
member<double>("target_mass"), member<std::array<double, 3>>("center"));
}
};
template <>
struct Serializer<equations::PointMassCompanion> {
using T = equations::PointMassCompanion;
static constexpr std::string_view key = "equation.point_mass_companion";
/** @brief Preserve G M_c, the companion's position and whether only its tide acts. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("gravitational_mass", value.gravitational_mass()), field("position", value.position()),
field("tidal_only", value.tidal_only()));
}
/** @brief Validate through the public constructor. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](double mass, std::array<double, 3> position, bool tidal_only) { return T{mass, position, tidal_only}; },
member<double>("gravitational_mass"), member<std::array<double, 3>>("position"), member<bool>("tidal_only"));
}
};
namespace detail {
/** @brief Archives written before companions existed have no companion field; a single star stays one. */
[[nodiscard]] inline Result<std::optional<equations::PointMassCompanion>> read_companion(const Node& node) {
if (!node.children.contains("companion")) { return std::optional<equations::PointMassCompanion>{}; }
auto companion = read_field<equations::PointMassCompanion>(node, "companion");
if (!companion) { return std::unexpected(companion.error()); }
return std::optional<equations::PointMassCompanion>{*companion};
}
/** @brief Record the companion only when there is one, so single-star archives keep their schema. */
inline Node with_companion(Node node, const std::optional<equations::PointMassCompanion>& companion) {
if (companion) { node.children.emplace("companion", encode(*companion)); }
return node;
}
}
template <typename EOS>
struct Serializer<equations::BarotropicDensityBalance<EOS>> {
using T = equations::BarotropicDensityBalance<EOS>;
static constexpr std::string_view key = "equation.barotropic_density";
/** @brief Preserve the EOS, surface pressure and physical rotation geometry rather than a cached primitive. */
[[nodiscard]] static Node save(const T& value) {
return detail::with_companion(object(key, field("eos", value.equation_of_state()), field("surface_pressure", value.surface_pressure()),
field("axis", value.rotation().angular_velocity_vector()), field("center", value.rotation().axis_origin())), value.companion());
}
/** @brief Reconstruct the surface reference through the equation's validated public constructor. */
[[nodiscard]] static Result<T> load(const Node& node) {
const auto companion = detail::read_companion(node);
if (!companion) { return std::unexpected(companion.error()); }
return read_object(node, key, [&](EOS eos, dimensions::PressureValue pressure, std::array<double, 3> axis, std::array<double, 3> center) {
return T{std::move(eos), pressure, axis, center, *companion};
}, member<EOS>("eos"), member<dimensions::PressureValue>("surface_pressure"),
member<std::array<double, 3>>("axis"), member<std::array<double, 3>>("center"));
}
};
template <>
struct Serializer<equations::PrimalPoissonBalance> {
using T = equations::PrimalPoissonBalance;
static constexpr std::string_view key = "equation.primal_poisson";
/** @brief Store the physical gravity coefficient without prepared finite-element data. */
[[nodiscard]] static Node save(const T& value) { return object(key, field("four_pi_G", value.four_pi_G())); }
/** @brief Restore the scalar potential equation through its public constructor. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](double value) { return T{value}; }, member<double>("four_pi_G"));
}
};
template <>
struct Serializer<equations::SurfacePotentialBalance> {
using T = equations::SurfacePotentialBalance;
static constexpr std::string_view key = "equation.surface_potential";
/** @brief Preserve the rotation convention used on the physical isobar. */
[[nodiscard]] static Node save(const T& value) {
return detail::with_companion(object(key, field("axis", value.rotation().angular_velocity_vector()),
field("center", value.rotation().axis_origin())), value.companion());
}
/** @brief Restore the boundary equation independently of any surface mesh. */
[[nodiscard]] static Result<T> load(const Node& node) {
const auto companion = detail::read_companion(node);
if (!companion) { return std::unexpected(companion.error()); }
return read_object(node, key, [&](std::array<double, 3> axis, std::array<double, 3> center) { return T{axis, center, *companion}; },
member<std::array<double, 3>>("axis"), member<std::array<double, 3>>("center"));
}
};
template <>
struct Serializer<equations::MassNormalization> {
using T = equations::MassNormalization;
static constexpr std::string_view key = "equation.mass_normalization";
/** @brief Store the physical coefficient independently of integrated caches. */
[[nodiscard]] static Node save(const T& value) { return object(key, field("mass", -value.residual_offset())); }
/** @brief Reconstruct the physical contribution. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](double value) { return T{value}; }, member<double>("mass"));
}
};
template <>
struct Serializer<equations::AngularMomentumConstraint> {
using T = equations::AngularMomentumConstraint;
static constexpr std::string_view key = "equation.angular_momentum";
/** @brief Preserve target and geometry independently of model prescriptions. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("target", -value.residual_offset()), field("axis", value.rotation().angular_velocity_vector()), field("center", value.rotation().axis_origin()));
}
/** @brief Rebuild the constraint through its validated constructor. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](double target, std::array<double, 3> axis, std::array<double, 3> center) { return T{target, axis, center}; },
member<double>("target"), member<std::array<double, 3>>("axis"), member<std::array<double, 3>>("center"));
}
};
template <typename... Equations>
struct Serializer<equations::EquationRegistry<Equations...>> {
using T = equations::EquationRegistry<Equations...>;
static constexpr std::string_view key = "equations.registry";
/** @brief Serialize equations through their public typed registry accessors. */
[[nodiscard]] static Node save(const T& value) { return object(key, field("equations", std::tie(value.template get<Equations>()...))); }
/** @brief Preserve compiled row identities while replacing only authored physical data. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](std::tuple<Equations...> equations) {
return std::apply([](auto&&... values) { return T{std::move(values)...}; }, std::move(equations));
}, member<std::tuple<Equations...>>("equations"));
}
};
}

View File

@@ -0,0 +1,276 @@
#pragma once
#include "serif/utils/serialize/equations.hpp"
#include "serif/model/state.hpp"
#include "serif/model/stellar_model.hpp"
#include "serif/discretization/blocks/fields.hpp"
#include "serif/utils/dependencies/dependency_context.hpp"
namespace serif::utils::serialize {
/** @brief Register stable coordinate identities separately from compiler-generated display names. */
template <typename Term>
struct TermIdentity {
static constexpr std::string_view id = [] {
if constexpr (requires { Term::stable_id; }) { return std::string_view{Term::stable_id}; }
else { return std::string_view{}; }
}();
};
template <> struct TermIdentity<discretization::blocks::density::mass> { static constexpr std::string_view id = "density.mass"; };
template <> struct TermIdentity<discretization::blocks::gravity::potential> { static constexpr std::string_view id = "gravity.potential"; };
template <> struct TermIdentity<discretization::blocks::displacement::vector> { static constexpr std::string_view id = "displacement.vector"; };
template <> struct TermIdentity<discretization::blocks::rotation::angular_velocity> { static constexpr std::string_view id = "rotation.angular_velocity"; };
template <> struct TermIdentity<discretization::blocks::barotropic_constant::mass_normalization> { static constexpr std::string_view id = "barotropic_constant.mass_normalization"; };
template <> struct TermIdentity<discretization::blocks::surface_deformation::coefficients> { static constexpr std::string_view id = "surface_deformation.coefficients"; };
template <> struct TermIdentity<discretization::blocks::surface_deformation::shape_equilibrium> { static constexpr std::string_view id = "surface_deformation.shape_equilibrium"; };
template <typename Specification>
struct TermIdentity<model::GeneratedCoordinateOf<Specification>> {
static constexpr std::string_view id = Specification::ModelDefinition::ScalarDescription::coordinate_id;
};
template <typename Specification>
struct TermIdentity<model::ConstraintOf<Specification>> {
static constexpr std::string_view id = Specification::ModelDefinition::ScalarDescription::residual_id;
};
/** @brief Expose the physical meaning and FE representation of one compiled coordinate. */
template <typename Term>
[[nodiscard]] Node term_schema() {
if constexpr (TermIdentity<Term>::id.empty()) {
return {.type = "unsupported", .value = std::string{"Register TermIdentity<T> or T::stable_id for this coordinate."}};
} else {
Node result = object("coordinate", field("id", std::string{TermIdentity<Term>::id}));
if constexpr (requires { typename Term::Quantity; }) {
result.children.emplace("dimension", encode(std::string{Term::Quantity::identifier}));
}
if constexpr (requires { typename Term::FunctionalSpace; }) {
using Space = typename Term::FunctionalSpace;
constexpr std::array names{"l2", "h1", "hdiv", "hcurl"};
result.children.emplace("space", object("functional_space", field("family", std::string{names[static_cast<std::size_t>(Space::functional_space.family)]}),
field("order", Space::order), field("rank", Space::rank::value)));
}
if constexpr (requires { Term::Support::names; }) {
std::vector<std::string> support;
for (std::string_view name : Term::Support::names) { support.emplace_back(name); }
result.children.emplace("support", encode(support));
}
if constexpr (std::same_as<Term, discretization::blocks::surface_deformation::coefficients> || std::same_as<Term, discretization::blocks::surface_deformation::shape_equilibrium>) {
constexpr bool coefficients = std::same_as<Term, discretization::blocks::surface_deformation::coefficients>;
result.children.emplace("dimension", encode(std::string{coefficients ? "length" : "force"}));
result.children.emplace("support", encode(std::vector<std::string>{"stellar_surface"}));
result.children.emplace("basis", encode(std::string{"declared_surface_representation"}));
}
if constexpr (requires { typename Term::SpecificationType; }) {
using Description = typename Term::SpecificationType::ModelDefinition::ScalarDescription;
if constexpr (std::same_as<Term, model::GeneratedCoordinateOf<typename Term::SpecificationType>>) {
result.children.emplace("dimension", encode(std::string{Description::CoordinateQuantity::identifier}));
} else {
result.children.emplace("dimension", encode(std::string{Description::ResidualQuantity::identifier}));
}
}
return result;
}
}
/** @brief A compiled identity must match and must have an explicit persistence registration. */
inline Result<void> validate_schema(const Node& saved, const Node& expected) {
if (expected.type == "unsupported") { return std::unexpected(Error{"", "A compiled identity has no stable serialization registration."}); }
if (saved != expected) { return std::unexpected(Error{"", "Saved physical coordinates do not match the requested compiled schema."}); }
for (const auto& [name, child] : expected.children) {
auto checked = validate_schema(child, child);
if (!checked) {
checked.error().path = name + (checked.error().path.empty() ? "" : "/" + checked.error().path);
return checked;
}
}
return {};
}
template <typename... Terms>
struct Serializer<dependencies::KeyList<Terms...>> {
static constexpr std::string_view key = "coordinate_list";
/** @brief Preserve independent coordinate order with stable per-term metadata. */
[[nodiscard]] static Node save(dependencies::KeyList<Terms...>) {
Node result = object(key);
std::size_t index = 0;
(result.children.emplace(std::to_string(index++), term_schema<Terms>()), ...);
return result;
}
/** @brief Never reinterpret an equal-sized vector as a different physical layout. */
[[nodiscard]] static Result<dependencies::KeyList<Terms...>> load(const Node& node) {
auto valid = validate_schema(node, save({}));
if (!valid) { return std::unexpected(valid.error()); }
return dependencies::KeyList<Terms...>{};
}
};
template <typename Values, typename Residuals>
struct Serializer<discretization::blocks::SystemLayout<Values, Residuals>> {
using T = discretization::blocks::SystemLayout<Values, Residuals>;
static constexpr std::string_view key = "system_layout";
/** @brief Save both coordinate orders and their independent lengths. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("values", Values{}), field("residuals", Residuals{}), field("state_sizes", value.state_sizes), field("residual_sizes", value.residual_sizes));
}
/** @brief Check schemas and total-size overflow before constructing a layout. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](Values, Residuals, decltype(T::state_sizes) states, decltype(T::residual_sizes) residuals) {
(void)discretization::blocks::checked_block_size(states);
(void)discretization::blocks::checked_block_size(residuals);
return T{states, residuals};
}, member<Values>("values"), member<Residuals>("residuals"), member<decltype(T::state_sizes)>("state_sizes"), member<decltype(T::residual_sizes)>("residual_sizes"));
}
};
template <typename Layout>
struct Serializer<model::StellarState<Layout>> {
using T = model::StellarState<Layout>;
static constexpr std::string_view key = "stellar_state";
/** @brief Keep physical coefficients exact, including every surface and scalar coordinate. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("layout", value.layout()), field("coefficients", std::vector<double>{value.values().begin(), value.values().end()}));
}
/** @brief Reject coefficient counts inconsistent with the saved physical layout. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](Layout layout, std::vector<double> values) { return T{std::move(layout), std::move(values)}; }, member<Layout>("layout"), member<std::vector<double>>("coefficients"));
}
};
template <>
struct Serializer<model::NoShapeForce> : StructSerializer<model::NoShapeForce> {
static constexpr std::string_view key = "equations.no_shape";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<model::NoSurfaceCondition> : StructSerializer<model::NoSurfaceCondition> {
static constexpr std::string_view key = "equations.no_surface";
static constexpr auto members = std::tuple{};
};
template <typename Interior, typename Shape, typename Surface>
struct Serializer<model::EquilibriumEquations<Interior, Shape, Surface>> {
using T = model::EquilibriumEquations<Interior, Shape, Surface>;
static constexpr std::string_view key = "equilibrium_equations";
/** @brief Save each authored equation responsibility separately. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("interior", value.interior_equations), field("shape", value.shape_force), field("surface", value.surface_condition));
}
/** @brief Reconstruct owning compositions; resolved borrowed views are recreated by model binding. */
[[nodiscard]] static Result<T> load(const Node& node) requires (!std::is_reference_v<Interior> && !std::is_reference_v<Shape> &&
!std::is_reference_v<Surface>) {
return read_object(node, key, [](Interior interior, Shape shape, Surface surface) {
return T{std::move(interior), std::move(shape), std::move(surface)};
}, member<Interior>("interior"), member<Shape>("shape"), member<Surface>("surface"));
}
};
template <typename EOS, typename Definition, typename... Specifications>
struct Serializer<model::StellarModel<EOS, Definition, types::TypeList<Specifications...>>> {
using T = model::StellarModel<EOS, Definition, types::TypeList<Specifications...>>;
static constexpr std::string_view key = "stellar_model";
/** @brief Preserve authored intent and, when available, independently reconstructible current physics. */
[[nodiscard]] static Node save(const T& value) {
Node result = object(key, field("eos", value.equation_of_state()), field("authored_definition", value.equations()),
field("prescriptions", std::tie(value.template specification<Specifications>()...)));
if constexpr (requires { model::resolve_equilibrium_equations(value); }) {
result.children.emplace("resolved_equations", encode(model::resolve_equilibrium_equations(value)));
}
return result;
}
/** @brief Exact authored-type reconstruction requires a serializer for its definition or callable. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](EOS eos, Definition equations, std::tuple<Specifications...> specifications) {
return std::apply([&](auto&&... values) { return T{std::move(eos), std::move(equations), std::move(values)...}; }, std::move(specifications));
}, member<EOS>("eos"), member<Definition>("authored_definition"), member<std::tuple<Specifications...>>("prescriptions"));
}
};
namespace detail {
template <typename Model>
using ResolvedEquations = decltype(model::resolve_equilibrium_equations(std::declval<const Model&>()));
template <typename Model>
using OwnedEquations = model::EquilibriumEquations<typename ResolvedEquations<Model>::InteriorEquations, typename ResolvedEquations<Model>::ShapeForce,
typename ResolvedEquations<Model>::SurfaceCondition>;
}
/** @brief Restore current physics as an explicit composition, without pretending to regenerate a deferred program. */
template <typename OriginalModel>
using RestoredStellarModel = model::StellarModel<typename OriginalModel::EquationOfState, detail::OwnedEquations<OriginalModel>, typename OriginalModel::SpecificationTypes>;
/** @brief Reconstruct resolved physics; future changes requiring the original callback remain a separate capability. */
template <typename OriginalModel>
[[nodiscard]] Result<RestoredStellarModel<OriginalModel>> restore_stellar_model(const Node& node) {
using T = RestoredStellarModel<OriginalModel>;
Node owning = node;
const auto found = node.children.find("resolved_equations");
if (found == node.children.end()) { return std::unexpected(Error{"resolved_equations", "This model has no saved concrete equilibrium description."}); }
owning.children.insert_or_assign("authored_definition", found->second);
return decode<T>(owning);
}
template <>
struct Serializer<dependencies::ValueSnapshot> : StructSerializer<dependencies::ValueSnapshot> {
static constexpr std::string_view key = "tracking.value";
static constexpr auto members = std::tuple{};
};
template <typename Key, typename Storage, typename Policy>
struct Serializer<dependencies::Resource<Key, Storage, Policy>> {
using T = dependencies::Resource<Key, Storage, Policy>;
using Value = typename T::Target;
static constexpr std::string_view key = "prescribed_resource";
static constexpr bool owned = std::same_as<Storage, dependencies::detail::OwnedValue<Value>>;
/** @brief Preserve readable prescribed data and explicitly distinguish borrowed ownership. */
[[nodiscard]] static Node save(const T& value) {
Node result = object(key, field("identity", term_schema<Key>()), field("value", value.get()), field("tracking", value.policy()),
field("ownership", std::string{owned ? "owned" : "borrowed"}));
if constexpr (!owned) {
result.children.emplace("reconstruction", Node{.type = "unsupported", .value = std::string{"Borrowed prescribed data requires explicit owner reconstruction and rebinding."}});
}
return result;
}
/** @brief A borrowed runtime object must be rebound by its owner, never reconstructed as a dangling reference. */
[[nodiscard]] static Result<T> load(const Node& node) {
if constexpr (!owned) {
return std::unexpected(Error{"ownership", "Rebind this borrowed prescribed value to an explicitly reconstructed owner."});
} else {
auto identity = read_field<Node>(node, "identity");
if (!identity) { return std::unexpected(identity.error()); }
auto valid = validate_schema(*identity, term_schema<Key>());
if (!valid) { return std::unexpected(valid.error()); }
return read_object(node, key, [](Value value, Policy policy, std::string ownership) {
errors::serif_ensure<errors::SERiFErrorCode::INVALID_ARGUMENT>(ownership == "owned", "A saved borrowed resource cannot silently become an owned input.");
return T{Storage{.value = std::move(value)}, std::move(policy)};
}, member<Value>("value"), member<Policy>("tracking"), member<std::string>("ownership"));
}
}
};
template <typename... Resources>
struct Serializer<dependencies::DependencyContext<Resources...>> {
using T = dependencies::DependencyContext<Resources...>;
static constexpr std::string_view key = "prescribed_inputs";
/** @brief Preserve each source's physical key, value, ownership and tracking policy. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("resources", std::tie(value.template resource<typename Resources::Key>()...)));
}
/** @brief Rebuild contexts only when all source values and ownership policies are reconstructible. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](std::tuple<Resources...> resources) {
return std::apply([](auto&&... values) { return T{std::move(values)...}; }, std::move(resources));
}, member<std::tuple<Resources...>>("resources"));
}
};
template <>
struct Serializer<dependencies::DependencyContext<>> {
static constexpr std::string_view key = "prescribed_inputs";
/** @brief Distinguish explicitly empty inputs from omitted or unavailable inputs. */
[[nodiscard]] static Node save(const dependencies::DependencyContext<>&) { return object(key); }
/** @brief Reject accidentally discarded prescribed inputs. */
[[nodiscard]] static Result<dependencies::DependencyContext<>> load(const Node& node) {
if (!node.children.empty()) { return std::unexpected(Error{"", "Expected no prescribed inputs."}); }
return read_object(node, key, [] { return dependencies::make_dependency_context(); });
}
};
}

View File

@@ -0,0 +1,483 @@
#pragma once
#include "serif/utils/serialize/model.hpp"
#include "serif/model/configured_discretization.hpp"
#include "serif/solver/normalization/configuration.hpp"
#include "serif/solver/normalization/diagonal.hpp"
#include "serif/solver/normalization/plan.hpp"
#include "serif/solver/preconditioner/default_preconditioners.hpp"
#include "serif/solver/nonlinear/options.hpp"
#include "serif/discretization/quadrature/policy.hpp"
#include "serif/model/normalization/plan.hpp"
#include "serif/geometry/deformation/computations.hpp"
namespace serif::utils::serialize {
template <model::StellarModelType Model, typename FiniteElements, typename Normalization, typename Preconditioner>
struct Serializer<model::DiscretizationOptions<Model, FiniteElements, Normalization, Preconditioner>> {
using T = model::DiscretizationOptions<Model, FiniteElements, Normalization, Preconditioner>;
static constexpr std::string_view key = "configuration";
static constexpr std::string_view type = "model.configured_discretization";
/** @brief Keep the complete declared solve configuration together with its physical model. */
[[nodiscard]] static Node save(const T& value) {
return object(type, field("model", value.model), field("finite_elements", value.finite_elements),
field("normalization", value.normalization), field("preconditioner", value.preconditioner));
}
/** @brief Restore the original compiled types when every component can reconstruct its ownership. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, type, [](Model model, FiniteElements finite_elements, Normalization normalization, Preconditioner preconditioner) {
return T{.model = std::move(model), .finite_elements = std::move(finite_elements),
.normalization = std::move(normalization), .preconditioner = std::move(preconditioner)};
}, member<Model>("model"), member<FiniteElements>("finite_elements"), member<Normalization>("normalization"), member<Preconditioner>("preconditioner"));
}
};
/** @brief Preserve the selected equation/unknown identities in every composed inverse. */
template <typename T>
struct RecipeSerializer : StructSerializer<T> {
/** @brief A recipe names physical coordinates independently of its nested numerical controls. */
[[nodiscard]] static Node save(const T& value) {
Node result = StructSerializer<T>::save(value);
result.children.emplace("rows", encode(typename T::Rows{}));
result.children.emplace("values", encode(typename T::Values{}));
return result;
}
/** @brief Reject recipes selecting different physical blocks even when their controls agree. */
[[nodiscard]] static Result<T> load(const Node& node) {
auto rows = read_field<typename T::Rows>(node, "rows");
if (!rows) { return std::unexpected(rows.error()); }
auto values = read_field<typename T::Values>(node, "values");
if (!values) { return std::unexpected(values.error()); }
return StructSerializer<T>::load(node);
}
};
template <>
struct Serializer<solver::Newton> : StructSerializer<solver::Newton> {
using T = solver::Newton;
static constexpr std::string_view key = "solver.newton";
static constexpr auto members = std::tuple{
member_field("relative_tolerance", &T::relative_tolerance),
member_field("absolute_tolerance", &T::absolute_tolerance),
member_field("maximum_iterations", &T::maximum_iterations),
member_field("linear_solve", &T::linear_solve)
};
};
template <>
struct Serializer<solver::Backtracking> : StructSerializer<solver::Backtracking> {
using T = solver::Backtracking;
static constexpr std::string_view key = "solver.backtracking";
static constexpr auto members = std::tuple{
member_field("initial_step", &T::initial_step),
member_field("contraction", &T::contraction),
member_field("fraction_to_boundary", &T::fraction_to_boundary),
member_field("sufficient_decrease", &T::sufficient_decrease),
member_field("minimum_step", &T::minimum_step),
member_field("maximum_trials", &T::maximum_trials),
member_field("geometry_preflight", &T::geometry_preflight)
};
};
template <>
struct Serializer<solver::linear::Control> : StructSerializer<solver::linear::Control> {
using T = solver::linear::Control;
static constexpr std::string_view key = "solver.linear.control";
static constexpr auto members = std::tuple{
member_field("relative_tolerance", &T::relative_tolerance),
member_field("absolute_tolerance", &T::absolute_tolerance),
member_field("maximum_iterations", &T::maximum_iterations)
};
};
template <>
struct Serializer<solver::linear::FGMRES> : StructSerializer<solver::linear::FGMRES> {
using T = solver::linear::FGMRES;
static constexpr std::string_view key = "solver.linear.fgmres";
static constexpr auto members = std::tuple{member_field("restart_length", &T::restart_length), member_field("print_level", &T::print_level)};
};
template <>
struct Serializer<solver::NormalizedResidualMetric> : StructSerializer<solver::NormalizedResidualMetric> {
using T = solver::NormalizedResidualMetric;
static constexpr std::string_view key = "solver.metric.normalized_residual";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::StandardNewton> : StructSerializer<solver::StandardNewton> {
using T = solver::StandardNewton;
static constexpr std::string_view key = "solver.strategy.standard_newton";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<laneemden::IntegrationOptions> : StructSerializer<laneemden::IntegrationOptions> {
using T = laneemden::IntegrationOptions;
static constexpr std::string_view key = "lane_emden.integration";
static constexpr auto members = std::tuple{
member_field("step", &T::step),
member_field("maximum_radius", &T::maximum_radius),
member_field("maximum_steps", &T::maximum_steps),
member_field("surface_tolerance", &T::surface_tolerance),
member_field("central_series_terms", &T::central_series_terms)
};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::isfinite(value.step) && value.step > 0.0 && std::isfinite(value.maximum_radius) && value.maximum_radius > 0.0 &&
std::isfinite(value.surface_tolerance) && value.surface_tolerance > 0.0 && value.surface_tolerance < 0.01 &&
value.maximum_steps > 0 && value.central_series_terms >= 6 && value.central_series_terms <= 64;
}
};
template <>
struct Serializer<solver::normalization::FromModel> : StructSerializer<solver::normalization::FromModel> {
using T = solver::normalization::FromModel;
static constexpr std::string_view key = "normalization.from_model";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::normalization::AutomaticPlan> : StructSerializer<solver::normalization::AutomaticPlan> {
using T = solver::normalization::AutomaticPlan;
static constexpr std::string_view key = "normalization.automatic_plan";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::normalization::Dimensionless> : StructSerializer<solver::normalization::Dimensionless> {
using T = solver::normalization::Dimensionless;
static constexpr std::string_view key = "normalization.dimensionless";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::normalization::StellarReferenceScales> : StructSerializer<solver::normalization::StellarReferenceScales> {
using T = solver::normalization::StellarReferenceScales;
static constexpr std::string_view key = "normalization.reference_scales";
static constexpr auto members = std::tuple{
member_field("mass", &T::mass),
member_field("radius", &T::radius),
member_field("gravitational_constant", &T::gravitational_constant),
member_field("density", &T::density),
member_field("acceleration", &T::acceleration),
member_field("inverse_time_squared", &T::inverse_time_squared),
member_field("specific_energy", &T::specific_energy),
member_field("pressure", &T::pressure),
member_field("angular_velocity", &T::angular_velocity),
member_field("angular_momentum", &T::angular_momentum),
member_field("force", &T::force)
};
/** @brief All characteristic scales must be finite and strictly positive. */
[[nodiscard]] static bool valid(const T& value) {
return std::apply([&](const auto&... fields) {
return ((std::isfinite((value.*fields.pointer).value()) && (value.*fields.pointer).value() > 0.0) && ...);
}, members);
}
};
template <>
struct Serializer<solver::normalization::UnitMetric> : StructSerializer<solver::normalization::UnitMetric> {
using T = solver::normalization::UnitMetric;
static constexpr std::string_view key = "normalization.metric.unit";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::normalization::IdentityCoordinate> : StructSerializer<solver::normalization::IdentityCoordinate> {
using T = solver::normalization::IdentityCoordinate;
static constexpr std::string_view key = "normalization.coordinate.identity";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<model::preconditioning::None> : StructSerializer<model::preconditioning::None> {
using T = model::preconditioning::None;
static constexpr std::string_view key = "preconditioner.default";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::preconditioner::strategies::inverse::Diagonal> : StructSerializer<solver::preconditioner::strategies::inverse::Diagonal> {
using T = solver::preconditioner::strategies::inverse::Diagonal;
static constexpr std::string_view key = "preconditioner.inverse.diagonal";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::preconditioner::strategies::inverse::DenseDirect> : StructSerializer<solver::preconditioner::strategies::inverse::DenseDirect> {
using T = solver::preconditioner::strategies::inverse::DenseDirect;
static constexpr std::string_view key = "preconditioner.inverse.dense_direct";
static constexpr auto members = std::tuple{member_field("minimum_reciprocal_condition", &T::minimum_reciprocal_condition)};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::isfinite(value.minimum_reciprocal_condition) && value.minimum_reciprocal_condition >= 0.0 && value.minimum_reciprocal_condition <= 1.0;
}
};
template <>
struct Serializer<solver::preconditioner::strategies::inverse::FixedAMG> : StructSerializer<solver::preconditioner::strategies::inverse::FixedAMG> {
using T = solver::preconditioner::strategies::inverse::FixedAMG;
static constexpr std::string_view key = "preconditioner.inverse.fixed_amg";
static constexpr auto members = std::tuple{member_field("cycles", &T::cycles)};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return value.cycles > 0;
}
};
template <>
struct Serializer<solver::preconditioner::strategies::inverse::SparseDirect> : StructSerializer<solver::preconditioner::strategies::inverse::SparseDirect> {
using T = solver::preconditioner::strategies::inverse::SparseDirect;
static constexpr std::string_view key = "preconditioner.inverse.sparse_direct";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<solver::preconditioner::strategies::coupling::SecondThenFirst> : StructSerializer<solver::preconditioner::strategies::coupling::SecondThenFirst> {
using T = solver::preconditioner::strategies::coupling::SecondThenFirst;
static constexpr std::string_view key = "preconditioner.coupling.second_then_first";
static constexpr auto members = std::tuple{};
};
template <typename Metric, typename Strategies, typename Linear>
struct Serializer<solver::SolverOptions<Metric, Strategies, Linear>> : StructSerializer<solver::SolverOptions<Metric, Strategies, Linear>> {
using T = solver::SolverOptions<Metric, Strategies, Linear>;
static constexpr std::string_view key = "solver_options";
static constexpr auto members = std::tuple{
member_field("method", &T::method),
member_field("linear_solver", &T::linear_solver),
member_field("line_search", &T::line_search),
member_field("metric", &T::metric),
member_field("strategies", &T::strategies)
};
};
template <typename Mass, typename Density>
struct Serializer<solver::normalization::LaneEmdenApproximation<Mass, Density>> : StructSerializer<solver::normalization::LaneEmdenApproximation<Mass, Density>> {
using T = solver::normalization::LaneEmdenApproximation<Mass, Density>;
static constexpr std::string_view key = "normalization.lane_emden";
static constexpr auto members = std::tuple{
member_field("n", &T::n),
member_field("mass", &T::mass),
member_field("central_density", &T::central_density),
member_field("integration", &T::integration)
};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::isfinite(value.n) && value.n >= 0.0 && [&] {
if constexpr (!std::same_as<Mass, solver::normalization::FromModel>) {
if (!std::isfinite(value.mass.value()) || value.mass.value() <= 0.0) { return false; }
}
if constexpr (!std::same_as<Density, solver::normalization::FromModel>) {
if (!std::isfinite(value.central_density.value()) || value.central_density.value() <= 0.0) { return false; }
}
return true;
}();
}
};
template <typename Scheme, typename Coordinates, typename Plan, typename Sources, typename Computations>
struct Serializer<solver::normalization::NormalizationOptions<Scheme, Coordinates, Plan, Sources, Computations>> : StructSerializer<solver::normalization::NormalizationOptions<Scheme, Coordinates, Plan, Sources, Computations>> {
using T = solver::normalization::NormalizationOptions<Scheme, Coordinates, Plan, Sources, Computations>;
static constexpr std::string_view key = "normalization";
static constexpr auto members = std::tuple{
member_field("scheme", &T::scheme),
member_field("coordinates", &T::coordinates),
member_field("plan", &T::plan),
member_field("gravitational_constant", &T::gravitational_constant),
member_field("sources", &T::sources),
member_field("computations", &T::computations)
};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::isfinite(value.gravitational_constant.value()) && value.gravitational_constant.value() > 0.0;
}
};
template <typename Row, typename Field, typename Strategy>
struct Serializer<solver::preconditioner::BlockPreconditioner<Row, Field, Strategy>> : RecipeSerializer<solver::preconditioner::BlockPreconditioner<Row, Field, Strategy>> {
using T = solver::preconditioner::BlockPreconditioner<Row, Field, Strategy>;
static constexpr std::string_view key = "preconditioner.block";
static constexpr auto members = std::tuple{member_field("inverse", &T::inverse)};
};
template <typename First, typename Second, typename Policy>
struct Serializer<solver::preconditioner::CoupledPreconditioner<First, Second, Policy>> : RecipeSerializer<solver::preconditioner::CoupledPreconditioner<First, Second, Policy>> {
using T = solver::preconditioner::CoupledPreconditioner<First, Second, Policy>;
static constexpr std::string_view key = "preconditioner.coupled";
static constexpr auto members = std::tuple{member_field("first", &T::first), member_field("second", &T::second), member_field("coupling", &T::coupling)};
};
template <typename Fields, typename Constraints>
struct Serializer<solver::preconditioner::BorderedPreconditioner<Fields, Constraints>> : RecipeSerializer<solver::preconditioner::BorderedPreconditioner<Fields, Constraints>> {
using T = solver::preconditioner::BorderedPreconditioner<Fields, Constraints>;
static constexpr std::string_view key = "preconditioner.bordered";
static constexpr auto members = std::tuple{member_field("fields", &T::fields), member_field("constraint_inverse", &T::constraint_inverse)};
};
template <>
struct Serializer<solver::normalization::DiagonalNormalization> {
static constexpr std::string_view key = "normalization.diagonal";
/** @brief Preserve the exact independently prepared primal and dual factors. */
[[nodiscard]] static Node save(const solver::normalization::DiagonalNormalization& value) {
return object(key, field("state", std::vector<double>{value.state_factors().begin(), value.state_factors().end()}),
field("residual", std::vector<double>{value.residual_factors().begin(), value.residual_factors().end()}));
}
/** @brief Reuse the existing positive finite factor validation. */
[[nodiscard]] static Result<solver::normalization::DiagonalNormalization> load(const Node& node) {
auto factors = read_object(node, key, [](std::vector<double> state, std::vector<double> residual) {
return solver::normalization::make_diagonal_normalization(std::move(state), std::move(residual));
}, member<std::vector<double>>("state"), member<std::vector<double>>("residual"));
if (!factors) { return std::unexpected(factors.error()); }
if (!*factors) { return std::unexpected(Error{"", factors->error()}); }
return std::move(**factors);
}
};
/** @brief Reconstruct stateless typed numerical declarations only when all their identities match. */
template <typename T>
struct SchemaSerializer {
/** @brief Materialize the declarative schema provided by this specialization. */
[[nodiscard]] static Node save(T) { return Serializer<T>::schema(); }
/** @brief Validate rather than infer the compiled numerical recipe from file strings. */
[[nodiscard]] static Result<T> load(const Node& node) {
auto valid = validate_schema(node, Serializer<T>::schema());
if (!valid) { return std::unexpected(valid.error()); }
return T{};
}
};
template <typename Term>
struct Serializer<solver::normalization::ReferenceVolumeMetric<Term>> : SchemaSerializer<solver::normalization::ReferenceVolumeMetric<Term>> {
static constexpr std::string_view key = "normalization.metric.volume";
/** @brief Tie the reference metric to its physical FE coordinate. */
[[nodiscard]] static Node schema() { return object(key, field("coordinate", term_schema<Term>())); }
};
template <>
struct Serializer<geometry::deformation::keys::SurfaceMetric> : StructSerializer<geometry::deformation::keys::SurfaceMetric> {
static constexpr std::string_view key = "normalization.metric.surface";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<model::normalization::SurfaceRows> : StructSerializer<model::normalization::SurfaceRows> {
static constexpr std::string_view key = "normalization.surface_rows";
static constexpr auto members = std::tuple{};
};
template <typename Volume, typename Quantity, typename Selection>
struct Serializer<solver::normalization::WithPointRows<Volume, Quantity, Selection>> : SchemaSerializer<solver::normalization::WithPointRows<Volume, Quantity, Selection>> {
static constexpr std::string_view key = "normalization.coordinate.point_rows";
/** @brief Distinguish strong point rows from their surrounding weak volume equations. */
[[nodiscard]] static Node schema() {
return object(key, field("volume", Volume{}), field("dimension", std::string{Quantity::identifier}), field("selection", Selection{}));
}
};
template <typename Quantity, typename Metric>
struct Serializer<solver::normalization::PhysicalRieszCoordinate<Quantity, Metric>> : SchemaSerializer<solver::normalization::PhysicalRieszCoordinate<Quantity, Metric>> {
static constexpr std::string_view key = "normalization.coordinate.physical_riesz";
/** @brief Retain independent physical dimension and metric identity. */
[[nodiscard]] static Node schema() { return object(key, field("dimension", std::string{Quantity::identifier}), field("metric", Metric{})); }
};
template <typename Quantity, typename Metric, solver::normalization::ReferenceGeometry Geometry>
struct Serializer<solver::normalization::DimensionlessRieszCoordinate<Quantity, Metric, Geometry>> : SchemaSerializer<solver::normalization::DimensionlessRieszCoordinate<Quantity, Metric, Geometry>> {
static constexpr std::string_view key = "normalization.coordinate.dimensionless_riesz";
/** @brief Preserve the volume, surface, or algebraic metric convention explicitly. */
[[nodiscard]] static Node schema() {
constexpr std::array names{"volume", "surface", "algebraic"};
return object(key, field("dimension", std::string{Quantity::identifier}), field("metric", Metric{}), field("geometry", std::string{names[static_cast<std::size_t>(Geometry)]}));
}
};
template <typename Key, typename Target>
struct Serializer<dependencies::Binding<Key, Target>> : SchemaSerializer<dependencies::Binding<Key, Target>> {
static constexpr std::string_view key = "normalization.rule";
/** @brief Preserve a coordinate-to-method association without positional inference. */
[[nodiscard]] static Node schema() { return object(key, field("coordinate", term_schema<Key>()), field("method", Target{})); }
};
template <typename... Bindings>
struct Serializer<dependencies::Bindings<Bindings...>> : SchemaSerializer<dependencies::Bindings<Bindings...>> {
static constexpr std::string_view key = "normalization.rules";
/** @brief Preserve the complete explicit rule catalog. */
[[nodiscard]] static Node schema() { return object(key, field("rules", std::tuple<Bindings...>{})); }
};
template <typename Values, typename Residuals>
struct Serializer<solver::normalization::NormalizationPlan<Values, Residuals>> : SchemaSerializer<solver::normalization::NormalizationPlan<Values, Residuals>> {
static constexpr std::string_view key = "normalization.plan";
/** @brief Retain both independently dimensioned sides of an explicit plan. */
[[nodiscard]] static Node schema() { return object(key, field("values", Values{}), field("residuals", Residuals{})); }
};
template <>
struct Serializer<discretization::quadrature::RuleControl> : StructSerializer<discretization::quadrature::RuleControl> {
using T = discretization::quadrature::RuleControl;
static constexpr std::string_view key = "quadrature.control";
static constexpr auto members = std::tuple{member_field("fixed_order", &T::fixed_order), member_field("boost", &T::boost)};
/** @brief Fixed quadrature overrides cannot request a negative integration order. */
[[nodiscard]] static bool valid(const T& value) { return !value.fixed_order || *value.fixed_order >= 0; }
};
template <>
struct Serializer<discretization::quadrature::RuleSet> {
using T = discretization::quadrature::RuleSet;
static constexpr std::string_view key = "quadrature.rules";
/** @brief Preserve role controls and explicitly report custom borrowed form descriptors. */
[[nodiscard]] static Node save(const T& value) {
using Role = discretization::quadrature::QuadratureRole;
Node result = object(key, field("discretization", value.role_control(Role::discretization)), field("preconditioner", value.role_control(Role::preconditioner)),
field("diagnostic", value.role_control(Role::diagnostic)), field("projection", value.role_control(Role::projection)), field("fallback", value.fallback_control()));
if (value.begin() != value.end()) {
Node forms = object("quadrature.form_rules");
forms.children.emplace("reconstruction", Node{.type = "unsupported", .value = std::string{"Form-specific quadrature controls require registered owning form descriptors."}});
std::size_t index = 0;
for (const auto& rule : value) {
forms.children.emplace(std::to_string(index++), object("quadrature.form_rule",
field("name", std::string{rule.form.name}), field("trial_field", std::string{rule.form.trial.field.name}),
field("trial_term", std::string{rule.form.trial.name}), field("test_field", std::string{rule.form.test.field.name}),
field("test_term", std::string{rule.form.test.name}), field("control", rule.control)));
}
result.children.emplace("forms", std::move(forms));
}
return result;
}
/** @brief Never discard form-specific controls when reconstructing the supported role-only recipe. */
[[nodiscard]] static Result<T> load(const Node& node) {
if (node.children.contains("forms")) { return std::unexpected(Error{"forms", "Form-specific quadrature descriptors are not reconstructible by this loader."}); }
using Control = discretization::quadrature::RuleControl;
return read_object(node, key, [](Control discretization, Control preconditioner, Control diagnostic, Control projection, Control fallback) {
using Role = discretization::quadrature::QuadratureRole;
T result;
result.set_role_control(Role::discretization, discretization);
result.set_role_control(Role::preconditioner, preconditioner);
result.set_role_control(Role::diagnostic, diagnostic);
result.set_role_control(Role::projection, projection);
result.set_fallback_control(fallback);
return result;
}, member<Control>("discretization"), member<Control>("preconditioner"), member<Control>("diagnostic"), member<Control>("projection"), member<Control>("fallback"));
}
};
template <>
struct Serializer<discretization::quadrature::Policy> {
static constexpr std::string_view key = "quadrature.policy";
/** @brief Save the authored controls rather than resolved per-element rules. */
[[nodiscard]] static Node save(const discretization::quadrature::Policy& value) { return object(key, field("rules", value.rules())); }
/** @brief Restore policy behavior from its declared controls. */
[[nodiscard]] static Result<discretization::quadrature::Policy> load(const Node& node) {
return read_object(node, key, [](discretization::quadrature::RuleSet rules) { return discretization::quadrature::Policy{std::move(rules)}; }, member<discretization::quadrature::RuleSet>("rules"));
}
};
}

View File

@@ -0,0 +1,153 @@
#pragma once
#include <memory>
#include <algorithm>
#include <cmath>
#include "serif/utils/serialize/components.hpp"
#include "serif/dimensions/dimensions.hpp"
#include "serif/eos/models/polytropic.hpp"
#include "serif/model/description.hpp"
#include "serif/model/eos_reference.hpp"
#include "serif/geometry/surface/isobaric.hpp"
#include "serif/geometry/deformation/models.hpp"
namespace serif::utils::serialize {
template <dimensions::PhysicalQuantityType Quantity>
struct Serializer<dimensions::QuantityValue<Quantity>> {
using T = dimensions::QuantityValue<Quantity>;
static constexpr std::string_view key = "quantity";
/** @brief Preserve the physical meaning as well as the stored number. */
[[nodiscard]] static Node save(const T& value) {
return object(key, field("dimension", std::string(Quantity::identifier)), field("value", value.value()));
}
/** @brief Reject a different physical quantity even when its numeric representation matches. */
[[nodiscard]] static Result<T> load(const Node& node) {
return read_object(node, key, [](std::string dimension, double value) {
errors::serif_ensure<errors::SERiFErrorCode::INVALID_ARGUMENT>(dimension == Quantity::identifier,
"The archived quantity has dimension {}, expected {}.", dimension, Quantity::identifier);
return T{value};
}, member<std::string>("dimension"), member<double>("value"));
}
};
template <>
struct Serializer<eos::models::Polytrope> {
static constexpr std::string_view key = "eos.polytrope";
/** @brief Store the two defining EOS parameters, never cached derivatives. */
[[nodiscard]] static Node save(const eos::models::Polytrope& value) {
return object(key, field("index", value.polytropic_index()), field("constant", value.polytropic_constant()));
}
/** @brief Let the EOS constructor validate its physical domain. */
[[nodiscard]] static Result<eos::models::Polytrope> load(const Node& node) {
return read_object(node, key, [](double n, double K) { return eos::models::Polytrope{n, K}; }, member<double>("index"), member<double>("constant"));
}
};
template <typename EOS>
struct Serializer<model::EosReference<EOS>> {
static constexpr std::string_view key = "eos.reference";
/** @brief Archive the immutable referenced physics, never a memory address. */
[[nodiscard]] static Node save(const model::EosReference<EOS>& value) { return object(key, field("eos", value.get())); }
/** @brief Own the reconstructed EOS so a loaded reference cannot dangle. */
[[nodiscard]] static Result<model::EosReference<EOS>> load(const Node& node) {
return read_object(node, key, [](EOS eos) { return model::EosReference<EOS>{std::make_shared<const EOS>(std::move(eos))}; }, member<EOS>("eos"));
}
};
template <>
struct Serializer<model::FixedTotalMass> {
static constexpr std::string_view key = "physics.fixed_total_mass";
/** @brief Preserve the prescribed dimensional target. */
[[nodiscard]] static Node save(const model::FixedTotalMass& value) { return object(key, field("target", value.target())); }
/** @brief Validate the target through the existing physical constructor. */
[[nodiscard]] static Result<model::FixedTotalMass> load(const Node& node) {
return read_object(node, key, [](dimensions::MassValue target) { return model::FixedTotalMass{target}; }, member<dimensions::MassValue>("target"));
}
};
template <>
struct Serializer<geometry::surface::IsobaricSurface> {
static constexpr std::string_view key = "surface.isobaric";
/** @brief Preserve the prescribed dimensional target. */
[[nodiscard]] static Node save(const geometry::surface::IsobaricSurface& value) { return object(key, field("target", value.target_pressure())); }
/** @brief Validate the target through the existing physical constructor. */
[[nodiscard]] static Result<geometry::surface::IsobaricSurface> load(const Node& node) {
return read_object(node, key, [](dimensions::PressureValue target) { return geometry::surface::IsobaricSurface{target}; }, member<dimensions::PressureValue>("target"));
}
};
template <>
struct Serializer<model::FixedAngularMomentum> {
static constexpr std::string_view key = "physics.fixed_angular_momentum";
/** @brief Preserve the target together with its physical axis and center. */
[[nodiscard]] static Node save(const model::FixedAngularMomentum& value) {
return object(key, field("target", value.target()), field("axis", value.axis()), field("center", value.center()));
}
/** @brief Restore a validated angular-momentum prescription. */
[[nodiscard]] static Result<model::FixedAngularMomentum> load(const Node& node) {
return read_object(node, key, [](dimensions::AngularMomentumValue target, std::array<double, 3> axis, std::array<double, 3> center) {
return model::FixedAngularMomentum{{.angular_momentum = target, .axis = axis, .center = center}};
}, member<dimensions::AngularMomentumValue>("target"), member<std::array<double, 3>>("axis"), member<std::array<double, 3>>("center"));
}
};
template <>
struct Serializer<geometry::deformation::NodalRadialSurface> : StructSerializer<geometry::deformation::NodalRadialSurface> {
using T = geometry::deformation::NodalRadialSurface;
static constexpr std::string_view key = "surface.nodal_radial";
static constexpr auto members = std::tuple{member_field("reference_center", &T::reference_center)};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::ranges::all_of(value.reference_center, [](double component) { return std::isfinite(component); });
}
};
template <>
struct Serializer<geometry::deformation::PowerLawRadialExtension> : StructSerializer<geometry::deformation::PowerLawRadialExtension> {
using T = geometry::deformation::PowerLawRadialExtension;
static constexpr std::string_view key = "extension.radial_power";
static constexpr auto members = std::tuple{member_field("radial_power", &T::radial_power)};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::isfinite(value.radial_power) && value.radial_power >= 1.0;
}
};
template <>
struct Serializer<geometry::deformation::FixedInfinityRadialExtension> : StructSerializer<geometry::deformation::FixedInfinityRadialExtension> {
using T = geometry::deformation::FixedInfinityRadialExtension;
static constexpr std::string_view key = "extension.fixed_infinity";
static constexpr auto members = std::tuple{};
};
template <>
struct Serializer<geometry::deformation::SphericalHarmonicSurface> : StructSerializer<geometry::deformation::SphericalHarmonicSurface> {
using T = geometry::deformation::SphericalHarmonicSurface;
static constexpr std::string_view key = "surface.normalized_real_spherical_harmonics";
static constexpr auto members = std::tuple{
member_field("reference_center", &T::reference_center),
member_field("maximum_degree", &T::maximum_degree),
member_field("maximum_azimuthal_order", &T::maximum_azimuthal_order)
};
/** @brief Reject invalid saved parameters before any numerical preparation. */
[[nodiscard]] static bool valid(const T& value) {
return std::ranges::all_of(value.reference_center, [](double component) { return std::isfinite(component); }) && value.maximum_degree >= 0 &&
(!value.maximum_azimuthal_order || (*value.maximum_azimuthal_order >= 0 && *value.maximum_azimuthal_order <= value.maximum_degree));
}
};
template <typename Surface, typename Interior, typename Vacuum>
struct Serializer<geometry::deformation::DomainDeformation<Surface, Interior, Vacuum>> : StructSerializer<geometry::deformation::DomainDeformation<Surface, Interior, Vacuum>> {
using T = geometry::deformation::DomainDeformation<Surface, Interior, Vacuum>;
static constexpr std::string_view key = "deformation.domain";
static constexpr auto members = std::tuple{
member_field("surface", &T::surface),
member_field("stellar_interior", &T::stellar_interior),
member_field("vacuum", &T::vacuum)
};
};
}

View File

@@ -0,0 +1,359 @@
#pragma once
#include <algorithm>
#include <array>
#include <bit>
#include <concepts>
#include <cstdint>
#include <exception>
#include <expected>
#include <functional>
#include <limits>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <tuple>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>
namespace serif::utils::serialize {
/** @brief Identify the component or field that could not be saved or reconstructed. */
struct Error {
std::string path;
std::string message;
};
template <typename T>
using Result = std::expected<T, Error>;
/** @brief Portable scientific data; runtime pointers and computational caches have no representation here. */
using Value = std::variant<std::monostate, bool, std::int64_t, std::uint64_t, double, std::string,
std::vector<double>, std::vector<std::int64_t>, std::vector<std::uint64_t>>;
/** @brief A named component owns its fields, stable schema identifier, and schema version. */
struct Node {
std::string type;
std::uint32_t version{1};
Value value;
std::map<std::string, Node, std::less<>> children;
/** @brief Compare stored representations, distinguishing signed zero and preserving NaN payload identity. */
bool operator==(const Node& other) const {
if (type != other.type || version != other.version || value.index() != other.value.index() || children != other.children) {
return false;
}
if (const auto* real = std::get_if<double>(&value)) {
return std::bit_cast<std::uint64_t>(*real) == std::bit_cast<std::uint64_t>(std::get<double>(other.value));
}
if (const auto* reals = std::get_if<std::vector<double>>(&value)) {
const auto& theirs = std::get<std::vector<double>>(other.value);
return std::ranges::equal(*reals, theirs, [](double first, double second) {
return std::bit_cast<std::uint64_t>(first) == std::bit_cast<std::uint64_t>(second);
});
}
return value == other.value;
}
};
/** @brief Specialize this protocol beside a component without adding I/O methods to the component itself. */
template <typename T>
struct Serializer;
/** @brief Produce data when a serializer exists, otherwise record the missing capability explicitly. */
template <typename T>
[[nodiscard]] Node encode(const T& value) {
if constexpr (requires { Serializer<std::remove_cvref_t<T>>::save(value); }) {
return Serializer<std::remove_cvref_t<T>>::save(value);
} else {
return {.type = "unsupported", .value = std::string{"No Serializer<T> is registered for this component."}};
}
}
/** @brief Translate invalid saved parameters into a value error at the reconstruction boundary. */
template <typename T>
[[nodiscard]] Result<T> decode(const Node& node) {
constexpr std::uint32_t version = [] {
if constexpr (requires { Serializer<T>::version; }) {
return static_cast<std::uint32_t>(Serializer<T>::version);
} else {
return std::uint32_t{1};
}
}();
if (node.version != version) {
return std::unexpected(Error{"", "Unsupported component schema version: " + std::to_string(node.version)});
}
if constexpr (requires { Serializer<T>::load(node); }) {
try {
return Serializer<T>::load(node);
} catch (const std::exception& error) {
return std::unexpected(Error{"", "Invalid saved component: " + std::string{error.what()}});
}
} else {
return std::unexpected(Error{"", "No loader is registered for this component type."});
}
}
/** @brief Associate a field name with an already encoded child. */
struct Field {
std::string name;
Node node;
};
/** @brief Encode one named field of a component. */
template <typename T>
[[nodiscard]] Field field(std::string name, const T& value) {
return {std::move(name), encode(value)};
}
/** @brief Compose a component from its independently serialized fields. */
template <typename... Fields>
[[nodiscard]] Node object(std::string_view type, Fields... fields) {
Node result{.type = std::string{type}};
(result.children.emplace(std::move(fields.name), std::move(fields.node)), ...);
return result;
}
/** @brief Name a typed field expected by a component's loader. */
template <typename T>
struct Member {
using Type = T;
std::string_view name;
};
/** @brief Describe one field to read_object without a separate temporary variable. */
template <typename T>
[[nodiscard]] Member<T> member(std::string_view name) {
return {name};
}
/** @brief Read a required field and retain its full path in any diagnostic. */
template <typename T>
[[nodiscard]] Result<T> read_field(const Node& node, std::string_view name) {
const auto found = node.children.find(name);
if (found == node.children.end()) {
return std::unexpected(Error{std::string{name}, "Required field is missing."});
}
Result<T> result = decode<T>(found->second);
if (!result) {
result.error().path = std::string{name} + (result.error().path.empty() ? "" : "/" + result.error().path);
}
return result;
}
/** @brief Construct a component only after every required field has decoded successfully. */
template <typename Construct, typename... Members>
[[nodiscard]] auto read_object(const Node& node, std::string_view type, Construct construct, Members... members)
-> Result<std::invoke_result_t<Construct, typename Members::Type...>> {
if (node.type != type || !std::holds_alternative<std::monostate>(node.value)) {
return std::unexpected(Error{"", "Expected component schema '" + std::string{type} + "', received '" + node.type + "'."});
}
auto values = std::tuple{read_field<typename Members::Type>(node, members.name)...};
std::optional<Error> error;
std::apply([&](const auto&... value) {
([&] {
if (!value && !error) {
error = value.error();
}
}(), ...);
}, values);
if (error) {
return std::unexpected(std::move(*error));
}
return std::apply([&](auto&... value) { return std::invoke(construct, std::move(*value)...); }, values);
}
/** @brief Preserve a raw component when inspection, rather than typed reconstruction, is wanted. */
template <>
struct Serializer<Node> {
/** @brief Copy the component without wrapping it in an additional schema. */
static Node save(const Node& node) { return node; }
/** @brief Return the independently owned component. */
static Result<Node> load(const Node& node) { return node; }
};
/** @brief Store numeric values using explicit portable widths. */
template <typename T> requires std::is_arithmetic_v<T>
struct Serializer<T> {
/** @brief Encode a scalar without formatting-dependent precision loss. */
static Node save(T value) {
if constexpr (std::same_as<T, bool>) {
return {.type = "bool", .value = value};
} else if constexpr (std::floating_point<T>) {
static_assert(sizeof(T) <= sizeof(double), "Provide an explicit serializer for extended precision.");
return {.type = "real", .value = static_cast<double>(value)};
} else if constexpr (std::signed_integral<T>) {
return {.type = "integer", .value = static_cast<std::int64_t>(value)};
} else {
return {.type = "unsigned", .value = static_cast<std::uint64_t>(value)};
}
}
/** @brief Validate the saved type and range before converting a scalar. */
static Result<T> load(const Node& node) {
using Stored = std::conditional_t<std::same_as<T, bool>, bool,
std::conditional_t<std::floating_point<T>, double,
std::conditional_t<std::signed_integral<T>, std::int64_t, std::uint64_t>>>;
const auto* value = std::get_if<Stored>(&node.value);
if (value == nullptr || !node.children.empty() || node.type != save(T{}).type) {
return std::unexpected(Error{"", "Saved scalar has the wrong representation."});
}
if constexpr (std::integral<T> && !std::same_as<T, bool>) {
if (*value < std::numeric_limits<T>::lowest() || *value > std::numeric_limits<T>::max()) {
return std::unexpected(Error{"", "Saved integer is outside the requested type's range."});
}
}
return static_cast<T>(*value);
}
};
template <>
struct Serializer<std::string> {
/** @brief Keep UTF-8 text as uninterpreted bytes. */
static Node save(const std::string& value) { return {.type = "string", .value = value}; }
/** @brief Read a string without numeric or locale conversion. */
static Result<std::string> load(const Node& node) {
if (const auto* value = std::get_if<std::string>(&node.value); value != nullptr && node.type == "string" && node.children.empty()) {
return *value;
}
return std::unexpected(Error{"", "Expected a string."});
}
};
template <>
struct Serializer<std::string_view> {
/** @brief Own the text so a packed archive never borrows a temporary string. */
static Node save(std::string_view value) { return Serializer<std::string>::save(std::string{value}); }
};
template <std::size_t Size>
struct Serializer<char[Size]> {
/** @brief Accept ordinary string literals without retaining their terminating null byte. */
static Node save(const char (&value)[Size]) { return encode(std::string{value, Size - (value[Size - 1] == '\0' ? 1 : 0)}); }
};
/** @brief Arrays of coefficients are contiguous payloads, while compound values retain named children. */
template <typename T>
struct Serializer<std::vector<T>> {
/** @brief Encode a numerical array compactly, or serialize each compound element. */
static Node save(const std::vector<T>& values) {
Node node{.type = "array"};
if constexpr (std::same_as<T, double> || std::same_as<T, std::int64_t> || std::same_as<T, std::uint64_t>) {
node.value = values;
} else if constexpr (std::integral<T> && !std::same_as<T, bool>) {
using Stored = std::conditional_t<std::signed_integral<T>, std::int64_t, std::uint64_t>;
node.value = std::vector<Stored>(values.begin(), values.end());
} else {
for (std::size_t index = 0; index < values.size(); ++index) {
node.children.emplace(std::to_string(index), encode(static_cast<const T&>(values[index])));
}
}
return node;
}
/** @brief Restore the exact array length and validate every represented element. */
static Result<std::vector<T>> load(const Node& node) {
if (node.type != "array") {
return std::unexpected(Error{"", "Expected an array."});
}
if constexpr (std::same_as<T, double> || (std::integral<T> && !std::same_as<T, bool>)) {
using Stored = std::conditional_t<std::same_as<T, double>, double,
std::conditional_t<std::signed_integral<T>, std::int64_t, std::uint64_t>>;
const auto* values = std::get_if<std::vector<Stored>>(&node.value);
if (values == nullptr || !node.children.empty()) {
return std::unexpected(Error{"", "Saved array has the wrong numeric representation."});
}
if constexpr (std::same_as<T, Stored>) {
return *values;
}
std::vector<T> result;
result.reserve(values->size());
for (Stored value : *values) {
auto converted = decode<T>(encode(value));
if (!converted) {
return std::unexpected(converted.error());
}
result.push_back(*converted);
}
return result;
} else {
if (!std::holds_alternative<std::monostate>(node.value)) {
return std::unexpected(Error{"", "Expected a compound array."});
}
std::vector<T> result;
result.reserve(node.children.size());
for (std::size_t index = 0; index < node.children.size(); ++index) {
auto value = read_field<T>(node, std::to_string(index));
if (!value) {
return std::unexpected(value.error());
}
result.push_back(std::move(*value));
}
return result;
}
}
};
template <typename T, std::size_t Size>
struct Serializer<std::array<T, Size>> {
/** @brief Store a fixed-size array with the same portable representation as a vector. */
static Node save(const std::array<T, Size>& values) { return encode(std::vector<T>{values.begin(), values.end()}); }
/** @brief Check the compile-time extent before constructing the array. */
static Result<std::array<T, Size>> load(const Node& node) {
auto values = decode<std::vector<T>>(node);
if (!values) {
return std::unexpected(values.error());
}
if (values->size() != Size) {
return std::unexpected(Error{"", "Saved array length does not match the requested fixed extent."});
}
std::array<T, Size> result{};
std::copy(values->begin(), values->end(), result.begin());
return result;
}
};
template <typename T>
struct Serializer<std::optional<T>> {
/** @brief Represent absence explicitly instead of inventing a default value. */
static Node save(const std::optional<T>& value) {
Node node{.type = "optional"};
if (value) {
node.children.emplace("value", encode(*value));
}
return node;
}
/** @brief Restore an optional field while preserving explicit absence. */
static Result<std::optional<T>> load(const Node& node) {
if (node.type != "optional" || !std::holds_alternative<std::monostate>(node.value)) {
return std::unexpected(Error{"", "Expected an optional value."});
}
if (node.children.empty()) {
return std::optional<T>{};
}
auto value = read_field<T>(node, "value");
if (!value) {
return std::unexpected(value.error());
}
return std::optional<T>{std::move(*value)};
}
};
template <typename... T>
struct Serializer<std::tuple<T...>> {
/** @brief Preserve tuple order with explicit numeric field names. */
static Node save(const std::tuple<T...>& values) {
return [&]<std::size_t... I>(std::index_sequence<I...>) {
return object("tuple", field(std::to_string(I), std::get<I>(values))...);
}(std::index_sequence_for<T...>{});
}
/** @brief Reconstruct a heterogeneous tuple only after all elements have decoded. */
static Result<std::tuple<T...>> load(const Node& node) {
return [&]<std::size_t... I>(std::index_sequence<I...>) {
return read_object(node, "tuple", [](T... values) { return std::tuple<T...>{std::move(values)...}; }, member<T>(std::to_string(I))...);
}(std::index_sequence_for<T...>{});
}
};
/** @brief Versioned content identity for accidental corruption and resource matching; not a cryptographic signature. */
[[nodiscard]] std::string fingerprint(std::string_view bytes);
}

View File

@@ -0,0 +1,8 @@
#pragma once
// Public entry point for the archive and built-in backend-independent components.
// Use archive.hpp alone for lightweight custom formats/components; MFEM adapters
// remain explicit includes under serif/backend/mfem/serialize.
#include "serif/utils/serialize/archive.hpp"
#include "serif/utils/serialize/solver.hpp"
#include "serif/utils/serialize/top.hpp"

View File

@@ -0,0 +1,169 @@
#pragma once
#include "serif/utils/serialize/model.hpp"
#include "serif/utils/serialize/numerics.hpp"
#include "serif/solver/nonlinear/result.hpp"
namespace serif::utils::serialize {
/** @brief Preserve stable termination names independently of enum storage or declaration order. */
template <>
struct Serializer<solver::linear::Status> : EnumSerializer<solver::linear::Status> {
static constexpr std::string_view key = "linear_status";
static constexpr auto choices = std::array{
std::pair{solver::linear::Status::converged, std::string_view{"converged"}},
std::pair{solver::linear::Status::maximum_iterations, std::string_view{"maximum_iterations"}},
std::pair{solver::linear::Status::breakdown, std::string_view{"breakdown"}},
std::pair{solver::linear::Status::non_finite, std::string_view{"non_finite"}},
std::pair{solver::linear::Status::backend_failure, std::string_view{"backend_failure"}}};
};
/** @brief Preserve stable termination names independently of enum storage or declaration order. */
template <>
struct Serializer<solver::SolveStatus> : EnumSerializer<solver::SolveStatus> {
static constexpr std::string_view key = "solve_status";
static constexpr auto choices = std::array{
std::pair{solver::SolveStatus::converged, std::string_view{"converged"}},
std::pair{solver::SolveStatus::initial_state_rejected, std::string_view{"initial_state_rejected"}},
std::pair{solver::SolveStatus::invalid_metric, std::string_view{"invalid_metric"}},
std::pair{solver::SolveStatus::preparation_failure, std::string_view{"preparation_failure"}},
std::pair{solver::SolveStatus::linear_solve_failure, std::string_view{"linear_solve_failure"}},
std::pair{solver::SolveStatus::globalization_failure, std::string_view{"globalization_failure"}},
std::pair{solver::SolveStatus::maximum_iterations, std::string_view{"maximum_iterations"}},
std::pair{solver::SolveStatus::stopped, std::string_view{"stopped"}}};
};
/** @brief Store bounded numerical history as named data, without runtime operators or graph caches. */
template <>
struct Serializer<solver::MetricEvaluation> : StructSerializer<solver::MetricEvaluation> {
using T = solver::MetricEvaluation;
static constexpr std::string_view key = "newton_metric";
static constexpr auto members = std::tuple{
member_field("residual_norm", &T::residual_norm), member_field("merit", &T::merit)};
};
/** @brief Store bounded numerical history as named data, without runtime operators or graph caches. */
template <>
struct Serializer<solver::linear::Report> : StructSerializer<solver::linear::Report> {
using T = solver::linear::Report;
static constexpr std::string_view key = "linear_report";
static constexpr auto members = std::tuple{
member_field("status", &T::status), member_field("control", &T::control), member_field("iterations", &T::iterations), member_field("restarts", &T::restarts),
member_field("right_hand_side_norm", &T::right_hand_side_norm), member_field("initial_residual_norm", &T::initial_residual_norm),
member_field("reported_residual_norm", &T::reported_residual_norm), member_field("true_residual_norm", &T::true_residual_norm),
member_field("relative_true_residual_norm", &T::relative_true_residual_norm), member_field("operator_applications", &T::operator_applications),
member_field("preconditioner_applications", &T::preconditioner_applications), member_field("solve_seconds", &T::solve_seconds),
member_field("operator_seconds", &T::operator_seconds), member_field("preconditioner_seconds", &T::preconditioner_seconds)};
};
/** @brief Store bounded numerical history as named data, without runtime operators or graph caches. */
template <>
struct Serializer<geometry::deformation::GeometryStepLimit> : StructSerializer<geometry::deformation::GeometryStepLimit> {
using T = geometry::deformation::GeometryStepLimit;
static constexpr std::string_view key = "geometry_step_limit";
static constexpr auto members = std::tuple{
member_field("step", &T::step), member_field("boundary_step", &T::boundary_step),
member_field("minimum_determinant_at_accepted", &T::minimum_determinant_at_accepted),
member_field("minimum_determinant_at_maximum", &T::minimum_determinant_at_maximum),
member_field("limiting_point_determinant_at_step", &T::limiting_point_determinant_at_step),
member_field("sampled_point_count", &T::sampled_point_count), member_field("limited_by_geometry", &T::limited_by_geometry),
member_field("limiting_element", &T::limiting_element), member_field("limiting_rule", &T::limiting_rule), member_field("limiting_point", &T::limiting_point)};
};
/** @brief Store bounded numerical history as named data, without runtime operators or graph caches. */
template <>
struct Serializer<solver::SolveDiagnostics> : StructSerializer<solver::SolveDiagnostics> {
using T = solver::SolveDiagnostics;
static constexpr std::string_view key = "solve_diagnostics";
static constexpr auto members = std::tuple{
member_field("attempted_iterations", &T::attempted_iterations),
member_field("accepted_iterations", &T::accepted_iterations), member_field("line_search_trials", &T::line_search_trials),
member_field("rejected_trials", &T::rejected_trials), member_field("geometry_limited_iterations", &T::geometry_limited_iterations),
member_field("total_linear_iterations", &T::total_linear_iterations), member_field("initial_residual_norm", &T::initial_residual_norm),
member_field("final_residual_norm", &T::final_residual_norm), member_field("convergence_threshold", &T::convergence_threshold),
member_field("last_linear_solve", &T::last_linear_solve), member_field("last_geometry_preflight", &T::last_geometry_preflight),
member_field("last_accepted_step", &T::last_accepted_step)};
/**
* @brief Keep version-one readers usable without making timing part of numerical history.
* @note Legacy elapsed fields are emitted as zero (unmeasured). New readers ignore them;
* applications may archive observer measurements separately as named metadata.
*/
static Node save(const T& diagnostics) {
Node result = StructSerializer<T>::save(diagnostics);
result.children.emplace("solve_seconds", encode(0.0));
result.children.emplace("linear_solve_seconds", encode(0.0));
return result;
}
};
/** @brief Store bounded numerical history as named data, without runtime operators or graph caches. */
template <>
struct Serializer<solver::NewtonProgress> : StructSerializer<solver::NewtonProgress> {
using T = solver::NewtonProgress;
static constexpr std::string_view key = "newton_progress";
static constexpr auto members = std::tuple{
member_field("accepted_metric", &T::accepted_metric), member_field("correction", &T::correction), member_field("next_step", &T::next_step),
member_field("diagnostics", &T::diagnostics)};
};
/** @brief Save a portable accepted physical state together with its complete supported Newton history. */
template <typename State>
struct Serializer<solver::NewtonCheckpoint<State>> {
static constexpr std::string_view key = "checkpoint";
/** @brief Leave physical state layout and field conventions to their own serializer. */
static Node save(const solver::NewtonCheckpoint<State>& checkpoint) {
return object("newton_checkpoint", field("strategy", std::string_view{"standard_newton"}),
field("state", checkpoint.state), field("progress", checkpoint.progress));
}
/** @brief Reject unsupported strategy history and malformed accepted boundaries before evaluation. */
static Result<solver::NewtonCheckpoint<State>> load(const Node& node) {
const auto strategy = read_field<std::string>(node, "strategy");
if (!strategy) {
return std::unexpected(strategy.error());
}
if (*strategy != "standard_newton") {
return std::unexpected(Error{"strategy", "This checkpoint requires unsupported numerical strategy history."});
}
auto result = read_object(node, "newton_checkpoint", [](State state, solver::NewtonProgress progress) {
return solver::NewtonCheckpoint<State>{.state = std::move(state), .progress = std::move(progress)};
}, member<State>("state"), member<solver::NewtonProgress>("progress"));
if (result) {
if (const auto error = solver::validate_newton_progress(result->progress, result->state.values().size())) {
return std::unexpected(Error{"progress", *error});
}
}
return result;
}
};
/** @brief Export scientific results and supported restart data without pretending frozen operators are persistent. */
template <typename State, typename System, typename Model>
struct Serializer<solver::SolveResult<State, System, Model>> {
static constexpr std::string_view key = "solution";
/** @brief Compose independently inspectable result, physical model, state and normalization records. */
static Node save(const solver::SolveResult<State, System, Model>& result) {
Node node = object("solve_result", field("status", result.status()), field("message", result.message()),
field("diagnostics", result.diagnostics()), field("has_state", result.has_state()));
if constexpr (!std::same_as<Model, solver::NoModel>) {
node.children.emplace("model", encode(result.model()));
}
if (result.has_state()) {
node.children.emplace("state", encode(result.state()));
}
if (result.has_checkpoint()) {
node.children.emplace("checkpoint", encode(result.checkpoint()));
}
if constexpr (requires { result.system()->reference_scales(); result.system()->coordinates(); }) {
if (result.system()) {
node.children.emplace("reference_scales", encode(result.system()->reference_scales()));
node.children.emplace("coordinates", encode(result.system()->coordinates()));
}
}
return node;
}
/** @brief Runtime systems must be rebuilt through the solver from the archived model and checkpoint. */
static Result<solver::SolveResult<State, System, Model>> load(const Node&) {
return std::unexpected(Error{"", "A saved solution contains scientific data, not executable operators. Read its model/state or checkpoint components and rebuild the runtime system with resume_equilibrium."});
}
};
}

View File

@@ -0,0 +1,64 @@
#pragma once
#include <cstddef>
#include <filesystem>
#include <functional>
#include "serif/utils/serialize/record.hpp"
namespace serif::utils::serialize {
/** @brief Sampling and admissibility limits for TOP's single-domain polytrope reader. */
struct TopExportOptions {
int radial_points{64};
int maximum_degree{16};
int angular_points{48};
int azimuthal_points{8};
double relative_symmetry_tolerance{1e-3};
double relative_surface_tolerance{1e-6};
double relative_surface_projection_tolerance{1e-3};
};
/** @brief Physical samples in a centered frame whose polar axis is the rotation axis.
* @note Enthalpy is an export conversion of the source EOS, not a primary solver field.
* The callbacks use mu=cos(theta), phi in radians, and physical radius/enthalpy units.
*/
struct TopPolytropeSource {
double polytropic_index{};
double gravitational_constant{};
double mass{};
double angular_velocity{};
double central_density{};
double central_enthalpy{};
std::function<Result<double>(double mu, double phi)> surface_radius;
std::function<Result<double>(double radius, double mu, double phi)> enthalpy;
/** @brief Optional count of explicitly bounded roundoff corrections made by an EOS adapter. */
std::function<std::size_t()> roundoff_density_clamps;
};
/** @brief Export scales and sampled projection defects; these do not certify oscillation accuracy. */
struct TopExportReport {
std::filesystem::path directory;
int radial_points{}, maximum_degree{}, angular_points{}, azimuthal_points{};
double source_equatorial_radius{}, source_polar_radius{};
double equatorial_radius{}, polar_radius{};
double lambda{}, flattening{}, alpha{}, rotation{}, rotation_over_kepler{};
double angular_frequency_unit{};
double maximum_relative_surface_symmetry_error{};
double maximum_relative_enthalpy_symmetry_error{};
double maximum_relative_surface_projection_error{};
/** @brief Sampled Gauss-node harmonic truncation error in H/Hc after vacuum-boundary normalization. */
double maximum_relative_enthalpy_projection_error{};
double maximum_relative_surface_enthalpy{};
double minimum_mapping_derivative{};
double minimum_enthalpy{};
std::size_t roundoff_density_clamps{};
};
/** @brief Atomically publish a new TOP model directory, refusing to overwrite any destination.
* @note Projects the source surface and pressure-derived enthalpy at common surface-fitted
* zeta coordinates. Only equatorially symmetric axisymmetric vacuum polytropes with n>=1
* are supported. TOP chooses Gamma1 in its equation set, separately from these files.
*/
[[nodiscard]] Result<TopExportReport> export_top(const std::filesystem::path& path,
const TopPolytropeSource& source, const TopExportOptions& options = {});
}

View File

@@ -0,0 +1,424 @@
#include "serif/utils/serialize/archive.hpp"
#include <algorithm>
#include <atomic>
#include <charconv>
#include <chrono>
#include <cstdio>
#include <iomanip>
#include <limits>
#include <locale>
#include <set>
#include <sstream>
#include <system_error>
#include "serif/config.hpp"
#include "format.hpp"
namespace serif::utils::serialize {
namespace {
struct Header {
Format format{Format::binary};
std::string version;
std::string timestamp;
std::uint64_t index_offset{detail::header_bytes};
std::uint64_t index_bytes{0};
std::uint64_t data_offset{0};
std::uint64_t file_bytes{0};
std::uint64_t entries{0};
std::string checksum;
};
struct Payload {
Entry entry;
std::string bytes;
};
/** @brief Keep the fixed header readable and independent of compiler structure padding. */
std::string print_header(const Header& header) {
std::ostringstream text;
text.imbue(std::locale::classic());
text << "SERIF_ARCHIVE 1\nformat " << (header.format == Format::binary ? "binary" : "text")
<< "\nserif_version " << std::quoted(header.version) << "\nwritten_utc " << std::quoted(header.timestamp)
<< "\nledger_offset " << header.index_offset << "\nledger_bytes " << header.index_bytes
<< "\ndata_offset " << header.data_offset << "\nfile_bytes " << header.file_bytes
<< "\nentries " << header.entries << "\nledger_checksum " << header.checksum << "\nEND_HEADER\n";
std::string result = text.str();
result.resize(detail::header_bytes, ' ');
result.back() = '\n';
return result;
}
/** @brief Parse a fixed sequence so duplicated or unknown control fields cannot be ambiguous. */
Result<Header> parse_header(std::string_view bytes) {
std::istringstream input{std::string{bytes}};
input.imbue(std::locale::classic());
Header header;
std::string name;
std::string format;
int version = 0;
/** @brief Consume the next required control-field name. */
const auto next = [&](std::string_view expected) { return static_cast<bool>(input >> name) && name == expected; };
const bool valid = next("SERIF_ARCHIVE") && (input >> version) && version == 1 && next("format") && (input >> format)
&& next("serif_version") && (input >> std::quoted(header.version)) && next("written_utc") && (input >> std::quoted(header.timestamp))
&& next("ledger_offset") && (input >> header.index_offset) && next("ledger_bytes") && (input >> header.index_bytes)
&& next("data_offset") && (input >> header.data_offset) && next("file_bytes") && (input >> header.file_bytes)
&& next("entries") && (input >> header.entries) && next("ledger_checksum") && (input >> header.checksum) && next("END_HEADER");
input >> std::ws;
if (!valid || !input.eof() || (format != "binary" && format != "text")) {
return std::unexpected(Error{"header", "Malformed header or unsupported archive format version."});
}
header.format = format == "binary" ? Format::binary : Format::text;
return header;
}
/** @brief Compare semantic major versions without interpreting arbitrary strings as zero. */
std::optional<std::uint64_t> major_version(std::string_view version) {
const std::size_t dot = version.find('.');
if (dot == std::string_view::npos || dot == 0) {
return {};
}
std::uint64_t major = 0;
const auto parsed = std::from_chars(version.data(), version.data() + dot, major);
if (parsed.ec != std::errc{} || parsed.ptr != version.data() + dot) {
return {};
}
return major;
}
/** @brief Flatten a component tree into independent payloads and reject ambiguous names. */
Result<void> collect(const Node& node, std::string path, Format format, std::vector<Payload>& result, std::size_t depth = 0) {
if (!detail::valid_path(path) || depth > 128 || node.version == 0 || node.type.empty() || node.type.size() > 1024
|| node.type.find_first_of("\r\n\0", 0, 3) != std::string::npos) {
return std::unexpected(Error{std::move(path), "Invalid component path, schema identifier, or nesting depth."});
}
if (!node.children.empty() && !std::holds_alternative<std::monostate>(node.value)) {
return std::unexpected(Error{std::move(path), "A component must contain either fields or a payload."});
}
Payload payload{
.entry = {.path = path, .type = node.type, .version = node.version, .kind = static_cast<std::uint32_t>(node.value.index()),
.count = detail::value_count(node.value)},
.bytes = detail::encode_value(node.value, format)};
payload.entry.bytes = payload.bytes.size();
payload.entry.checksum = fingerprint(payload.bytes);
result.push_back(std::move(payload));
for (const auto& [name, child] : node.children) {
if (name.find('/') != std::string::npos) {
return std::unexpected(Error{path, "A field name cannot contain a path separator."});
}
auto saved = collect(child, path + "/" + name, format, result, depth + 1);
if (!saved) {
return saved;
}
}
return {};
}
/** @brief Fixed-width offsets keep the ledger length unchanged when final locations are filled in. */
std::string print_ledger(const std::vector<Payload>& payloads) {
std::ostringstream output;
output.imbue(std::locale::classic());
output << "# path schema version kind count offset bytes checksum\n";
for (const Payload& payload : payloads) {
const Entry& entry = payload.entry;
output << std::quoted(entry.path) << ' ' << std::quoted(entry.type) << ' ' << entry.version << ' ' << entry.kind << ' '
<< entry.count << ' ' << std::setfill('0') << std::setw(20) << entry.offset << ' ' << std::setw(20) << entry.bytes
<< ' ' << entry.checksum << '\n';
}
return output.str();
}
/** @brief Give each text payload a visible section label outside its indexed bytes. */
std::string section_label(const Entry& entry, Format format) {
return format == Format::text ? "\n# " + entry.path + "\n" : "";
}
/** @brief Remove a failed temporary write without ever removing the previous destination. */
struct TemporaryFile {
std::filesystem::path path;
std::FILE* stream{nullptr};
bool owned{false};
/** @brief Clean up regardless of which validation or I/O operation failed. */
~TemporaryFile() {
if (stream != nullptr) {
std::fclose(stream);
}
if (owned) {
std::error_code error;
std::filesystem::remove(path, error);
}
}
/** @brief Check every byte of an output section. */
bool append(std::string_view bytes) const { return std::fwrite(bytes.data(), 1, bytes.size(), stream) == bytes.size(); }
};
/** @brief Read an exact bounded region, clearing EOF from any preceding failed read. */
Result<std::string> read_bytes(std::ifstream& file, std::uint64_t offset, std::uint64_t bytes, std::string_view path) {
if (bytes > static_cast<std::uint64_t>(std::numeric_limits<std::streamsize>::max())
|| offset > static_cast<std::uint64_t>(std::numeric_limits<std::streamoff>::max())) {
return std::unexpected(Error{std::string{path}, "File region exceeds this reader's addressable range."});
}
file.clear();
file.seekg(static_cast<std::streamoff>(offset));
std::string result(bytes, '\0');
file.read(result.data(), static_cast<std::streamsize>(bytes));
if (!file) {
return std::unexpected(Error{std::string{path}, "Unable to read the complete indexed file region."});
}
return result;
}
/** @brief Validate all structural bounds before any payload is allocated or interpreted. */
Result<std::vector<Entry>> parse_ledger(std::string_view bytes, const Header& header, const ReadOptions& options) {
std::istringstream input{std::string{bytes}};
input.imbue(std::locale::classic());
std::string line;
std::getline(input, line);
if (line != "# path schema version kind count offset bytes checksum") {
return std::unexpected(Error{"ledger", "Unrecognized ledger columns."});
}
std::vector<Entry> entries;
std::map<std::string, std::uint32_t> kinds;
std::uint64_t previous_end = header.data_offset;
while (std::getline(input, line)) {
Entry entry;
std::istringstream row{line};
row.imbue(std::locale::classic());
const bool parsed = static_cast<bool>(row >> std::quoted(entry.path) >> std::quoted(entry.type) >> entry.version >> entry.kind
>> entry.count >> entry.offset >> entry.bytes >> entry.checksum);
row >> std::ws;
if (!parsed || !row.eof() || !detail::valid_path(entry.path) || entry.type.empty() || entry.type.size() > 1024
|| entry.kind > 8 || entry.version == 0 || entries.size() >= options.maximum_entries
|| static_cast<std::uint64_t>(std::count(entry.path.begin(), entry.path.end(), '/')) > options.maximum_depth
|| (!entries.empty() && entries.back().path >= entry.path)) {
return std::unexpected(Error{entry.path, "Malformed, duplicated, unsorted, or oversized ledger entry."});
}
const std::size_t separator = entry.path.rfind('/');
if (separator != std::string::npos) {
const auto parent = kinds.find(entry.path.substr(0, separator));
if (parent == kinds.end() || parent->second != 0) {
return std::unexpected(Error{entry.path, "A field's parent must be an existing component directory."});
}
}
if (entry.kind == 0) {
if (entry.count != 0 || entry.offset != 0 || entry.bytes != 0 || entry.checksum != fingerprint("")) {
return std::unexpected(Error{entry.path, "Directory entries cannot reference payload bytes."});
}
} else {
if (entry.offset < previous_end || entry.offset > header.file_bytes || entry.bytes > header.file_bytes - entry.offset
|| entry.bytes > options.maximum_payload_bytes || entry.count > options.maximum_payload_bytes) {
return std::unexpected(Error{entry.path, "Payload overlaps another section, exceeds the file, or exceeds configured limits."});
}
previous_end = entry.offset + entry.bytes;
}
kinds.emplace(entry.path, entry.kind);
entries.push_back(std::move(entry));
}
if (entries.size() != header.entries || !kinds.contains("provenance")) {
return std::unexpected(Error{"ledger", "Entry count is inconsistent or required provenance is missing."});
}
return entries;
}
}
void add_component(Pack& contents, Field component) {
if (contents.error) {
return;
}
if (component.name == "provenance" || !detail::valid_path(component.name) || component.name.find('/') != std::string::npos) {
contents.error = Error{component.name, "Use a non-reserved, single-component archive name."};
return;
}
const auto found = contents.root.children.find(component.name);
if (found != contents.root.children.end() && found->second != component.node) {
contents.error = Error{component.name, "Two different components were packed under the same name."};
return;
}
contents.root.children.emplace(std::move(component.name), std::move(component.node));
}
Result<void> write(const std::filesystem::path& path, const Pack& contents, const WriteOptions& options) {
if (contents.error) {
return std::unexpected(*contents.error);
}
std::vector<Payload> payloads;
const Node metadata = detail::provenance(options);
auto saved = collect(metadata, "provenance", options.format, payloads);
if (!saved) {
return saved;
}
for (const auto& [name, node] : contents.root.children) {
if (name == "provenance") {
return std::unexpected(Error{name, "Provenance is written by the archive, not supplied as a model component."});
}
saved = collect(node, name, options.format, payloads);
if (!saved) {
return saved;
}
}
std::ranges::sort(payloads, {}, [](const Payload& payload) { return payload.entry.path; });
Header header{.format = options.format, .version = SERIF_VERSION,
.timestamp = std::get<std::string>(metadata.children.at("written_utc").value),
.index_bytes = print_ledger(payloads).size(), .entries = payloads.size()};
header.data_offset = header.index_offset + header.index_bytes;
std::uint64_t position = header.data_offset;
for (Payload& payload : payloads) {
if (payload.entry.kind != 0) {
position += section_label(payload.entry, options.format).size();
payload.entry.offset = position;
position += payload.bytes.size();
}
}
header.file_bytes = position;
const std::string ledger = print_ledger(payloads);
header.checksum = fingerprint(ledger);
static std::atomic_uint64_t sequence{0};
TemporaryFile temporary;
for (int attempt = 0; attempt < 10 && temporary.stream == nullptr; ++attempt) {
temporary.path = path;
temporary.path += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + std::to_string(sequence++);
temporary.stream = std::fopen(temporary.path.string().c_str(), "wbx");
}
if (temporary.stream == nullptr) {
return std::unexpected(Error{path.string(), "Cannot create an exclusive temporary file beside the destination."});
}
temporary.owned = true;
bool written = temporary.append(print_header(header)) && temporary.append(ledger);
for (const Payload& payload : payloads) {
if (written && payload.entry.kind != 0) {
written = temporary.append(section_label(payload.entry, options.format)) && temporary.append(payload.bytes);
}
}
const int close_status = std::fclose(temporary.stream);
temporary.stream = nullptr;
if (!written || close_status != 0) {
return std::unexpected(Error{path.string(), "The archive could not be written completely; the previous destination was retained."});
}
std::error_code error;
std::filesystem::rename(temporary.path, path, error);
if (error) {
return std::unexpected(Error{path.string(), "Cannot publish the completed archive: " + error.message()});
}
temporary.owned = false;
return {};
}
Result<Reader> Reader::open(const std::filesystem::path& path, ReadOptions options) {
Reader reader;
reader.m_options = options;
reader.m_file.open(path, std::ios::binary | std::ios::ate);
if (!reader.m_file) {
return std::unexpected(Error{path.string(), "Cannot open archive for reading."});
}
const std::streamoff file_size = reader.m_file.tellg();
if (file_size < static_cast<std::streamoff>(detail::header_bytes)) {
return std::unexpected(Error{"header", "Archive is truncated before the complete header."});
}
auto bytes = read_bytes(reader.m_file, 0, detail::header_bytes, "header");
if (!bytes) {
return std::unexpected(bytes.error());
}
auto header = parse_header(*bytes);
if (!header) {
return std::unexpected(header.error());
}
const bool same_major = major_version(header->version) && major_version(header->version) == major_version(SERIF_VERSION);
if ((options.version_tolerance == VersionTolerance::exact && header->version != SERIF_VERSION)
|| (options.version_tolerance == VersionTolerance::same_major && !same_major)) {
return std::unexpected(Error{"header/serif_version", "SERiF " + header->version + " is incompatible with the selected reader tolerance (reader " SERIF_VERSION ")."});
}
if (header->index_offset != detail::header_bytes || header->file_bytes != static_cast<std::uint64_t>(file_size)
|| header->index_bytes > options.maximum_index_bytes || header->index_bytes > header->file_bytes - detail::header_bytes
|| header->data_offset != header->index_offset + header->index_bytes || header->entries > options.maximum_entries) {
std::string cmp_string = std::format("header: [index_offset={}, index_bytes={}, data_offset={}, file_bytes={}, entries={}]", header->index_offset, header->index_bytes, header->data_offset, header->file_bytes, header->entries);
cmp_string = std::format("{}, file: [size={}]", cmp_string, file_size);
cmp_string = std::format("{}, limits: [max_index_bytes={}, max_entries={}]", cmp_string, options.maximum_index_bytes, options.maximum_entries);
cmp_string = std::format("{}, version_tolerance={}", cmp_string, static_cast<int>(options.version_tolerance));
cmp_string = std::format("{}, detail::header_bytes={}", cmp_string, detail::header_bytes);
return std::unexpected(Error{"header", std::format("Invalid file size, section offsets, or configured allocation limits. {}", cmp_string)});
}
auto ledger = read_bytes(reader.m_file, header->index_offset, header->index_bytes, "ledger");
if (!ledger) {
return std::unexpected(ledger.error());
}
if (fingerprint(*ledger) != header->checksum) {
return std::unexpected(Error{"ledger", "Ledger checksum does not match the header."});
}
auto entries = parse_ledger(*ledger, *header, options);
if (!entries) {
return std::unexpected(entries.error());
}
reader.m_entries = std::move(*entries);
reader.m_format = header->format;
reader.m_version = std::move(header->version);
return reader;
}
bool Reader::contains(std::string_view path) const {
const auto found = std::ranges::lower_bound(m_entries, path, {}, &Entry::path);
return found != m_entries.end() && found->path == path;
}
Result<Node> Reader::node(std::string_view path) const {
const auto first = std::ranges::lower_bound(m_entries, path, {}, &Entry::path);
if (first == m_entries.end() || first->path != path) {
return std::unexpected(Error{std::string{path}, "Requested component is not present in the archive ledger."});
}
Node root;
std::map<std::string, Node*> nodes;
const std::string prefix = std::string{path} + "/";
std::uint64_t total_bytes = 0;
for (auto entry = first; entry != m_entries.end(); ++entry) {
if (entry != first && !entry->path.starts_with(prefix)) {
// Sibling names can sort before a parent's descendants (a.b precedes a/b).
if (entry->path < prefix) {
continue;
}
break;
}
if (entry->bytes > m_options.maximum_payload_bytes - total_bytes) {
return std::unexpected(Error{entry->path, "Selected subtree exceeds the configured payload budget."});
}
total_bytes += entry->bytes;
auto bytes = read_bytes(m_file, entry->offset, entry->bytes, entry->path);
if (!bytes) {
return std::unexpected(bytes.error());
}
if (fingerprint(*bytes) != entry->checksum) {
return std::unexpected(Error{entry->path, "Payload checksum mismatch; the component is damaged."});
}
auto value = detail::decode_value(*bytes, *entry, m_format);
if (!value) {
return std::unexpected(value.error());
}
Node* destination = &root;
if (entry != first) {
const std::size_t separator = entry->path.rfind('/');
const auto parent = nodes.find(entry->path.substr(0, separator));
if (parent == nodes.end()) {
return std::unexpected(Error{entry->path, "Requested subtree has a missing parent."});
}
destination = &parent->second->children[entry->path.substr(separator + 1)];
}
*destination = Node{.type = entry->type, .version = entry->version, .value = std::move(*value)};
nodes.emplace(entry->path, destination);
}
return root;
}
Result<void> Reader::validate() const {
for (const Entry& entry : m_entries) {
if (entry.kind != 0) {
auto payload = node(entry.path);
if (!payload) {
return std::unexpected(payload.error());
}
}
}
return {};
}
}

View File

@@ -0,0 +1,230 @@
#include "format.hpp"
#include <bit>
#include <charconv>
#include <cmath>
#include <iomanip>
#include <limits>
#include <locale>
#include <sstream>
#include <type_traits>
namespace serif::utils::serialize {
std::string fingerprint(std::string_view bytes) {
std::uint64_t hash = 14695981039346656037ULL;
for (unsigned char byte : bytes) {
hash ^= byte;
hash *= 1099511628211ULL;
}
std::ostringstream output;
output.imbue(std::locale::classic());
output << "fnv1a64-v1:" << std::hex << std::setfill('0') << std::setw(16) << hash;
return output.str();
}
}
namespace serif::utils::serialize::detail {
namespace {
/** @brief Emit one integer in little-endian order, independent of the writing host. */
void append_word(std::string& output, std::uint64_t word) {
for (int shift = 0; shift < 64; shift += 8) {
output.push_back(static_cast<char>((word >> shift) & 0xff));
}
}
/** @brief Recover one unaligned little-endian word without aliasing native objects. */
std::uint64_t read_word(std::string_view bytes, std::size_t index) {
std::uint64_t word = 0;
for (int byte = 0; byte < 8; ++byte) {
word |= static_cast<std::uint64_t>(static_cast<unsigned char>(bytes[index * 8 + byte])) << (8 * byte);
}
return word;
}
/** @brief Keep finite text readable and preserve even the payload bits of nonfinite doubles. */
void write_real(std::ostream& output, double value) {
if (std::isfinite(value)) {
output << std::setprecision(std::numeric_limits<double>::max_digits10) << value;
} else {
output << "bits:" << std::hex << std::setfill('0') << std::setw(16) << std::bit_cast<std::uint64_t>(value) << std::dec;
}
}
/** @brief Parse text doubles with an explicit representation for nonfinite values. */
bool read_real(std::istream& input, double& value) {
std::string token;
if (!(input >> token)) {
return false;
}
if (token.starts_with("bits:")) {
std::uint64_t bits = 0;
const auto parsed = std::from_chars(token.data() + 5, token.data() + token.size(), bits, 16);
if (token.size() != 21 || parsed.ec != std::errc{} || parsed.ptr != token.data() + token.size()) {
return false;
}
value = std::bit_cast<double>(bits);
return true;
}
const auto parsed = std::from_chars(token.data(), token.data() + token.size(), value);
return parsed.ec == std::errc{} && parsed.ptr == token.data() + token.size();
}
/** @brief Require an exact array extent, including no unconsumed trailing text. */
template <typename T>
Result<Value> read_array(std::string_view bytes, const Entry& entry, Format format) {
if ((format == Format::binary && (bytes.size() % 8 != 0 || entry.count != bytes.size() / 8)) || entry.count > bytes.size()) {
return std::unexpected(Error{entry.path, "Array extent does not match the payload length."});
}
std::vector<T> values(entry.count);
if (format == Format::binary) {
for (std::size_t index = 0; index < values.size(); ++index) {
values[index] = std::bit_cast<T>(read_word(bytes, index));
}
} else {
std::istringstream input{std::string{bytes}};
input.imbue(std::locale::classic());
for (T& value : values) {
bool valid;
if constexpr (std::same_as<T, double>) {
valid = read_real(input, value);
} else {
std::string token;
input >> token;
const auto parsed = std::from_chars(token.data(), token.data() + token.size(), value);
valid = !token.empty() && parsed.ec == std::errc{} && parsed.ptr == token.data() + token.size();
}
if (!valid) {
return std::unexpected(Error{entry.path, "Invalid number in text payload."});
}
}
input >> std::ws;
if (!input.eof()) {
return std::unexpected(Error{entry.path, "Unexpected trailing text in numeric payload."});
}
}
return Value{std::move(values)};
}
/** @brief Reuse checked array decoding for fixed-width scalars. */
template <typename T>
Result<Value> read_scalar(std::string_view bytes, const Entry& entry, Format format) {
if (entry.count != 1) {
return std::unexpected(Error{entry.path, "A scalar must have exactly one value."});
}
auto values = read_array<T>(bytes, entry, format);
if (!values) {
return std::unexpected(values.error());
}
return Value{std::get<std::vector<T>>(*values).front()};
}
}
std::uint64_t value_count(const Value& value) {
return std::visit([](const auto& stored) -> std::uint64_t {
using T = std::remove_cvref_t<decltype(stored)>;
if constexpr (std::same_as<T, std::monostate>) {
return 0;
} else if constexpr (requires { stored.size(); }) {
return stored.size();
} else {
return 1;
}
}, value);
}
std::string encode_value(const Value& value, Format format) {
std::string binary;
std::ostringstream text;
text.imbue(std::locale::classic());
/** @brief Emit one numerical value through the selected representation. */
const auto append_number = [&](auto number) {
using T = decltype(number);
if (format == Format::binary) {
append_word(binary, std::bit_cast<std::uint64_t>(number));
} else {
if constexpr (std::same_as<T, double>) {
write_real(text, number);
} else {
text << number;
}
text << '\n';
}
};
std::visit([&](const auto& stored) {
using T = std::remove_cvref_t<decltype(stored)>;
if constexpr (std::same_as<T, bool>) {
binary.push_back(stored ? '\1' : '\0');
text << (stored ? '1' : '0');
} else if constexpr (std::same_as<T, std::string>) {
binary = stored;
text << std::quoted(stored);
} else if constexpr (std::is_arithmetic_v<T>) {
append_number(stored);
} else if constexpr (!std::same_as<T, std::monostate>) {
for (auto number : stored) {
append_number(number);
}
}
}, value);
return format == Format::binary ? binary : text.str();
}
Result<Value> decode_value(std::string_view bytes, const Entry& entry, Format format) {
switch (entry.kind) {
case 0: {
if (entry.count != 0 || !bytes.empty()) {
return std::unexpected(Error{entry.path, "A component directory cannot contain a scalar payload."});
}
return Value{};
}
case 1: {
const char zero = format == Format::binary ? '\0' : '0';
const char one = format == Format::binary ? '\1' : '1';
if (entry.count != 1 || bytes.size() != 1 || (bytes[0] != zero && bytes[0] != one)) {
return std::unexpected(Error{entry.path, "Invalid boolean payload."});
}
return Value{bytes[0] == one};
}
case 2: { return read_scalar<std::int64_t>(bytes, entry, format); }
case 3: { return read_scalar<std::uint64_t>(bytes, entry, format); }
case 4: { return read_scalar<double>(bytes, entry, format); }
case 5: {
std::string value;
if (format == Format::binary) {
value = bytes;
} else {
std::istringstream input{std::string{bytes}};
input.imbue(std::locale::classic());
if (input.peek() != '"' || !(input >> std::quoted(value))) {
return std::unexpected(Error{entry.path, "Invalid quoted text payload."});
}
input >> std::ws;
if (!input.eof()) {
return std::unexpected(Error{entry.path, "Unexpected trailing text after string."});
}
}
if (value.size() != entry.count) {
return std::unexpected(Error{entry.path, "String length does not match the ledger."});
}
return Value{std::move(value)};
}
case 6: { return read_array<double>(bytes, entry, format); }
case 7: { return read_array<std::int64_t>(bytes, entry, format); }
case 8: { return read_array<std::uint64_t>(bytes, entry, format); }
default: { return std::unexpected(Error{entry.path, "Unknown payload representation."}); }
}
}
bool valid_path(std::string_view path) {
if (path.empty() || path.size() > 4096 || path.front() == '/' || path.back() == '/' || path.find("//") != std::string_view::npos) {
return false;
}
for (unsigned char character : path) {
const bool alphabet = (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z');
if (!alphabet && !(character >= '0' && character <= '9') && character != '_' && character != '-' && character != '.' && character != '/') {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include <string>
#include <string_view>
#include "serif/utils/serialize/archive.hpp"
namespace serif::utils::serialize::detail {
inline constexpr std::uint64_t header_bytes = 512;
/** @brief Scalar types have one value; numerical arrays and strings carry their explicit extent. */
[[nodiscard]] std::uint64_t value_count(const Value& value);
/** @brief Encode one indexed payload without relying on native object layout or byte order. */
[[nodiscard]] std::string encode_value(const Value& value, Format format);
/** @brief Validate the extent and representation of an independently read payload. */
[[nodiscard]] Result<Value> decode_value(std::string_view bytes, const Entry& entry, Format format);
/** @brief Capture provenance once at the beginning of a write. */
[[nodiscard]] Node provenance(const WriteOptions& options);
/** @brief Restrict field names so path separators and line breaks cannot corrupt the ledger. */
[[nodiscard]] bool valid_path(std::string_view path);
}

View File

@@ -0,0 +1,86 @@
#include "format.hpp"
#include <bit>
#include <chrono>
#include <ctime>
#include <iomanip>
#include <limits>
#include <locale>
#include <sstream>
#include <thread>
#include <nameof.hpp>
#include <magic_enum/magic_enum.hpp>
#include "serif/config.hpp"
#if defined(__unix__) || defined(__APPLE__)
#include <sys/utsname.h>
#endif
namespace serif::utils::serialize::detail {
namespace {
/** @brief Record the writing instant in an unambiguous UTC representation. */
std::string timestamp() {
const std::time_t now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm utc{};
#if defined(_WIN32)
gmtime_s(&utc, &now);
#else
gmtime_r(&now, &utc);
#endif
std::ostringstream output;
output.imbue(std::locale::classic());
output << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ");
return output.str();
}
/** @brief Keep semantic library versions readable without relying on implementation-specific formatters. */
std::string version(int major, int minor, int patch) {
return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
}
/** @brief Describe the host without storing a user's account, environment variables, or home directory. */
Node host_information() {
Node host = object("host",
field("operating_system", std::string{SERIF_BUILD_SYSTEM}),
field("architecture", std::string{SERIF_BUILD_ARCHITECTURE}),
field("hardware_threads", std::thread::hardware_concurrency()),
field("pointer_bits", static_cast<unsigned int>(8 * sizeof(void*))),
field("byte_order", std::string{std::endian::native == std::endian::little ? "little" : "big"}));
#if defined(__unix__) || defined(__APPLE__)
struct utsname information{};
if (uname(&information) == 0) {
host.children.emplace("name", encode(std::string{information.nodename}));
host.children.emplace("kernel", encode(std::string{information.sysname}));
host.children.emplace("kernel_release", encode(std::string{information.release}));
host.children.emplace("kernel_version", encode(std::string{information.version}));
host.children.emplace("machine", encode(std::string{information.machine}));
}
#endif
return host;
}
}
Node provenance(const WriteOptions& options) {
static_assert(sizeof(double) == 8 && std::numeric_limits<double>::is_iec559,
"The native archive currently represents IEEE-754 binary64 coefficients.");
Node libraries = object("libraries",
field("nameof", version(NAMEOF_VERSION_MAJOR, NAMEOF_VERSION_MINOR, NAMEOF_VERSION_PATCH)),
field("magic_enum", version(MAGIC_ENUM_VERSION_MAJOR, MAGIC_ENUM_VERSION_MINOR, MAGIC_ENUM_VERSION_PATCH)),
field("mfem_build_dependency", std::string{SERIF_MFEM_BUILD_VERSION}));
#if defined(_LIBCPP_VERSION)
libraries.children.emplace("libcxx", encode(std::to_string(_LIBCPP_VERSION)));
#elif defined(__GLIBCXX__)
libraries.children.emplace("libstdcxx", encode(std::to_string(__GLIBCXX__)));
#endif
Node annotations{.type = "metadata"};
for (const auto& [key, value] : options.metadata) {
annotations.children.emplace(key, encode(value));
}
return object("provenance",
field("written_utc", timestamp()), field("serif_version", std::string{SERIF_VERSION}),
field("host", host_information()), field("libraries", libraries),
field("compiler", std::string{SERIF_BUILD_COMPILER}), field("build_type", std::string{SERIF_BUILD_TYPE}),
field("author", options.author), field("comment", options.comment), field("metadata", annotations));
}
}

View File

@@ -0,0 +1,425 @@
#include "serif/utils/serialize/top.hpp"
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <limits>
#include <locale>
#include <numbers>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#if defined(__linux__)
#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
#endif
namespace serif::utils::serialize {
namespace {
constexpr double pi = std::numbers::pi;
using Matrix = std::vector<std::vector<double>>;
struct Failure { Error error; };
[[noreturn]] void fail(std::string path, std::string message) {
throw Failure{Error{std::move(path), std::move(message)}};
}
double checked(Result<double> value, const std::string& path) {
if (!value) {
fail(path + (value.error().path.empty() ? "" : "/" + value.error().path), value.error().message);
}
if (!std::isfinite(*value)) { fail(path, "The source returned a nonfinite value."); }
return *value;
}
bool positive(double value) { return std::isfinite(value) && value > 0.; }
/** @brief Ordinary Legendre polynomials; normalization is applied exactly once at projection. */
std::vector<double> legendre(double mu, int degree) {
std::vector<double> result(degree + 1, 1.);
if (degree) { result[1] = mu; }
for (int l = 2; l <= degree; ++l) {
result[l] = ((2*l - 1)*mu*result[l-1] - (l-1)*result[l-2])/l;
}
return result;
}
std::pair<std::vector<double>, std::vector<double>> gauss(int count) {
std::vector<double> nodes(count), weights(count);
for (int i = 0; i < (count+1)/2; ++i) {
double x = std::cos(pi*(i+.75)/(count+.5));
double derivative = 0.;
for (int step = 0; step < 100; ++step) {
const auto p = legendre(x, count);
derivative = count*(x*p[count]-p[count-1])/(x*x-1.);
const double next = x-p[count]/derivative;
if (std::abs(next-x) <= 4*std::numeric_limits<double>::epsilon()) {
x = next;
const auto final = legendre(x, count);
derivative = count*(x*final[count]-final[count-1])/(x*x-1.);
break;
}
x = next;
if (step == 99) { fail("options/angular_points", "Gauss-Legendre nodes did not converge."); }
}
nodes[i] = -x; nodes[count-1-i] = x;
weights[i] = weights[count-1-i] = 2./((1-x*x)*derivative*derivative);
}
return {std::move(nodes), std::move(weights)};
}
double reconstruct(const std::vector<double>& coefficients, double mu) {
const auto p = legendre(mu, static_cast<int>(coefficients.size())-1);
long double sum = 0.;
for (std::size_t l = 0; l < coefficients.size(); l += 2) {
sum += coefficients[l]*std::sqrt((2.*l+1)/(4*pi))*p[l];
}
return static_cast<double>(sum);
}
std::vector<double> project(const std::vector<double>& values, const std::vector<double>& mu,
const std::vector<double>& weights, int degree) {
std::vector<long double> sums(degree+1, 0.);
for (std::size_t j = 0; j < mu.size(); ++j) {
const auto p = legendre(mu[j], degree);
for (int l = 0; l <= degree; l += 2) { sums[l] += values[j]*weights[j]*p[l]; }
}
std::vector<double> result(degree+1, 0.);
for (int l = 0; l <= degree; l += 2) {
result[l] = static_cast<double>(sums[l])*std::sqrt(pi*(2*l+1));
}
return result;
}
Matrix derivative_matrix(const std::vector<double>& zeta) {
const int count = static_cast<int>(zeta.size());
Matrix result(count, std::vector<double>(count));
for (int i = 0; i < count; ++i) {
const double wi = (i%2 ? -1. : 1.)*(i==0 || i==count-1 ? .5 : 1.);
for (int j = 0; j < count; ++j) {
if (i == j) { continue; }
const double wj = (j%2 ? -1. : 1.)*(j==0 || j==count-1 ? .5 : 1.);
result[i][j] = (wj/wi)/(zeta[i]-zeta[j]);
}
}
return result;
}
Matrix differentiate(const Matrix& values, const Matrix& derivative) {
Matrix result(values.size(), std::vector<double>(values.front().size()));
for (std::size_t i = 0; i < values.size(); ++i) {
for (std::size_t l = 0; l < values[i].size(); l += 2) {
long double sum = 0.;
for (std::size_t j = 0; j < values.size(); ++j) {
sum += derivative[i][j]*(values[j][l]-values[i][l]);
}
result[i][l] = static_cast<double>(sum);
if (!std::isfinite(result[i][l])) { fail("enthalpy_et_der", "A radial spectral derivative is nonfinite."); }
}
}
return result;
}
struct TemporaryDirectory {
std::filesystem::path path;
~TemporaryDirectory() { if (!path.empty()) { std::error_code error; std::filesystem::remove_all(path, error); } }
};
std::ofstream open_output(const std::filesystem::path& path) {
std::ofstream output(path);
output.imbue(std::locale::classic());
output << std::scientific << std::setprecision(std::numeric_limits<double>::max_digits10);
if (!output) { fail(path.string(), "Could not create TOP output file."); }
return output;
}
void close_output(std::ofstream& output, const std::filesystem::path& path) {
output.close();
if (!output) { fail(path.string(), "Could not finish writing TOP output file."); }
}
/** @brief Unlike rename(), these publication primitives cannot replace an existing empty directory. */
void publish(const std::filesystem::path& temporary, const std::filesystem::path& path) {
#if defined(__APPLE__)
if (::renamex_np(temporary.c_str(), path.c_str(), RENAME_EXCL) == 0) { return; }
fail(path.string(), "Could not publish TOP directory without replacement: " + std::string{std::strerror(errno)});
#elif defined(__linux__) && defined(SYS_renameat2)
if (::syscall(SYS_renameat2, AT_FDCWD, temporary.c_str(), AT_FDCWD, path.c_str(), 1U) == 0) { return; }
fail(path.string(), "Could not publish TOP directory without replacement: " + std::string{std::strerror(errno)});
#else
fail(path.string(), "Atomic directory publication without replacement is unavailable on this platform.");
#endif
}
}
Result<TopExportReport> export_top(const std::filesystem::path& requested_path,
const TopPolytropeSource& source, const TopExportOptions& options) {
try {
if (requested_path.empty() || requested_path.filename().empty() || requested_path.filename()=="." || requested_path.filename()=="..") {
fail(requested_path.string(), "Specify a new model directory, not an empty path or parent directory.");
}
const auto path = std::filesystem::absolute(requested_path).lexically_normal();
if (std::filesystem::symlink_status(path).type() != std::filesystem::file_type::not_found) {
fail(path.string(), "TOP export refuses to replace an existing destination.");
}
if (!std::filesystem::is_directory(path.parent_path())) { fail(path.string(), "The output parent directory does not exist."); }
if (options.radial_points < 4 || options.radial_points > 4096) { fail("options/radial_points", "Use between 4 and 4096 radial points."); }
if (options.maximum_degree < 0 || options.maximum_degree > 512 || options.maximum_degree%2) {
fail("options/maximum_degree", "The maximum harmonic degree must be even and between 0 and 512.");
}
if (options.angular_points < std::max(2, options.maximum_degree+1) || options.angular_points > 4096) {
fail("options/angular_points", "Angular quadrature requires at least maximum_degree+1 points and between 2 and 4096 points.");
}
if (options.azimuthal_points < 4 || options.azimuthal_points > 1024) { fail("options/azimuthal_points", "Use between 4 and 1024 azimuthal samples."); }
for (const auto& [name, value] : {std::pair{"relative_symmetry_tolerance", options.relative_symmetry_tolerance},
{"relative_surface_tolerance", options.relative_surface_tolerance},
{"relative_surface_projection_tolerance", options.relative_surface_projection_tolerance}}) {
if (!std::isfinite(value) || value < 0.) { fail("options/" + std::string{name}, "Tolerance must be finite and nonnegative."); }
}
for (const auto& [name, value] : {std::pair{"polytropic_index", source.polytropic_index},
{"gravitational_constant", source.gravitational_constant}, {"mass", source.mass},
{"central_density", source.central_density}, {"central_enthalpy", source.central_enthalpy}}) {
if (!positive(value)) { fail("source/" + std::string{name}, "This physical scale must be finite and positive."); }
}
if (source.polytropic_index < 1.) { fail("source/polytropic_index", "TOP's vacuum polytrope equations require n>=1 in this exporter."); }
if (!std::isfinite(source.angular_velocity)) { fail("source/angular_velocity", "Angular velocity must be finite."); }
if (!source.surface_radius || !source.enthalpy) { fail("source", "Both surface-radius and enthalpy sampling callbacks are required."); }
TopExportReport report{.directory=path, .radial_points=options.radial_points, .maximum_degree=options.maximum_degree,
.angular_points=options.angular_points, .azimuthal_points=options.azimuthal_points};
const auto [mu, weights] = gauss(options.angular_points);
auto radii = [&](double cosine) {
std::vector<double> values;
values.reserve(2*options.azimuthal_points);
for (double sign : {1., -1.}) {
for (int k = 0; k < options.azimuthal_points; ++k) {
const double phi = 2*pi*k/options.azimuthal_points;
const double value = checked(source.surface_radius(sign*cosine, phi), "source/surface_radius");
if (value <= 0.) { fail("source/surface_radius", "The surface must be star-shaped with positive radius in every sampled direction."); }
values.push_back(value);
}
}
return values;
};
auto mean = [](const std::vector<double>& values) {
long double sum = 0.; for (double value : values) { sum += value; }
return static_cast<double>(sum/values.size());
};
report.source_equatorial_radius = mean(radii(0.));
report.source_polar_radius = mean(radii(1.));
auto surface_samples = [&](double cosine) {
auto values = radii(cosine);
const double average = mean(values);
for (double value : values) {
report.maximum_relative_surface_symmetry_error = std::max(report.maximum_relative_surface_symmetry_error,
std::abs(value-average)/report.source_equatorial_radius);
}
return values;
};
Matrix radius_values;
std::vector<double> surface_means;
for (double cosine : mu) {
radius_values.push_back(surface_samples(cosine));
surface_means.push_back(mean(radius_values.back()));
}
auto surface_coefficients = project(surface_means, mu, weights, options.maximum_degree);
report.equatorial_radius = reconstruct(surface_coefficients, 0.);
report.polar_radius = reconstruct(surface_coefficients, 1.);
if (!positive(report.equatorial_radius) || !positive(report.polar_radius)) { fail("domains_boundaries", "Projected equatorial and polar radii must be positive."); }
report.minimum_mapping_derivative = std::numeric_limits<double>::infinity();
auto mapping_check = [&](double radius) {
// A'(zeta) ranges from zero to 15/8, so this is the exact radial minimum.
const double minimum = std::min(report.polar_radius,
report.polar_radius + 1.875*(radius-report.polar_radius))/report.equatorial_radius;
report.minimum_mapping_derivative = std::min(report.minimum_mapping_derivative, minimum);
if (!positive(minimum)) { fail("domains_boundaries", "The source or projected surface gives a nonpositive TOP mapping derivative."); }
};
for (int j = 0; j <= 2*options.angular_points; ++j) {
const double cosine = std::cos(pi*j/(2*options.angular_points));
const auto values = surface_samples(cosine);
const double projected = reconstruct(surface_coefficients, cosine);
if (!positive(projected)) { fail("domains_boundaries", "Projected surface radius must stay positive."); }
mapping_check(projected);
for (double value : values) { mapping_check(value); }
report.maximum_relative_surface_projection_error = std::max(report.maximum_relative_surface_projection_error,
std::abs(projected-mean(values))/report.source_equatorial_radius);
}
if (report.maximum_relative_surface_symmetry_error > options.relative_symmetry_tolerance) {
fail("source/surface_radius", "Surface axisymmetry or equatorial-symmetry defect exceeds relative_symmetry_tolerance.");
}
if (report.maximum_relative_surface_projection_error > options.relative_surface_projection_tolerance) {
fail("domains_boundaries", "Surface harmonic truncation exceeds relative_surface_projection_tolerance; increase maximum_degree or angular_points.");
}
report.flattening = 1.-report.polar_radius/report.equatorial_radius;
report.angular_frequency_unit = std::sqrt(4*pi*source.gravitational_constant*source.central_density);
report.lambda = std::pow(report.angular_frequency_unit*report.equatorial_radius, 2)/source.central_enthalpy;
report.alpha = 4*pi*source.central_density*std::pow(report.equatorial_radius, 3)/(3*source.mass);
report.rotation = source.angular_velocity*report.equatorial_radius/std::sqrt(source.central_enthalpy);
report.rotation_over_kepler = source.angular_velocity*std::sqrt(std::pow(report.equatorial_radius, 3)/(source.gravitational_constant*source.mass));
if (!positive(report.angular_frequency_unit) || !positive(report.lambda) || !positive(report.alpha)
|| !std::isfinite(report.rotation) || !std::isfinite(report.rotation_over_kepler)) {
fail("lambda_eps", "Physical normalization overflowed or underflowed.");
}
std::vector<double> zeta(options.radial_points);
for (int i = 0; i < options.radial_points; ++i) { zeta[i] = .5*(1-std::cos(pi*i/(options.radial_points-1))); }
zeta.front() = 0.; zeta.back() = 1.;
report.minimum_enthalpy = std::numeric_limits<double>::infinity();
auto enthalpy_samples = [&](int i, double cosine, const std::vector<double>& surfaces) {
const double z = zeta[i], a = (5*z*z*z-3*std::pow(z, 5))/2;
std::vector<double> values;
values.reserve(surfaces.size());
std::size_t index = 0;
for (double sign : {1., -1.}) {
for (int k = 0; k < options.azimuthal_points; ++k) {
const double radius = i==options.radial_points-1 ? surfaces[index] :
report.polar_radius*z+a*(surfaces[index]-report.polar_radius);
const double raw = checked(source.enthalpy(radius, sign*cosine, 2*pi*k/options.azimuthal_points),
"source/enthalpy/radial_" + std::to_string(i))/source.central_enthalpy;
if (!std::isfinite(raw)) { fail("source/enthalpy", "Normalized enthalpy is nonfinite."); }
if (i==options.radial_points-1) {
report.maximum_relative_surface_enthalpy = std::max(report.maximum_relative_surface_enthalpy, std::abs(raw));
if (std::abs(raw) > options.relative_surface_tolerance) {
fail("source/enthalpy/surface", "TOP's vacuum surface requires zero enthalpy within relative_surface_tolerance.");
}
} else if (raw <= 0.) {
fail("source/enthalpy/radial_"+std::to_string(i), "TOP requires strictly positive interior enthalpy for its logarithmic derivatives.");
}
if (i==0 && std::abs(raw-1.) > 1e-8) { fail("source/central_enthalpy", "The supplied central enthalpy does not match the source center."); }
values.push_back(raw);
++index;
}
}
const double average = mean(values);
for (double value : values) {
report.maximum_relative_enthalpy_symmetry_error = std::max(report.maximum_relative_enthalpy_symmetry_error, std::abs(value-average));
}
if (report.maximum_relative_enthalpy_symmetry_error > options.relative_symmetry_tolerance) {
fail("source/enthalpy", "Enthalpy axisymmetry or equatorial-symmetry defect exceeds relative_symmetry_tolerance.");
}
// The vacuum boundary is imposed only after measuring and accepting its source defect.
const double result = i==options.radial_points-1 ? 0. : average;
report.minimum_enthalpy = std::min(report.minimum_enthalpy, result);
return result;
};
Matrix coefficients;
coefficients.reserve(options.radial_points);
for (int i = 0; i < options.radial_points; ++i) {
std::vector<double> values;
for (int j = 0; j < options.angular_points; ++j) { values.push_back(enthalpy_samples(i, mu[j], radius_values[j])); }
coefficients.push_back(project(values, mu, weights, options.maximum_degree));
for (int j = 0; j < options.angular_points; ++j) {
report.maximum_relative_enthalpy_projection_error = std::max(report.maximum_relative_enthalpy_projection_error,
std::abs(reconstruct(coefficients.back(), mu[j])-values[j]));
}
}
// Include poles and equator in physical boundary/symmetry checks, independently of Gauss nodes.
for (double cosine : {0., 1.}) {
const auto surfaces = surface_samples(cosine);
for (int i = 0; i < options.radial_points; ++i) { enthalpy_samples(i, cosine, surfaces); }
}
const auto derivative = derivative_matrix(zeta);
const auto first = differentiate(coefficients, derivative);
const auto second = differentiate(first, derivative);
// TOP uses h_zeta_theta/h_zeta at the surface, so a flat or outward-increasing
// exported profile cannot be handed to that reader even when all samples are finite.
for (int j = 0; j <= 2*options.angular_points; ++j) {
const double slope = reconstruct(first.back(), std::cos(pi*j/(2*options.angular_points)));
if (!std::isfinite(slope) || slope >= -128*std::numeric_limits<double>::epsilon()) {
fail("enthalpy_et_der/surface_derivative", "TOP requires strictly negative, nonvanishing surface enthalpy derivative; refine the export or source model.");
}
}
// TOP takes abs(h) silently and divides by h. Reject nonpositive projection values before either operation.
for (int i = 0; i < options.radial_points-1; ++i) {
for (int j = 0; j <= 2*options.angular_points; ++j) {
const double value = reconstruct(coefficients[i], std::cos(pi*j/(2*options.angular_points)));
if (!positive(value)) {
fail("enthalpy_et_der", "Harmonic projection creates nonpositive interior enthalpy; increase angular resolution.");
}
}
}
if (source.roundoff_density_clamps) { report.roundoff_density_clamps = source.roundoff_density_clamps(); }
for (double& coefficient : surface_coefficients) { coefficient /= report.equatorial_radius; }
TemporaryDirectory temporary;
static std::atomic<unsigned long long> sequence{0};
for (int attempt = 0; attempt < 100; ++attempt) {
const auto suffix = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + std::to_string(sequence++);
auto candidate = path.parent_path()/("."+path.filename().string()+".top-tmp-"+suffix);
if (std::filesystem::create_directory(candidate)) { temporary.path=std::move(candidate); break; }
}
if (temporary.path.empty()) { fail(path.string(), "Could not reserve a temporary TOP directory."); }
{
const auto file = temporary.path/"lambda_eps";
auto output = open_output(file);
for (double value : {report.lambda, report.flattening, report.alpha, 0., source.polytropic_index,
source.polytropic_index, report.rotation, report.rotation_over_kepler}) { output << value << '\n'; }
output << options.radial_points << '\n';
close_output(output, file);
}
{
const auto file = temporary.path/"domains_boundaries";
auto output = open_output(file);
output << options.maximum_degree << '\n';
for (int l = 0; l <= options.maximum_degree; ++l) { output << 0. << ' '; } output << '\n';
for (double value : surface_coefficients) { output << value << ' '; } output << '\n';
close_output(output, file);
}
{
const auto file = temporary.path/"enthalpy_et_der";
auto output = open_output(file);
output << options.radial_points << ' ' << options.maximum_degree+1 << '\n';
output << "enthalpy in physical space: (one hemisphere)\n";
const auto physical_mu = gauss(options.maximum_degree+1).first;
for (int j = 0; j <= options.maximum_degree/2; ++j) {
output << j+1 << '\n';
for (const auto& row : coefficients) { output << reconstruct(row, physical_mu[j]) << '\n'; }
}
auto block = [&](const char* label, const Matrix& values) {
output << label << '\n';
for (int l = 0; l <= options.maximum_degree; l += 2) {
output << l << '\n'; for (const auto& row : values) { output << row[l] << '\n'; }
}
};
block("enthalpy in legendre space: l=0,2,4...lmax", coefficients);
block("first radial derivative in legendre space", first);
block("second radial derivative in legendre space", second);
close_output(output, file);
}
{
const auto file = temporary.path/"metadata.txt";
auto output = open_output(file);
output << "SERiF TOP poly_ester export version 1\n"
<< "Coordinates: zeta=(1-cos(pi*i/(nr-1)))/2; mu=cos(theta) relative to the source rotation axis.\n"
<< "Basis: Y_l0=sqrt((2*l+1)/(4*pi))*P_l(mu); even l only.\n"
<< "Projection: source surface and enthalpy are averaged over azimuth and both hemispheres, then projected at common surface-fitted zeta.\n"
<< "Physical sampling map: r=Rp*zeta+(5*zeta^3-3*zeta^5)/2*(source_surface_radius-Rp).\n"
<< "TOP replaces source_surface_radius with its harmonic projection; this is a recorded geometry approximation, not an exact FE remap.\n"
<< "Derivatives: Chebyshev interpolation derivatives at fixed angle; export refinement is required before interpreting mode accuracy.\n"
<< "Surface: accepted source enthalpy defect is recorded, then boundary enthalpy is set to zero. Interior values are not clipped.\n"
<< "Enthalpy projection diagnostic: maximum Gauss-node error of the averaged H/Hc after vacuum-boundary normalization; the source surface defect is reported separately.\n"
<< "Diagnostic units: enthalpy and its defects are normalized by central_enthalpy; radial mapping derivative by equatorial_radius.\n"
<< "Frequency: TOP angular frequency times angular_frequency_unit gives source-unit angular frequency; divide by 2*pi for cyclic frequency.\n"
<< "Gamma1 is selected by TOP's equation set, not by these files. Diagnostic maxima are sampled, not rigorous global bounds.\n";
#define SERIF_TOP_METADATA(member) output << #member << " " << report.member << '\n'
SERIF_TOP_METADATA(radial_points); SERIF_TOP_METADATA(maximum_degree); SERIF_TOP_METADATA(angular_points); SERIF_TOP_METADATA(azimuthal_points);
SERIF_TOP_METADATA(source_equatorial_radius); SERIF_TOP_METADATA(source_polar_radius);
SERIF_TOP_METADATA(equatorial_radius); SERIF_TOP_METADATA(polar_radius); SERIF_TOP_METADATA(lambda); SERIF_TOP_METADATA(flattening);
SERIF_TOP_METADATA(alpha); SERIF_TOP_METADATA(rotation); SERIF_TOP_METADATA(rotation_over_kepler); SERIF_TOP_METADATA(angular_frequency_unit);
SERIF_TOP_METADATA(maximum_relative_surface_symmetry_error); SERIF_TOP_METADATA(maximum_relative_enthalpy_symmetry_error);
SERIF_TOP_METADATA(maximum_relative_surface_projection_error); SERIF_TOP_METADATA(maximum_relative_enthalpy_projection_error);
SERIF_TOP_METADATA(maximum_relative_surface_enthalpy);
SERIF_TOP_METADATA(minimum_mapping_derivative); SERIF_TOP_METADATA(minimum_enthalpy); SERIF_TOP_METADATA(roundoff_density_clamps);
#undef SERIF_TOP_METADATA
output << "polytropic_index " << source.polytropic_index << '\n' << "gravitational_constant " << source.gravitational_constant << '\n'
<< "mass " << source.mass << '\n' << "angular_velocity " << source.angular_velocity << '\n'
<< "central_density " << source.central_density << '\n' << "central_enthalpy " << source.central_enthalpy << '\n'
<< "relative_symmetry_tolerance " << options.relative_symmetry_tolerance << '\n'
<< "relative_surface_tolerance " << options.relative_surface_tolerance << '\n'
<< "relative_surface_projection_tolerance " << options.relative_surface_projection_tolerance << '\n';
close_output(output, file);
}
publish(temporary.path, path);
temporary.path.clear();
return report;
} catch (const Failure& failure) {
return std::unexpected(failure.error);
} catch (const std::exception& error) {
return std::unexpected(Error{requested_path.string(), error.what()});
}
}
}