Add native spherical harmonic surface coordinates to coupled equilibrium

This commit is contained in:
2026-09-19 08:51:42 -04:00
parent 2171b8b76a
commit 7b0eb8d92d
23 changed files with 3131 additions and 57 deletions

View File

@@ -77,6 +77,8 @@ target_sources(mean_field
libmeanfield/impl/mapping/domain_mapper.cpp libmeanfield/impl/mapping/domain_mapper.cpp
libmeanfield/impl/mapping/transformations.cpp libmeanfield/impl/mapping/transformations.cpp
libmeanfield/impl/deformation/nodal_radial_surface.cpp libmeanfield/impl/deformation/nodal_radial_surface.cpp
libmeanfield/impl/deformation/spherical_harmonic_surface.cpp
libmeanfield/impl/deformation/surface_metric.cpp
libmeanfield/impl/deformation/radial_extensions.cpp libmeanfield/impl/deformation/radial_extensions.cpp
libmeanfield/impl/deformation/safe_newton_step.cpp libmeanfield/impl/deformation/safe_newton_step.cpp
libmeanfield/impl/operators/gravity_field.cpp libmeanfield/impl/operators/gravity_field.cpp
@@ -231,10 +233,12 @@ target_sources(mean_field
libmeanfield/interface/deformation/descriptors.cppm libmeanfield/interface/deformation/descriptors.cppm
libmeanfield/interface/deformation/surface_prescription.cppm libmeanfield/interface/deformation/surface_prescription.cppm
libmeanfield/interface/deformation/nodal_radial_surface.cppm libmeanfield/interface/deformation/nodal_radial_surface.cppm
libmeanfield/interface/deformation/spherical_harmonic_surface.cppm
libmeanfield/interface/deformation/interior_extension.cppm libmeanfield/interface/deformation/interior_extension.cppm
libmeanfield/interface/deformation/vacuum_extension.cppm libmeanfield/interface/deformation/vacuum_extension.cppm
libmeanfield/interface/deformation/radial_extensions.cppm libmeanfield/interface/deformation/radial_extensions.cppm
libmeanfield/interface/deformation/domain_deformation.cppm libmeanfield/interface/deformation/domain_deformation.cppm
libmeanfield/interface/deformation/surface_metric.cppm
libmeanfield/interface/deformation/safe_newton_step.cppm libmeanfield/interface/deformation/safe_newton_step.cppm
libmeanfield/interface/models/stellar_model.cppm libmeanfield/interface/models/stellar_model.cppm
libmeanfield/interface/operators/root_manifest.cppm libmeanfield/interface/operators/root_manifest.cppm
@@ -344,12 +348,14 @@ add_executable(tests
tests/deformation/contracts.cpp tests/deformation/contracts.cpp
tests/deformation/surface_scalar_dof_map.cpp tests/deformation/surface_scalar_dof_map.cpp
tests/deformation/nodal_radial_surface.cpp tests/deformation/nodal_radial_surface.cpp
tests/deformation/spherical_harmonic_surface.cpp
tests/deformation/radial_extensions.cpp tests/deformation/radial_extensions.cpp
tests/deformation/domain_deformation.cpp tests/deformation/domain_deformation.cpp
tests/deformation/safe_newton_step.cpp tests/deformation/safe_newton_step.cpp
tests/operators/prepared_mass_normalization.cpp tests/operators/prepared_mass_normalization.cpp
tests/operators/prepared_angular_momentum.cpp tests/operators/prepared_angular_momentum.cpp
tests/operators/prepared_stellar_equilibrium.cpp tests/operators/prepared_stellar_equilibrium.cpp
tests/operators/spherical_harmonic_equilibrium.cpp
tests/utils/domain.cpp tests/utils/domain.cpp
tests/field/field_base.cpp tests/field/field_base.cpp
tests/field/field_registry.cpp tests/field/field_registry.cpp
@@ -359,6 +365,7 @@ add_executable(tests
tests/preconditioning/backends.cpp tests/preconditioning/backends.cpp
tests/preconditioning/gravity_field.cpp tests/preconditioning/gravity_field.cpp
tests/preconditioning/material_surface.cpp tests/preconditioning/material_surface.cpp
tests/preconditioning/spherical_harmonic_surface.cpp
tests/preconditioning/stellar_structure.cpp tests/preconditioning/stellar_structure.cpp
tests/preconditioning/specification_border.cpp tests/preconditioning/specification_border.cpp
tests/extensions/fixed_magnetic_specific_energy.cpp tests/extensions/fixed_magnetic_specific_energy.cpp
@@ -367,6 +374,7 @@ add_executable(tests
tests/normalization/plan.cpp tests/normalization/plan.cpp
tests/normalization/physical_riesz.cpp tests/normalization/physical_riesz.cpp
tests/normalization/stellar_equilibrium.cpp tests/normalization/stellar_equilibrium.cpp
tests/normalization/spherical_harmonic_surface.cpp
tests/user-api/stellar_equilibrium.cpp tests/user-api/stellar_equilibrium.cpp
tests/solver/preconditioning_diagnostics.cpp tests/solver/preconditioning_diagnostics.cpp
tests/solver/stellar_equilibrium_architecture.cpp tests/solver/stellar_equilibrium_architecture.cpp

View File

@@ -0,0 +1,76 @@
# Native spherical harmonic surface candidate
Branch: `research/spherical-harmonic-surface-20260919`.
Parent: `2171b8b76ad78856323fdbcf3d2c537d9039201f`.
The preferred research candidate is degree 4 with **all real azimuthal orders**: 25 independent surface coefficients. This is a discretization choice, not a change to the physical model. The coefficients and interior fields belong to the same Newton system, including their full cross derivatives. The existing nodal radial prescription remains selectable for comparisons. The immediate physical target is rotation through 50% critical; this integration does not establish that target or choose a converged harmonic cutoff for it.
## Coordinates and configuration
For reference boundary nodes with radial directions `rhat_i`, the scalar radial displacement is
q_i = sum_{l=0..L, |m|<=min(l,M)} a_lm Y_lm(rhat_i).
Omitting `M` retains every order. The real basis has unit integral of its square over solid angle, includes the Condon–Shortley phase, and uses negative orders for sine and positive orders for cosine. Coefficients have units of length; zero coefficients preserve the reference mesh. The catalog is ordered by increasing degree, then increasing signed order. Degree zero is a genuine one-parameter monopole.
| Choice | Independent surface parameters |
|---|---:|
| L=4, all m | 25 |
| L=4, maximum absolute m=2 | 19 |
| L=4, m=0 only | 5 |
| L=8, all m | 81 |
No odd-degree or azimuthal modes are silently removed. Restricting to `m=0` constrains the **shape** about the fixed global z axis; it does not impose axisymmetry on the three-dimensional interior fields. The reference center is configurable, but changing the frame is not part of a Newton update.
```cpp
auto discretization = equilibrium::makeStellarDiscretization(
std::move(finiteElements),
normalization::PhysicalRieszDiagonal{dimensions::LengthValue{radius}, G},
deformation::SphericalHarmonicSurface{
deformation::SphericalHarmonicSurfaceOptions{.maximumDegree = 4}
}
);
```
Set `maximumAzimuthalOrder = 0` in the options for the axisymmetric shape comparison. Mode counts and actual sampled independence are checked at compilation onto the mesh. Unresolved modes are rejected, not silently discarded. Prescription/extension compatibility and the coupled operator types remain compile-time constrained; degree, mesh rank and sample dimensions are runtime data.
The sandbox selects this candidate explicitly. Existing two-argument discretization calls retain their nodal behavior. Surface choice lives on the numerical discretization, so the physics-facing EOS, surface-pressure and mass/angular-momentum specification is unchanged.
## Mathematical and architectural changes
- `SphericalHarmonicSurface` satisfies the existing surface-prescription contract and composes with the existing interior and fixed-infinity vacuum extensions. Its cached reference basis has exact forward and transpose actions and a zero derivative of the pullback.
- `StellarDiscretizationFor` owns typed surface/interior/vacuum prescriptions. The modern equilibrium compiler no longer constructs a hardcoded nodal surface.
- Normalization and the default material/surface inverse assemble a diagonal of the pulled-back reference vector trace mass matrix for modal coordinates. The state/residual Gram factors remain a primal/dual pair. The nodal assembly path is preserved.
- Reference metric assembly checks the FEM identity and an explicit canonical trace-layout capability. The erased bridge supports older volume-only deformation operators without claiming that arbitrary trace layouts can use this metric.
- The calibrated diagonal surface inverse supports the actual ordered parameters. The old nodal H1 surrogate explicitly rejects harmonic coordinates; implementing a projected H1 or a dense small surface Schur block is a later preconditioner experiment.
The mesh geometry and field orders are unchanged: Q4 reference geometry, Q3 displacement. Harmonic nodal samples are interpolated through the existing FE trace. Consequently the resulting surface is not an exact analytic harmonic graph between nodes, and the original reference-mesh interpolation error remains.
This differs deliberately from yesterday's frozen reduced-space diagnostic. That experiment retained a general nodal base `q0`, area-whitened its radial basis, and used separate normalized QR maps. The native prescription uses fixed raw reference coordinates from the seed onward. Equality of corresponding **physical** residuals and Jacobians is the validation criterion; identical Krylov trajectories under different diagonal metrics are not expected.
The implementation currently requires one MPI rank, consistent with the existing radial volume extension. Replicated global coefficients are not passed to distributed Newton inner products.
## Validation and reproducibility
New tests cover the harmonic catalog and analytic low-order functions, derivative/transpose/pullback identities, genuine sampled alias rejection, equivalence to the same nodal geometry, coupled physical residual/Jacobian equivalence and centered differences with generated rotation, normalization before physical preparation, independent boundary integration of the metric, and default/calibrated inverse recovery. Unsupported metric layouts, a different current FEM and the unsupported H1 combination are rejected explicitly.
The live preset is `spherical_harmonic_surface_cases_2026-09-19.json`. Native use of the diagnostic driver is explicit:
--surface-prescription harmonics --surface-degree 4
Optionally add `--surface-maximum-order 0`. Legacy frozen nodal probes are excluded from native mode. Known harmonic snapshot metadata is rejected by legacy loaders; undocumented external snapshots still require the caller to supply compatible coordinates.
Every live case retains q14/q18 virial, EOS, positivity and hydrostatic observations. Additional artifacts contain the exact sampled basis, ordered coefficients, full nodal shape force and its component outside the harmonic span. The excluded force uses the Euclidean nodal-dual norm and is not the solver's normalized residual. Both geometry and force pullback equivalence are checked in the observer.
Evidence directory: `../../diagnostics/spherical_harmonic_surface_2026-09-19` relative to this checkout. Builds, tests, live cases and independent artifact audits run serially. Source reviews can run concurrently.
## Implementation validation
- All 10 new harmonic cases passed: 4,273,672 assertions in 174.514 seconds. The large assertion count includes array-element checks; the substantive evidence is the independent analytic, adjoint, coupled finite-difference and nodal-equivalence tests.
- All 64 selected existing regression cases passed: 2,795,921 assertions in 643.882 seconds. These cover nodal/radial/domain deformation, normalization, material/surface preconditioning, modern equilibrium problems and the user API.
- The two-rank invocation exited successfully. It exercised the explicit harmonic rejection paths; tests requiring serial physical preparation were skipped. This does not establish distributed harmonic-solver support.
- The independent artifact auditor passed its valid fixture and rejected six deliberate corruptions (catalog, radial displacement, excluded force, norm, nonfinite data and coefficient count).
Clang initially exhausted its source-location budget when the new interfaces included all of MFEM. Narrowing their includes and moving the non-template metric assembly into an implementation file resolved the test-build failure without changing compiler flags or numerical behavior. The test executable and sandbox have built successfully; live driver/results are recorded below when complete.
A small modal residual or good virial balance alone is not proof of a physically accurate rotating model.

View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Check saved native harmonic geometry/force algebra and campaign provenance.
No FEM residual or physical quadrature is recomputed. The force complement is
Euclidean in nodal coordinates, not the solver's dimensioned residual metric.
Artifact consistency, nonlinear convergence and physical accuracy are separate.
"""
import argparse
import csv
import hashlib
import json
import math
import os
from pathlib import Path
import tempfile
for _name in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
os.environ[_name] = "1"
import numpy as np
EPS = np.finfo(float).eps
RTOL = 5e-10
STAGES = ("initial", "final")
REQUIRED = {"metadata.txt", "layout.csv", "accepted_state.txt", "surface_basis.csv", "newton.csv"} | {
f"{stage}_{suffix}.csv" for stage in STAGES
for suffix in ("physical", "surface_coefficients", "surface_nodal_force", "surface_audit")
}
def digest(path):
h = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def rows(path):
with Path(path).open(newline="") as stream:
reader = csv.DictReader(stream)
if not reader.fieldnames or len(set(reader.fieldnames)) != len(reader.fieldnames):
raise ValueError(f"Missing/duplicate CSV columns: {path}")
data = list(reader)
if any(None in row or any(v is None for v in row.values()) for row in data):
raise ValueError(f"Malformed CSV row: {path}")
return data
def finite(value):
result = float(value)
if not math.isfinite(result):
raise ValueError(f"Nonfinite available algebraic value: {value}")
return result
def integer(value):
result = finite(value)
if result != int(result):
raise ValueError(f"Nonintegral index/count: {value}")
return int(result)
def keyed(data, key):
result = {key(row): row for row in data}
if len(result) != len(data):
raise ValueError("Duplicate observation key")
return result
def contract(subscripts, *arrays):
if any(not np.isfinite(a).all() for a in arrays):
raise ValueError("Nonfinite contraction input")
# Avoid the Accelerate matmul flag issue observed in the earlier auditors.
result = np.einsum(subscripts, *arrays, optimize=False)
if not np.isfinite(result).all():
raise ValueError("Nonfinite contraction result")
return result
def norm(a):
return math.sqrt(float(contract("i,i->", np.ravel(a), np.ravel(a))))
class Audit:
def __init__(self):
self.checks = []
def check(self, name, condition):
self.checks.append({"check": name, "passed": bool(condition)})
def close(self, name, actual, expected, scale=0.):
a, b = np.asarray(actual, dtype=float), np.asarray(expected, dtype=float)
valid = a.shape == b.shape and np.isfinite(a).all() and np.isfinite(b).all()
self.check(name, valid and norm(a-b) <= RTOL * max(norm(a), norm(b)) + 256 * EPS * abs(scale))
@property
def passed(self):
return all(c["passed"] for c in self.checks)
def modes(degree, maximum_order):
if degree < 0 or maximum_order < -1 or maximum_order > degree:
raise ValueError("Invalid harmonic degree/order")
cutoff = degree if maximum_order == -1 else maximum_order
return [(l, m) for l in range(degree+1) for m in range(-min(l, cutoff), min(l, cutoff)+1)]
def surface_stage(folder, stage, basis, catalog, audit):
label = stage + ":surface:"
coefficients = rows(folder / f"{stage}_surface_coefficients.csv")
forces = rows(folder / f"{stage}_surface_nodal_force.csv")
summary = {k: finite(r["value"]) for k, r in keyed(
rows(folder / f"{stage}_surface_audit.csv"), lambda r: r["metric"]).items()}
n, k = basis.shape
if len(coefficients) != k or len(forces) != n:
raise ValueError("Surface coefficient/force counts disagree with basis")
audit.check(label+"coefficient_indices", [integer(r["index"]) for r in coefficients] == list(range(k)))
audit.check(label+"mode_catalog", [(integer(r["degree"]), integer(r["order"])) for r in coefficients] == catalog)
audit.check(label+"node_indices", [integer(r["node"]) for r in forces] == list(range(n)))
audit.check(label+"reported_dimensions", summary["parameter_count"] == k and summary["nodal_count"] == n)
a = np.array([finite(r["radial_length_coefficient"]) for r in coefficients])
radial, force, excluded = (np.array([finite(r[field]) for r in forces])
for field in ("radial_displacement", "force", "excluded_force"))
audit.close(label+"radial_displacement", radial, contract("ij,j->i", basis, a), norm(basis)*norm(a))
q, r = np.linalg.qr(basis, mode="reduced")
if not np.isfinite(q).all() or not np.isfinite(r).all():
raise ValueError("Nonfinite QR factors")
relative_pivots = np.abs(np.diag(r)) / np.sqrt(contract("ij,ij->j", basis, basis))
audit.check(label+"resolved_mode_rank", np.isfinite(relative_pivots).all() and np.all(relative_pivots > 1e-12))
audit.close(label+"QR_reconstruction", contract("ij,jk->ik", q, r), basis)
audit.close(label+"QR_orthogonality", contract("ij,ik->jk", q, q), np.eye(k))
projection = contract("ij,j->i", q, contract("ij,i->j", q, force))
expected_excluded = force-projection
modal_force = contract("ij,i->j", basis, force)
audit.close(label+"excluded_projection", excluded, expected_excluded, norm(force))
audit.close(label+"excluded_annihilates_modes", contract("ij,i->j", basis, excluded), np.zeros(k),
norm(basis)*norm(force))
audit.close(label+"orthogonal_energy", norm(force)**2, norm(projection)**2 + norm(excluded)**2, norm(force)**2)
audit.close(label+"virtual_work", float(contract("i,i->", a, modal_force)),
float(contract("i,i->", radial, force)), norm(radial)*norm(force))
for metric, actual in (("full_nodal_force_euclidean_norm", norm(force)),
("excluded_nodal_force_euclidean_norm", norm(excluded)),
("modal_force_euclidean_norm", norm(modal_force))):
audit.close(label+metric, summary[metric], actual, norm(basis)*norm(force))
audit.check(label+"producer_equivalence_errors_nonnegative", summary["volume_equivalence_error"] >= 0 and
summary["force_pullback_equivalence_error"] >= 0)
audit.check(label+"producer_pullback_bound", summary["force_pullback_equivalence_error"] <=
1e-11 * (1 + norm(modal_force)))
return a, {"summary": summary, "radial_displacement_euclidean_norm": norm(radial),
"coefficient_euclidean_norm": norm(a), "minimum_relative_QR_pivot": float(min(relative_pivots)),
"excluded_force_fraction": norm(excluded)/norm(force) if norm(force) > 0 else None,
"basis_transpose_excluded_norm": norm(contract("ij,i->j", basis, excluded)),
"volume_and_native_pullback_equivalence": "producer observations; underlying vectors are not saved"}
def physical_stage(folder, stage, metadata, audit):
data = keyed(rows(folder / f"{stage}_physical.csv"), lambda r: (integer(r["quadrature"]), r["metric"]))
audit.check(stage+":physical:quadratures", {q for q, _ in data} == {14, 18})
result = {}
for q in (14, 18):
m = {name: float(row["value"]) for (order, name), row in data.items() if order == q}
label = f"{stage}:physical:q{q}:"
audit.check(label+"model", m["polytropic_index"] == finite(metadata["n"]) and
m["polytropic_constant"] == finite(metadata["K"]))
for key in ("complete_volume_measurement", "pressure_from_enthalpy_defined_everywhere",
"pressure_from_density_defined_everywhere"):
audit.check(label+key+":boolean", m[key] in (0., 1.))
complete = m["complete_volume_measurement"] == 1
if complete:
audit.close(label+"mass_error", m["mass_absolute_error"], abs(m["mass"]-finite(metadata["M"])), abs(m["mass"]))
audit.close(label+"J_error", m["angular_momentum_absolute_error"], abs(m["angular_momentum_z"]-finite(metadata["J"])), 1.)
milestones = {}
for prefix, pressure, valid in (("", "pressure_integral", "pressure_from_density_defined_everywhere"),
("enthalpy_", "enthalpy_pressure_integral", "pressure_from_enthalpy_defined_everywhere")):
defined = complete and m[valid] == 1 and math.isfinite(m["binding_energy"]) and abs(m["binding_energy"]) > 0
for force_prefix, binding in (("", "binding_energy"), ("force_", "force_binding_energy")):
key = prefix+force_prefix+"virial_"
signed, error = m[key+"signed"], m[key+"error"]
if defined:
terms = (2*m["kinetic_energy"], m[binding], 3*m[pressure])
scale = math.fsum(abs(v) for v in terms)/abs(m["binding_energy"])
audit.close(label+key+"identity", signed, math.fsum(terms)/abs(m["binding_energy"]), scale)
audit.close(label+key+"absolute", error, abs(signed), scale)
else:
audit.check(label+key+"undefined", math.isnan(signed) and math.isnan(error))
milestones[key+"within_1e5"] = bool(error <= 1e-5) if defined and math.isfinite(error) else None
audit.check(label+"finite_native_residual", math.isfinite(m["normalized_bordered_residual"]) and
m["normalized_bordered_residual"] >= 0)
result[str(q)] = {"metrics": m, "virial_milestones": milestones,
"undefined_metrics": [name for name, value in m.items() if not math.isfinite(value)]}
return result
def inspect_case(folder, record, metadata, audit):
if metadata["surface_prescription"] != "harmonics":
raise ValueError("The native harmonic auditor requires harmonic cases")
catalog = modes(integer(metadata["surface_degree"]), integer(metadata["surface_maximum_order"]))
with (folder / "surface_basis.csv").open(newline="") as stream:
basis = np.array([[finite(value) for value in row] for row in csv.reader(stream)])
if basis.ndim != 2 or basis.shape[1] != len(catalog) or basis.shape[0] < basis.shape[1]:
raise ValueError("Invalid tall harmonic basis dimensions")
audit.close("basis:monopole_normalization", basis[:, 0], np.full(basis.shape[0], 1/math.sqrt(4*math.pi)))
surface, coefficients = {}, {}
for stage in STAGES:
coefficients[stage], surface[stage] = surface_stage(folder, stage, basis, catalog, audit)
layout = keyed(rows(folder / "layout.csv"), lambda r: (r["kind"], r["block"]))
totals = {}
for kind in ("value", "residual"):
blocks = sorted((r for (k, _), r in layout.items() if k == kind), key=lambda r: integer(r["offset"]))
offset = 0
for row in blocks:
audit.check("layout:"+kind+":"+row["block"], integer(row["offset"]) == offset and integer(row["size"]) > 0 and finite(row["scale"]) > 0)
offset += integer(row["size"])
totals[kind] = offset
audit.check("layout:square", totals["value"] == totals["residual"] > 0)
shape = layout["value", "surface_deformation"]
audit.check("layout:shape_dimensions", integer(shape["size"]) == len(catalog) and
integer(layout["residual", "surface_shape_balance"]["size"]) == len(catalog))
with (folder / "accepted_state.txt").open() as stream:
size = integer(stream.readline())
state = np.loadtxt(stream, ndmin=1)
if state.ndim != 1 or len(state) != size or size != totals["value"] or not np.isfinite(state).all():
raise ValueError("Invalid accepted physical state")
start = integer(shape["offset"])
audit.close("final:coefficients_match_accepted_state", coefficients["final"], state[start:start+len(catalog)])
physics = {stage: physical_stage(folder, stage, metadata, audit) for stage in STAGES}
history = rows(folder / "newton.csv")
audit.check("newton:ordered_iterations", [integer(row["iteration"]) for row in history] == list(range(len(history))))
accepted = 0
before = physics["initial"]["18"]["metrics"]["normalized_bordered_residual"]
for row in history:
label = "newton:"+row["iteration"]+":"
flag, residual, alpha = integer(row["accepted"]), finite(row["F"]), finite(row["alpha"])
audit.check(label+"accepted_boolean", flag in (0, 1))
audit.check(label+"nonnegative_norm", residual >= 0)
for name in ("seconds", "linear_seconds", "prepare_seconds", "refresh_seconds"):
audit.check(label+name, finite(row[name]) >= 0)
if flag == 1:
accepted += 1
audit.check(label+"accepted_alpha", 0 < alpha <= 1)
audit.check(label+"recorded_true_linear_gate", 0 <= finite(row["linear_true"]) <= finite(metadata["linear_tol"])*(1+RTOL))
audit.check(label+"residual_decrease", residual <= before*(1+RTOL))
else:
audit.close(label+"rejected_state_preserved", residual, before)
before = residual
audit.check("newton:accepted_count", integer(metadata["accepted_steps"]) == accepted)
converged = integer(metadata["converged"])
audit.check("newton:converged_boolean", converged in (0, 1))
audit.check("newton:exit_matches_outcome", record["exit_code"] == (0 if converged else 3))
for q in ("14", "18"):
audit.close("newton:final_residual:q"+q, physics["final"][q]["metrics"]["normalized_bordered_residual"], before)
sensitivities = {stage: {name: physics[stage]["18"]["metrics"][name]-value
for name, value in physics[stage]["14"]["metrics"].items()
if math.isfinite(value) and math.isfinite(physics[stage]["18"]["metrics"].get(name, math.nan))}
for stage in STAGES}
return {"metadata": metadata, "modes": catalog, "basis_shape": list(basis.shape), "surface": surface,
"physical": physics, "q18_minus_q14": sensitivities, "newton": history,
"accepted_steps": accepted, "converged": bool(converged), "failure": metadata.get("failure"),
"initial_residual": physics["initial"]["18"]["metrics"]["normalized_bordered_residual"],
"final_residual": before, "summed_iteration_seconds": math.fsum(finite(r["seconds"]) for r in history),
"basis_values_independently_reconstructed": False,
"basis_limitation": "Reference node directions are not saved; mode labels and monopole are checked, not all Y_lm values."}
def audit_campaign(campaign):
campaign = campaign.resolve()
audit, results = Audit(), {}
provenance = json.loads((campaign / "provenance.json").read_text())
cases = provenance["cases"]
audit.check("campaign:manifest", cases == json.loads((campaign / "cases.json").read_text()) and
len(cases) > 0 and len({c["id"] for c in cases}) == len(cases))
audit.check("campaign:binary", digest(campaign / "plateau_diagnostic") == provenance["binary_sha256"])
audit.check("campaign:mesh", digest(provenance["mesh"]) == provenance["mesh_sha256"])
for case in cases:
name, arguments = case["id"], case["arguments"]
local = Audit()
try:
if not name or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for c in name):
raise ValueError("Invalid case directory name")
folder = campaign / name
record_path = campaign / (name+"_run.json")
record = json.loads(record_path.read_text())
local.check("provenance:completed", bool(record.get("finished_utc")) and not record.get("timed_out", False) and record.get("exit_code") in (0, 3))
local.check("provenance:command", record["id"] == name and record["command"] ==
[str(campaign / "plateau_diagnostic"), *arguments, "--mesh", provenance["mesh"], "--output", str(folder)])
hashes = record["output_hashes"]
local.check("provenance:required_hashes", REQUIRED <= set(hashes))
observed = {str(p.relative_to(folder)) for p in folder.rglob("*") if p.is_file() and p.suffix in (".txt", ".csv", ".json")}
local.check("provenance:complete_output_manifest", set(hashes) == observed)
for relative, expected in hashes.items():
path = folder / relative
if not path.resolve().is_relative_to(folder.resolve()):
raise ValueError("Output hash path escapes case directory")
local.check("provenance:output:"+relative, digest(path) == expected)
for path, expected in record.get("input_state_hashes", {}).items():
local.check("provenance:input:"+path, digest(path) == expected)
entries = [line.split("=", 1) for line in (folder / "metadata.txt").read_text().splitlines() if "=" in line]
metadata = dict(entries)
if len(metadata) != len(entries):
raise ValueError("Duplicate metadata key")
for option, key in (("--index", "n"), ("--angular-momentum", "J"), ("--surface-degree", "surface_degree"),
("--surface-maximum-order", "surface_maximum_order"), ("--max-newton", "max_newton"),
("--linear-tol", "linear_tol"), ("--max-linear", "max_linear"), ("--restart", "restart")):
if option in arguments:
local.check("requested:"+key, finite(metadata[key]) == finite(arguments[arguments.index(option)+1]))
for option, key in (("--surface-prescription", "surface_prescription"), ("--strategy", "strategy"),
("--preconditioner-variant", "preconditioner_variant")):
if option in arguments:
local.check("requested:"+key, metadata[key] == arguments[arguments.index(option)+1])
if "--no-central" in arguments:
local.check("requested:no_central", metadata["central_phase"] == "0")
result = inspect_case(folder, record, metadata, local)
result["run_sha256"] = digest(record_path)
result["timing"] = {key: record.get(key) for key in ("seconds", "utc_elapsed_seconds", "utc_minus_monotonic_seconds", "child_cpu_seconds")}
except (OSError, ValueError, KeyError, TypeError, IndexError, OverflowError, np.linalg.LinAlgError) as error:
local.check("readable_complete_artifacts:"+str(error), False)
result = {"error": str(error)}
result.update(artifact_passed=local.passed, checks=local.checks)
results[name] = result
audit.check("case:"+name, local.passed)
return {"schema_version": 1, "artifact_passed": audit.passed, "campaign": str(campaign),
"source_commit": provenance["commit"], "provenance_sha256": digest(campaign / "provenance.json"),
"auditor_sha256": digest(__file__), "tolerances": {"relative": RTOL, "arithmetic_allowance_eps": 256},
"scope": "Saved algebra, hashes and reported scalar identities; no fresh FEM, quadrature or model validity certificate.",
"checks": audit.checks, "cases": results}
def self_test():
"""Tiny real-mode fixture; deliberate corruptions must fail independently."""
constant, linear = 1/math.sqrt(4*math.pi), math.sqrt(3/(4*math.pi))
points = np.array([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]])
basis = np.column_stack((np.full(6, constant), -linear*points[:, 1], linear*points[:, 2], -linear*points[:, 0]))
coefficients = np.array([.2, .01, .02, .03])
force = np.array([.3, -.4, .2, .7, -.3, .9])
modal = contract("ij,i->j", basis, force)
excluded = force-contract("ij,j->i", basis, modal/contract("ij,ij->j", basis, basis))
radial = contract("ij,j->i", basis, coefficients)
outcomes = []
for corruption in (None, "catalog", "radial", "excluded", "norm", "nonfinite", "coefficient_count"):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
mode_rows = [[i, l, m, coefficients[i]] for i, (l, m) in enumerate(modes(1, -1))]
force_rows = [[i, radial[i], force[i], excluded[i]] for i in range(6)]
summary = {"parameter_count": 4, "nodal_count": 6, "full_nodal_force_euclidean_norm": norm(force),
"excluded_nodal_force_euclidean_norm": norm(excluded), "modal_force_euclidean_norm": norm(modal),
"volume_equivalence_error": 0., "force_pullback_equivalence_error": 0.}
if corruption == "catalog": mode_rows[1][2] = 0
if corruption == "radial": force_rows[0][1] += .01
if corruption == "excluded": force_rows[1][3] += .01
if corruption == "norm": summary["modal_force_euclidean_norm"] += .01
if corruption == "nonfinite": force_rows[2][2] = math.nan
if corruption == "coefficient_count": mode_rows.pop()
for name, header, data in (("coefficients", ("index", "degree", "order", "radial_length_coefficient"), mode_rows),
("nodal_force", ("node", "radial_displacement", "force", "excluded_force"), force_rows),
("audit", ("metric", "value"), summary.items())):
with (folder / ("initial_surface_"+name+".csv")).open("w", newline="") as stream:
writer = csv.writer(stream); writer.writerow(header); writer.writerows(data)
check = Audit()
try:
surface_stage(folder, "initial", basis, modes(1, -1), check)
passed = check.passed
except (ValueError, KeyError):
passed = False
outcomes.append({"fixture": corruption or "valid", "passed": passed == (corruption is None)})
return {"self_test_passed": all(r["passed"] for r in outcomes), "fixtures": outcomes}
def safe(value):
if isinstance(value, float) and not math.isfinite(value): return None
if isinstance(value, dict): return {k: safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)): return [safe(v) for v in value]
return value
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("campaign", type=Path, nargs="?")
parser.add_argument("--output", type=Path)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
if args.self_test:
result = self_test()
print(json.dumps(result, indent=2))
return 0 if result["self_test_passed"] else 1
if args.campaign is None or args.output is None:
parser.error("A campaign and --output are required unless --self-test is selected")
try:
result = audit_campaign(args.campaign)
except (OSError, ValueError, KeyError, TypeError) as error:
result = {"artifact_passed": False, "error": str(error), "auditor_sha256": digest(__file__)}
args.output.write_text(json.dumps(safe(result), indent=2, allow_nan=False)+"\n")
print("PASS" if result["artifact_passed"] else "FAIL", args.output)
return 0 if result["artifact_passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -32,6 +32,7 @@ import mean_field;
#include "plateau_polynomial_certificate.hpp" #include "plateau_polynomial_certificate.hpp"
#include "shape_field_weak_diagnostics.hpp" #include "shape_field_weak_diagnostics.hpp"
#include "reduced_surface_diagnostics.hpp" #include "reduced_surface_diagnostics.hpp"
#include "spherical_harmonic_surface_diagnostics.hpp"
using namespace mean_field; using namespace mean_field;
namespace { namespace {
@@ -44,7 +45,8 @@ struct Options {
bool preconditionerProbe = false, nonlinearStepProbe = false; bool preconditionerProbe = false, nonlinearStepProbe = false;
bool weakShapeProbe = false, weakShapeFinal = false; bool weakShapeProbe = false, weakShapeFinal = false;
bool reducedSurfaceProbe = false, reducedSurfaceAlgebraCheck = false; bool reducedSurfaceProbe = false, reducedSurfaceAlgebraCheck = false;
int surfaceDegree = 4; int surfaceDegree = 4, surfaceMaximumOrder = -1;
std::string surfacePrescription = "nodal";
bool coupledTrialProbe = false, coupledSeedControl = false; bool coupledTrialProbe = false, coupledSeedControl = false;
bool dampedCoupledProbe = false; bool dampedCoupledProbe = false;
std::string coupledSearch = "near"; std::string coupledSearch = "near";
@@ -122,6 +124,10 @@ Options Parse(int argc, char **argv) {
o.reducedSurfaceProbe = true; o.reducedSurfaceProbe = true;
else if (a == "--reduced-surface-algebra-check") else if (a == "--reduced-surface-algebra-check")
o.reducedSurfaceAlgebraCheck = true; o.reducedSurfaceAlgebraCheck = true;
else if (a == "--surface-prescription")
o.surfacePrescription = v();
else if (a == "--surface-maximum-order")
o.surfaceMaximumOrder = std::stoi(v());
else if (a == "--surface-degree") else if (a == "--surface-degree")
o.surfaceDegree = std::stoi(v()); o.surfaceDegree = std::stoi(v());
else if (a == "--weak-shape-probe") else if (a == "--weak-shape-probe")
@@ -186,6 +192,23 @@ Options Parse(int argc, char **argv) {
} }
if (o.output.empty() || o.n < 1 || o.n >= 5 || o.maxNewton < 0 || o.maxLinear < 1 || o.restart < 1) if (o.output.empty() || o.n < 1 || o.n >= 5 || o.maxNewton < 0 || o.maxLinear < 1 || o.restart < 1)
throw std::invalid_argument("Invalid options"); throw std::invalid_argument("Invalid options");
if (o.surfacePrescription != "nodal" && o.surfacePrescription != "harmonics")
throw std::invalid_argument("Surface prescriptions are nodal or harmonics");
if (o.surfacePrescription == "harmonics") {
deformation::SphericalHarmonicSurfaceOptions surfaceOptions{.maximumDegree = o.surfaceDegree};
if (o.surfaceMaximumOrder != -1) surfaceOptions.maximumAzimuthalOrder = o.surfaceMaximumOrder;
surfaceOptions.validate();
// Older frozen probes interpret snapshots as nodal radial values. A
// size match is insufficient to establish modal snapshot provenance.
if (o.reducedSurfaceProbe || o.reducedSurfaceAlgebraCheck || o.weakShapeProbe || o.weakShapeFinal ||
o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace || o.strategyProbe ||
o.positivityProbe || o.positivityCertificate || o.preconditionerProbe || o.nonlinearStepProbe ||
o.coupledTrialProbe || o.dampedCoupledProbe || !o.probeState.empty() || !o.probeDirection.empty() ||
!o.replayCandidate.empty() || o.strategy != "baseline")
throw std::invalid_argument("Native harmonic diagnostics currently support live baseline Newton trajectories only");
} else if (o.surfaceMaximumOrder != -1) {
throw std::invalid_argument("--surface-maximum-order requires --surface-prescription harmonics");
}
if (o.reducedSurfaceProbe || o.reducedSurfaceAlgebraCheck) { if (o.reducedSurfaceProbe || o.reducedSurfaceAlgebraCheck) {
if ((o.reducedSurfaceProbe && o.reducedSurfaceAlgebraCheck) || o.maxNewton != 0 || if ((o.reducedSurfaceProbe && o.reducedSurfaceAlgebraCheck) || o.maxNewton != 0 ||
o.strategy != "baseline" || o.probes || o.pressureProbe || o.shapeCurvature || o.strategy != "baseline" || o.probes || o.pressureProbe || o.shapeCurvature ||
@@ -323,6 +346,15 @@ void SaveVector(const std::filesystem::path &p, const mfem::Vector &v) {
v.Print(s, 1); v.Print(s, 1);
} }
mfem::Vector LoadVector(const std::filesystem::path &path, int expectedSize) { mfem::Vector LoadVector(const std::filesystem::path &path, int expectedSize) {
// Legacy nodal probes may share a total vector size with another shape
// space. Honor known coordinate provenance before reading its numbers.
for (const auto &directory : {path.parent_path(), path.parent_path().parent_path()}) {
std::ifstream metadata(directory / "metadata.txt");
std::string line;
while (std::getline(metadata, line))
if (line == "surface_prescription=harmonics")
throw std::invalid_argument("Legacy nodal probes cannot load a harmonic snapshot: " + path.string());
}
std::ifstream input(path); std::ifstream input(path);
int count = -1; int count = -1;
input >> count; input >> count;
@@ -560,6 +592,8 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
<< "\ngravity_iterations=" << o.gravityIterations << "\ngravity_restart=" << o.gravityRestart << "\ngravity_iterations=" << o.gravityIterations << "\ngravity_restart=" << o.gravityRestart
<< "\npreconditioner_probe=" << o.preconditionerProbe << "\npreconditioner_probe=" << o.preconditionerProbe
<< "\nnonlinear_step_probe=" << o.nonlinearStepProbe << "\nnonlinear_step_probe=" << o.nonlinearStepProbe
<< "\nsurface_prescription=" << o.surfacePrescription
<< "\nsurface_maximum_order=" << o.surfaceMaximumOrder
<< "\nreduced_surface_probe=" << o.reducedSurfaceProbe << "\nsurface_degree=" << o.surfaceDegree << "\nreduced_surface_probe=" << o.reducedSurfaceProbe << "\nsurface_degree=" << o.surfaceDegree
<< "\nweak_shape_probe=" << o.weakShapeProbe << "\nweak_shape_final=" << o.weakShapeFinal << "\nweak_shape_probe=" << o.weakShapeProbe << "\nweak_shape_final=" << o.weakShapeFinal
<< "\ndamped_coupled_probe=" << o.dampedCoupledProbe << "\ndamped_coupled_probe=" << o.dampedCoupledProbe
@@ -762,6 +796,11 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
experiment::polytrope_validation::PhysicalState physical(s, f); experiment::polytrope_validation::PhysicalState physical(s, f);
auto out = File(o.output / (label + "_physical.csv")); auto out = File(o.output / (label + "_physical.csv"));
out << "quadrature,metric,value\n"; out << "quadrature,metric,value\n";
if (o.surfacePrescription == "harmonics") {
deformation::SphericalHarmonicSurfaceOptions surfaceOptions{.maximumDegree = o.surfaceDegree};
if (o.surfaceMaximumOrder != -1) surfaceOptions.maximumAzimuthalOrder = o.surfaceMaximumOrder;
experiment::plateau_diagnostics::RecordHarmonicSurface(s, f, surfaceOptions, o.output, label);
}
for (int q : {14, 18}) { for (int q : {14, 18}) {
auto metrics = experiment::plateau_diagnostics::MeasurePhysical(physical, o.n, scales.K, q); auto metrics = experiment::plateau_diagnostics::MeasurePhysical(physical, o.n, scales.K, q);
metrics["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm; metrics["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm;
@@ -1065,9 +1104,11 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
}); });
return converged ? 0 : 3; return converged ? 0 : 3;
} }
template <class Model> int Setup(Model model, fem::FEM f, const Options &o, const Scales &scales) { template <class Model, class Surface>
int SetupWithSurface(Model model, fem::FEM f, Surface surface, const Options &o, const Scales &scales) {
auto d = equilibrium::makeStellarDiscretization( auto d = equilibrium::makeStellarDiscretization(
std::move(f), normalization::PhysicalRieszDiagonal{dimensions::LengthValue{1.}, utils::G}); std::move(f), normalization::PhysicalRieszDiagonal{dimensions::LengthValue{1.}, utils::G},
std::move(surface));
const auto withRecipe = [&](auto recipe) { const auto withRecipe = [&](auto recipe) {
using Recipe = decltype(recipe); using Recipe = decltype(recipe);
using Problem = equilibrium::StellarEquilibriumProblem<Model, decltype(d)>; using Problem = equilibrium::StellarEquilibriumProblem<Model, decltype(d)>;
@@ -1095,6 +1136,18 @@ template <class Model> int Setup(Model model, fem::FEM f, const Options &o, cons
{.correctionSweeps = o.schurCorrectionSweeps, .relaxation = o.schurRelaxation}}); {.correctionSweeps = o.schurCorrectionSweeps, .relaxation = o.schurRelaxation}});
throw std::logic_error("An unsupported Newton preconditioner bypassed option validation."); throw std::logic_error("An unsupported Newton preconditioner bypassed option validation.");
} }
template <class Model> int Setup(Model model, fem::FEM f, const Options &o, const Scales &scales) {
if (o.surfacePrescription == "harmonics") {
deformation::SphericalHarmonicSurfaceOptions options{.maximumDegree = o.surfaceDegree};
if (o.surfaceMaximumOrder != -1) options.maximumAzimuthalOrder = o.surfaceMaximumOrder;
return SetupWithSurface(std::move(model), std::move(f),
deformation::SphericalHarmonicSurface{options}, o, scales);
}
mfem::Vector center(3);
center = 0.0;
return SetupWithSurface(std::move(model), std::move(f),
deformation::NodalRadialSurface{std::move(center)}, o, scales);
}
int Run(const Options &o) { int Run(const Options &o) {
const auto scales = Derive(o.n); const auto scales = Derive(o.n);
std::cout << "n=" << o.n << " K=" << scales.K << " rho_c=" << scales.rho << " J=" << o.angularMomentum << std::endl; std::cout << "n=" << o.n << " K=" << scales.K << " rho_c=" << scales.rho << " J=" << o.angularMomentum << std::endl;

View File

@@ -0,0 +1,150 @@
[
{
"id": "n1_J0_L4",
"arguments": [
"--index",
"1",
"--angular-momentum",
"0",
"--surface-prescription",
"harmonics",
"--surface-degree",
"4",
"--no-central",
"--max-newton",
"6",
"--restart",
"160",
"--max-linear",
"200",
"--linear-tol",
"0.03",
"--rtol",
"1e-8",
"--atol",
"0",
"--strategy",
"baseline"
]
},
{
"id": "n1p5_J0_L4",
"arguments": [
"--index",
"1.5",
"--angular-momentum",
"0",
"--surface-prescription",
"harmonics",
"--surface-degree",
"4",
"--no-central",
"--max-newton",
"6",
"--restart",
"160",
"--max-linear",
"200",
"--linear-tol",
"0.03",
"--rtol",
"1e-8",
"--atol",
"0",
"--strategy",
"baseline"
]
},
{
"id": "n1_J0p01_L4",
"arguments": [
"--index",
"1",
"--angular-momentum",
"0.01",
"--surface-prescription",
"harmonics",
"--surface-degree",
"4",
"--no-central",
"--max-newton",
"6",
"--restart",
"160",
"--max-linear",
"200",
"--linear-tol",
"0.03",
"--rtol",
"1e-8",
"--atol",
"0",
"--strategy",
"baseline"
]
},
{
"id": "n1_J0p01_L4_m0",
"arguments": [
"--index",
"1",
"--angular-momentum",
"0.01",
"--surface-prescription",
"harmonics",
"--surface-degree",
"4",
"--no-central",
"--max-newton",
"6",
"--restart",
"160",
"--max-linear",
"200",
"--linear-tol",
"0.03",
"--rtol",
"1e-8",
"--atol",
"0",
"--strategy",
"baseline",
"--surface-maximum-order",
"0"
]
},
{
"id": "n1p5_J0_L4_gravity_schur",
"arguments": [
"--index",
"1.5",
"--angular-momentum",
"0",
"--surface-prescription",
"harmonics",
"--surface-degree",
"4",
"--no-central",
"--max-newton",
"6",
"--restart",
"160",
"--max-linear",
"200",
"--linear-tol",
"0.03",
"--rtol",
"1e-8",
"--atol",
"0",
"--strategy",
"baseline",
"--preconditioner-variant",
"gravity-schur",
"--schur-sweeps",
"1",
"--schur-relaxation",
"0.5"
]
}
]

View File

@@ -0,0 +1,125 @@
#pragma once
// Include after import mean_field. These observations do not participate in
// acceptance: an equilibrium in the selected shape space can retain force
// components conjugate to the boundary motions excluded by that choice.
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <stdexcept>
#include <string>
namespace experiment::plateau_diagnostics {
template <class State>
void RecordHarmonicSurface(
State &state,
const mean_field::fem::FEM &fem,
mean_field::deformation::SphericalHarmonicSurfaceOptions options,
const std::filesystem::path &output,
const std::string &label
) {
using namespace mean_field;
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const auto map = field::make_stellar_surface_scalar_dof_map<Schema>(*fem.surfaceDeformationFes);
const auto surface = deformation::compileSurfaceDeformationPrescription(
deformation::SphericalHarmonicSurface{options},
deformation::SurfaceDeformationCompilationContext{*fem.surfaceDeformationFes, map}
);
mfem::Vector center(3);
center = 0.;
const auto extensionContext = deformation::makeRadialDeformationExtensionCompilationContext<Schema>(
*fem.surfaceDeformationFes, *fem.displacementFes, *fem.logicalReferenceMesh
);
const auto nodal = deformation::composePreparedDomainDeformation(
deformation::compileSurfaceDeformationPrescription(
deformation::NodalRadialSurface{center},
deformation::SurfaceDeformationCompilationContext{*fem.surfaceDeformationFes, map}
),
deformation::compileInteriorDeformationExtension(
deformation::PowerLawRadialInteriorExtension{}, extensionContext
),
deformation::compileVacuumDeformationExtension(
deformation::FixedInfinityRadialVacuumExtension{}, extensionContext
),
*fem.surfaceDeformationFes, *fem.displacementFes, *fem.logicalReferenceMesh
);
const auto &physical = state.Problem().GetPhysicalOperator();
const auto &basis = surface.nodalBasis();
const auto &coefficients = physical.GetSurfaceDeformationParameters();
if (coefficients.Size() != basis.Width())
throw std::logic_error("Harmonic observer received incompatible coordinates.");
mfem::Vector radial(basis.Height()), force(basis.Height()), volume(nodal.volumeDisplacementSize());
basis.Mult(coefficients, radial);
nodal.buildVolumeDisplacement(radial, volume);
volume -= physical.GetGeneratedVolumeDisplacement();
nodal.applyJacobianTranspose(radial, physical.GetFullMechanicalResidual(), force);
mfem::Vector retained(basis.Width()), native(basis.Width());
basis.MultTranspose(force, retained);
physical.GetDomainDeformation().applyJacobianTranspose(
coefficients, physical.GetFullMechanicalResidual(), native
);
native -= retained;
for (const auto *vector : {&force, &retained, &native, &volume, &radial})
for (int index = 0; index < vector->Size(); ++index)
if (!std::isfinite((*vector)(index)))
throw std::runtime_error("Harmonic observer received nonfinite geometry or force.");
if (volume.Norml2() > 1e-11 * (1. + physical.GetGeneratedVolumeDisplacement().Norml2()) ||
native.Norml2() > 1e-11 * (1. + retained.Norml2()))
throw std::runtime_error("Harmonic observer failed its nodal geometry or dual-work equivalence check.");
// The Euclidean complement is an explicitly identified diagnostic norm,
// not a replacement for the solver's dimensioned Riesz residual norm.
mfem::DenseMatrix orthogonal(basis);
mfem::Vector excluded(force), column(basis.Height());
for (int mode = 0; mode < basis.Width(); ++mode) {
for (int row = 0; row < basis.Height(); ++row)
column(row) = orthogonal(row, mode);
for (int pass = 0; pass < 2; ++pass)
for (int previous = 0; previous < mode; ++previous) {
long double projection = 0.;
for (int row = 0; row < basis.Height(); ++row)
projection += column(row) * orthogonal(row, previous);
for (int row = 0; row < basis.Height(); ++row)
column(row) -= projection * orthogonal(row, previous);
}
const double norm = column.Norml2();
if (!(norm > 0.) || !std::isfinite(norm))
throw std::runtime_error("Unresolved harmonic observer basis.");
column /= norm;
excluded.Add(-(column * force), column);
for (int row = 0; row < basis.Height(); ++row)
orthogonal(row, mode) = column(row);
}
const auto file = [&](const std::string &name) {
std::ofstream stream(output / name);
stream.exceptions(std::ios::badbit | std::ios::failbit);
stream << std::setprecision(17);
return stream;
};
auto modes = file(label + "_surface_coefficients.csv");
modes << "index,degree,order,radial_length_coefficient\n";
for (int mode = 0; mode < basis.Width(); ++mode)
modes << mode << ',' << surface.modes()[mode].degree << ',' << surface.modes()[mode].order << ','
<< coefficients(mode) << '\n';
auto forces = file(label + "_surface_nodal_force.csv");
forces << "node,radial_displacement,force,excluded_force\n";
for (int node = 0; node < basis.Height(); ++node)
forces << node << ',' << radial(node) << ',' << force(node) << ',' << excluded(node) << '\n';
auto metrics = file(label + "_surface_audit.csv");
metrics << "metric,value\nparameter_count," << basis.Width() << "\nnodal_count," << basis.Height()
<< "\nfull_nodal_force_euclidean_norm," << force.Norml2() << "\nexcluded_nodal_force_euclidean_norm,"
<< excluded.Norml2() << "\nmodal_force_euclidean_norm," << retained.Norml2()
<< "\nvolume_equivalence_error," << volume.Norml2() << "\nforce_pullback_equivalence_error,"
<< native.Norml2() << '\n';
if (label == "initial") {
auto savedBasis = file("surface_basis.csv");
for (int node = 0; node < basis.Height(); ++node) {
for (int mode = 0; mode < basis.Width(); ++mode)
savedBasis << (mode ? "," : "") << basis(node, mode);
savedBasis << '\n';
}
}
}
} // namespace experiment::plateau_diagnostics

View File

@@ -0,0 +1,381 @@
module;
#include <algorithm>
#include <cmath>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <utility>
#include <vector>
#include <mfem.hpp>
#include <mpi.h>
module mean_field;
import :deformation.spherical_harmonic_surface;
namespace mean_field::deformation {
namespace {
[[nodiscard]] SurfaceDeformationDescriptor harmonicDescriptor() noexcept {
return {
.name = "SphericalHarmonicSurface",
.spatialDimension = 3,
.motionKind = SurfaceMotionKind::Radial,
.linearOnReferenceGeometry = true,
.requiresStarShapedReferenceSurface = true,
.hasExactDerivativeTranspose = true,
.hasExactPullbackDerivative = true,
// A spectral cutoff alone is not a proof that either physical
// gauge is removed; retain the conservative nodal descriptor.
.translationTreatment = GeometricGaugeTreatment::Retained,
.orientationTreatment = GeometricGaugeTreatment::Retained
};
}
[[nodiscard]] long long countModes(const SphericalHarmonicSurfaceOptions &options) noexcept {
const long long degree = options.maximumDegree;
const long long order = options.maximumAzimuthalOrder.value_or(options.maximumDegree);
return (order + 1) * (order + 1) + (degree - order) * (2 * order + 1);
}
[[nodiscard]] mfem::Vector zeroCenter() {
mfem::Vector center(3);
center = 0.;
return center;
}
void requireFinite(
const mfem::Vector &values,
const char *message
) {
for (int i = 0; i < values.Size(); ++i)
if (!std::isfinite(values(i)))
throw std::invalid_argument(message);
}
[[nodiscard]] double checkedDouble(long double value) {
const double result = static_cast<double>(value);
if (!std::isfinite(result))
throw std::invalid_argument("Spherical harmonic surface action produced a nonfinite value.");
return result;
}
// Recurrence on normalized associated Legendre functions avoids the
// factorial growth of an unnormalized polynomial followed by scaling.
[[nodiscard]] std::vector<double> evaluateModes(
const double x,
const double y,
const double z,
const int maximumDegree,
const int maximumOrder,
const std::vector<SphericalHarmonicMode> &modes
) {
const std::size_t stride = static_cast<std::size_t>(maximumOrder) + 1;
std::vector<double> legendre((static_cast<std::size_t>(maximumDegree) + 1) * stride, 0.);
const auto p = [&](int l, int m) -> double & { return legendre[static_cast<std::size_t>(l) * stride + m]; };
const double azimuth = std::atan2(y, x), cylindricalRadius = std::hypot(x, y);
p(0, 0) = 1. / std::sqrt(4. * std::numbers::pi);
for (int m = 0; m <= maximumOrder; ++m) {
if (m > 0)
p(m, m) = -std::sqrt((2. * m + 1.) / (2. * m)) * cylindricalRadius * p(m - 1, m - 1);
if (m < maximumDegree)
p(m + 1, m) = std::sqrt(2. * m + 3.) * z * p(m, m);
for (int l = m + 2; l <= maximumDegree; ++l) {
const double ll = l, mm = m;
const double a = std::sqrt((4. * ll * ll - 1.) / (ll * ll - mm * mm));
const double b = std::sqrt(
((2. * ll + 1.) * ((ll - 1.) * (ll - 1.) - mm * mm)) / ((2. * ll - 3.) * (ll * ll - mm * mm))
);
p(l, m) = a * z * p(l - 1, m) - b * p(l - 2, m);
}
}
std::vector<double> values;
values.reserve(modes.size());
for (const auto mode : modes) {
double value = p(mode.degree, std::abs(mode.order));
if (mode.order < 0)
value *= std::sqrt(2.) * std::sin(-mode.order * azimuth);
if (mode.order > 0)
value *= std::sqrt(2.) * std::cos(mode.order * azimuth);
if (!std::isfinite(value))
throw std::invalid_argument("Nonfinite spherical harmonic reference basis.");
values.push_back(value);
}
return values;
}
[[nodiscard]] long double columnDot(
const mfem::DenseMatrix &matrix,
int first,
int second
) {
long double result = 0.;
for (int row = 0; row < matrix.Height(); ++row)
result += static_cast<long double>(matrix(row, first)) * matrix(row, second);
return result;
}
void requireIndependentModes(const mfem::DenseMatrix &basis) {
mfem::DenseMatrix orthogonal(basis);
// This detects unresolved/aliased nodal modes without changing
// their physical normalization or silently dropping coordinates.
constexpr double minimumRelativePivot = 1e-12;
for (int column = 0; column < basis.Width(); ++column) {
const double original = std::sqrt(checkedDouble(columnDot(basis, column, column)));
for (int pass = 0; pass < 2; ++pass)
for (int previous = 0; previous < column; ++previous) {
const double projection = checkedDouble(columnDot(orthogonal, previous, column));
for (int row = 0; row < basis.Height(); ++row)
orthogonal(row, column) -= projection * orthogonal(row, previous);
}
const double pivot = std::sqrt(checkedDouble(columnDot(orthogonal, column, column)));
if (!(original > 0.) || !std::isfinite(pivot) || !(pivot > minimumRelativePivot * original))
throw std::invalid_argument(
"Selected spherical harmonics are numerically dependent on the surface nodes; "
"reduce their degree/order or refine the surface discretization."
);
for (int row = 0; row < basis.Height(); ++row)
orthogonal(row, column) /= pivot;
}
}
} // namespace
void SphericalHarmonicSurfaceOptions::validate() const {
if (maximumDegree < 0 ||
(maximumAzimuthalOrder && (*maximumAzimuthalOrder < 0 || *maximumAzimuthalOrder > maximumDegree)))
throw std::invalid_argument(
"Spherical harmonic degree must be nonnegative and the azimuthal cutoff must lie in [0,L]."
);
if (countModes(*this) > std::numeric_limits<int>::max())
throw std::invalid_argument("The spherical harmonic mode count exceeds supported vector indexing.");
}
SphericalHarmonicSurface::SphericalHarmonicSurface(SphericalHarmonicSurfaceOptions options)
: SphericalHarmonicSurface(
zeroCenter(),
std::move(options)
) {
}
SphericalHarmonicSurface::SphericalHarmonicSurface(
mfem::Vector referenceCenter,
SphericalHarmonicSurfaceOptions options
)
: m_referenceCenter(std::move(referenceCenter)),
m_options(std::move(options)) {
validate();
}
const mfem::Vector &SphericalHarmonicSurface::referenceCenter() const noexcept {
return m_referenceCenter;
}
const SphericalHarmonicSurfaceOptions &SphericalHarmonicSurface::options() const noexcept {
return m_options;
}
int SphericalHarmonicSurface::modeCount() const noexcept {
return static_cast<int>(countModes(m_options));
}
SurfaceDeformationDescriptor SphericalHarmonicSurface::descriptor() const noexcept {
return harmonicDescriptor();
}
void SphericalHarmonicSurface::validate() const {
m_options.validate();
if (m_referenceCenter.Size() != 3)
throw std::invalid_argument("SphericalHarmonicSurface requires a three-dimensional reference center.");
requireFinite(m_referenceCenter, "Spherical harmonic reference-center coordinates must be finite.");
}
PreparedSphericalHarmonicSurface::PreparedSphericalHarmonicSurface(
PreparedNodalRadialSurface radialSurface,
std::vector<SphericalHarmonicMode> modes,
mfem::DenseMatrix nodalBasis
)
: m_radialSurface(std::move(radialSurface)),
m_modes(std::move(modes)),
m_nodalBasis(std::move(nodalBasis)) {
}
SurfaceDeformationDescriptor PreparedSphericalHarmonicSurface::descriptor() const noexcept {
return harmonicDescriptor();
}
int PreparedSphericalHarmonicSurface::parameterCount() const noexcept {
return static_cast<int>(m_modes.size());
}
long long PreparedSphericalHarmonicSurface::globalParameterCount() const noexcept {
return parameterCount();
}
long long PreparedSphericalHarmonicSurface::globalParameterOffset() const noexcept {
return 0;
}
int PreparedSphericalHarmonicSurface::spatialDimension() const noexcept {
return 3;
}
int PreparedSphericalHarmonicSurface::surfaceNodeCount() const noexcept {
return m_radialSurface.parameterCount();
}
int PreparedSphericalHarmonicSurface::surfaceDisplacementSize() const noexcept {
return m_radialSurface.surfaceDisplacementSize();
}
long long PreparedSphericalHarmonicSurface::globalSurfaceDisplacementSize() const noexcept {
return m_radialSurface.globalSurfaceDisplacementSize();
}
long long PreparedSphericalHarmonicSurface::globalSurfaceDisplacementOffset() const noexcept {
return m_radialSurface.globalSurfaceDisplacementOffset();
}
int PreparedSphericalHarmonicSurface::surfaceDisplacementDof(
int node,
int component
) const {
return m_radialSurface.surfaceDisplacementDof(node, component);
}
double PreparedSphericalHarmonicSurface::radialDirection(
int node,
int component
) const {
return m_radialSurface.radialDirection(node, component);
}
double PreparedSphericalHarmonicSurface::referenceRadius(int node) const {
return m_radialSurface.referenceRadius(node);
}
const mfem::Vector &PreparedSphericalHarmonicSurface::referenceCenter() const noexcept {
return m_radialSurface.referenceCenter();
}
const field::ScalarBoundaryDofMap &PreparedSphericalHarmonicSurface::surfaceDofMap() const noexcept {
return m_radialSurface.surfaceDofMap();
}
std::span<const SphericalHarmonicMode> PreparedSphericalHarmonicSurface::modes() const noexcept {
return m_modes;
}
const mfem::DenseMatrix &PreparedSphericalHarmonicSurface::nodalBasis() const noexcept {
return m_nodalBasis;
}
void PreparedSphericalHarmonicSurface::requireParameters(const mfem::Vector &parameters) const {
if (parameters.Size() != parameterCount())
throw std::invalid_argument("Spherical harmonic parameter vector has an incompatible size.");
requireFinite(parameters, "Spherical harmonic parameters and directions must be finite.");
}
void PreparedSphericalHarmonicSurface::requireSurfaceSize(const mfem::Vector &surfaceDisplacement) const {
if (surfaceDisplacement.Size() != surfaceDisplacementSize())
throw std::invalid_argument("Spherical harmonic nodal surface vector has an incompatible size.");
}
void PreparedSphericalHarmonicSurface::applyEmbedding(
const mfem::Vector &parameters,
mfem::Vector &surfaceDisplacement
) const {
for (int node = 0; node < surfaceNodeCount(); ++node) {
long double amplitude = 0.;
for (int mode = 0; mode < parameterCount(); ++mode)
amplitude += static_cast<long double>(m_nodalBasis(node, mode)) * parameters(mode);
const double radialAmplitude = checkedDouble(amplitude);
for (int component = 0; component < 3; ++component)
surfaceDisplacement(surfaceDisplacementDof(node, component)) =
checkedDouble(static_cast<long double>(radialAmplitude) * radialDirection(node, component));
}
}
void PreparedSphericalHarmonicSurface::buildSurfaceDisplacement(
const mfem::Vector &parameters,
mfem::Vector &surfaceDisplacement
) const {
requireParameters(parameters);
requireSurfaceSize(surfaceDisplacement);
applyEmbedding(parameters, surfaceDisplacement);
}
void PreparedSphericalHarmonicSurface::applyJacobian(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
mfem::Vector &surfaceDisplacementDirection
) const {
requireParameters(parameters);
requireParameters(parameterDirection);
requireSurfaceSize(surfaceDisplacementDirection);
applyEmbedding(parameterDirection, surfaceDisplacementDirection);
}
void PreparedSphericalHarmonicSurface::applyJacobianTranspose(
const mfem::Vector &parameters,
const mfem::Vector &surfaceDisplacementDual,
mfem::Vector &parameterDual
) const {
requireParameters(parameters);
requireSurfaceSize(surfaceDisplacementDual);
requireFinite(surfaceDisplacementDual, "Spherical harmonic surface dual must be finite.");
if (parameterDual.Size() != parameterCount())
throw std::invalid_argument("Spherical harmonic parameter dual has an incompatible size.");
for (int mode = 0; mode < parameterCount(); ++mode) {
long double work = 0.;
for (int node = 0; node < surfaceNodeCount(); ++node) {
long double radialWork = 0.;
for (int component = 0; component < 3; ++component)
radialWork += static_cast<long double>(radialDirection(node, component)) *
surfaceDisplacementDual(surfaceDisplacementDof(node, component));
work += static_cast<long double>(m_nodalBasis(node, mode)) * radialWork;
}
parameterDual(mode) = checkedDouble(work);
}
}
void PreparedSphericalHarmonicSurface::applyPullbackDerivative(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
const mfem::Vector &surfaceDisplacementDual,
mfem::Vector &parameterDualAction
) const {
requireParameters(parameters);
requireParameters(parameterDirection);
requireSurfaceSize(surfaceDisplacementDual);
requireFinite(surfaceDisplacementDual, "Spherical harmonic surface dual must be finite.");
if (parameterDualAction.Size() != parameterCount())
throw std::invalid_argument("Spherical harmonic pullback action has an incompatible size.");
// The basis is compiled on the reference surface and never reweighted
// as the star moves, so the derivative of its transpose is exactly zero.
parameterDualAction = 0.;
}
PreparedSphericalHarmonicSurface compileSurfaceDeformationPrescription(
const SphericalHarmonicSurface &prescription,
const SurfaceDeformationCompilationContext &context
) {
int ranks = 0;
if (MPI_Comm_size(context.scalarFiniteElementSpace().GetComm(), &ranks) != MPI_SUCCESS)
throw std::runtime_error("Could not inspect the spherical harmonic surface communicator.");
// A replicated K-vector would be double-counted by distributed Newton
// inner products. Reject it until modal ownership is implemented.
if (ranks != 1)
throw std::invalid_argument(
"Spherical harmonic surface compilation currently supports one MPI rank; distributed modal ownership "
"is not implemented."
);
prescription.validate();
auto radial =
compileSurfaceDeformationPrescription(NodalRadialSurface{prescription.referenceCenter()}, context);
if (radial.spatialDimension() != 3)
throw std::invalid_argument("Spherical harmonics require a three-dimensional reference mesh.");
if (prescription.modeCount() > radial.parameterCount())
throw std::invalid_argument("Selected spherical harmonic mode count exceeds the number of surface nodes.");
const int degree = prescription.options().maximumDegree;
const int cutoff = prescription.options().maximumAzimuthalOrder.value_or(degree);
std::vector<SphericalHarmonicMode> modes;
modes.reserve(prescription.modeCount());
for (int l = 0; l <= degree; ++l)
for (int m = -std::min(l, cutoff); m <= std::min(l, cutoff); ++m)
modes.push_back({l, m});
mfem::DenseMatrix basis(radial.parameterCount(), prescription.modeCount());
for (int node = 0; node < radial.parameterCount(); ++node) {
const auto values = evaluateModes(
radial.radialDirection(node, 0), radial.radialDirection(node, 1), radial.radialDirection(node, 2),
degree, cutoff, modes
);
for (int mode = 0; mode < prescription.modeCount(); ++mode)
basis(node, mode) = values[mode];
}
requireIndependentModes(basis);
return PreparedSphericalHarmonicSurface(std::move(radial), std::move(modes), std::move(basis));
}
} // namespace mean_field::deformation

View File

@@ -0,0 +1,89 @@
module;
#include <cmath>
#include <memory>
#include <stdexcept>
#include <mfem.hpp>
#include <mpi.h>
module mean_field;
import :deformation.surface_metric;
import :field.mfem;
import :utils.domain;
namespace mean_field::deformation {
// Parameter coordinates need their own reference metric: a modal
// coefficient is not the value at a boundary node. This assembles the
// diagonal of J_surface^T (M_boundary tensor I) J_surface, without using
// a physical prepared state or the nonphysical volume extension.
mfem::Vector assembleSurfaceParameterMassDiagonal(
const fem::FEM &finiteElements,
const PreparedDomainDeformationRuntime &deformation
) {
if (!finiteElements.okay() || !deformation.matchesCurrentDiscretization() ||
!deformation.supportsSurfaceParameterMetric()) {
throw std::invalid_argument("A surface parameter metric requires a current reference surface tangent.");
}
const auto dependencies = deformation.discretizationDependencies();
if (dependencies.physicalMeshIdentity != finiteElements.mesh.get() ||
dependencies.surfaceScalarSpaceIdentity != finiteElements.surfaceDeformationFes.get() ||
dependencies.volumeDisplacementSpaceIdentity != finiteElements.displacementFes.get()) {
throw std::invalid_argument("The surface parameter metric and deformation must use the same FEM.");
}
int ranks = 0;
MPI_Comm_size(finiteElements.mesh->GetComm(), &ranks);
if (ranks != 1) {
throw std::invalid_argument("Surface parameter metric assembly currently requires one MPI rank.");
}
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const auto surfaceMap =
field::make_stellar_surface_scalar_dof_map<Schema>(*finiteElements.surfaceDeformationFes);
const int dimension = deformation.descriptor().surfaceDeformation.spatialDimension;
if (deformation.surfaceDisplacementSize() != dimension * surfaceMap.local_size()) {
throw std::invalid_argument("The surface tangent and scalar boundary map have incompatible traces.");
}
constexpr int attribute = Schema::template boundary_attribute<utils::domain::StellarSurface>();
mfem::Array<int> marker(finiteElements.mesh->bdr_attributes.Max());
if (attribute <= 0 || attribute > marker.Size()) {
throw std::invalid_argument("The surface parameter metric requires the stellar boundary attribute.");
}
marker = 0;
marker[attribute - 1] = 1;
mfem::ParBilinearForm mass(finiteElements.surfaceDeformationFes.get());
mass.AddBoundaryIntegrator(new mfem::MassIntegrator(), marker);
mass.Assemble();
mass.Finalize();
std::unique_ptr<mfem::HypreParMatrix> matrix(mass.ParallelAssemble());
if (!matrix)
throw std::runtime_error("The reference surface mass failed to assemble.");
const int count = deformation.parameterCount();
mfem::Vector parameters(count), direction(count), diagonal(count);
mfem::Vector trace(deformation.surfaceDisplacementSize());
mfem::Vector scalarTrace(surfaceMap.local_size());
mfem::Vector ambient(surfaceMap.volume_true_dof_size()), action(ambient.Size());
parameters = 0.0;
for (int parameter = 0; parameter < count; ++parameter) {
direction = 0.0;
direction(parameter) = 1.0;
deformation.applySurfaceJacobian(parameters, direction, trace);
double entry = 0.0;
for (int component = 0; component < dimension; ++component) {
for (int node = 0; node < scalarTrace.Size(); ++node) {
scalarTrace(node) = trace(dimension * node + component);
}
ambient = 0.0;
surfaceMap.scatter(scalarTrace, ambient);
matrix->Mult(ambient, action);
entry += ambient * action;
}
if (!std::isfinite(entry) || entry <= 0.0) {
throw std::runtime_error("A surface parameter has a nonpositive or nonfinite reference metric.");
}
diagonal(parameter) = entry;
}
return diagonal;
}
} // namespace mean_field::deformation

View File

@@ -172,6 +172,51 @@ export namespace mean_field::deformation {
return m_surface.parameterCount(); return m_surface.parameterCount();
} }
[[nodiscard]] bool hasNodalRadialParameters() const noexcept {
if constexpr (requires { PreparedSurface::hasNodalRadialParameters; }) {
return PreparedSurface::hasNodalRadialParameters;
}
return false;
}
[[nodiscard]] bool supportsSurfaceParameterMetric() const noexcept {
if constexpr (requires { PreparedSurface::hasCanonicalNodalVectorTrace; }) {
return PreparedSurface::hasCanonicalNodalVectorTrace;
}
return false;
}
// Reference surface metrics need the prescription tangent before any
// physical state has been prepared. Exposing that tangent avoids
// making normalization depend on the arbitrary interior mesh lift.
void applySurfaceJacobian(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
mfem::Vector &surfaceDirection
) const {
requireCurrentDiscretization();
requireParameterSize(parameters);
requireParameterSize(parameterDirection);
if (surfaceDirection.Size() != surfaceDisplacementSize()) {
throw std::invalid_argument("The surface tangent output has an incompatible trace size.");
}
m_surface.applyJacobian(parameters, parameterDirection, surfaceDirection);
}
void applySurfaceJacobianTranspose(
const mfem::Vector &parameters,
const mfem::Vector &surfaceDual,
mfem::Vector &parameterDual
) const {
requireCurrentDiscretization();
requireParameterSize(parameters);
requireParameterSize(parameterDual);
if (surfaceDual.Size() != surfaceDisplacementSize()) {
throw std::invalid_argument("The surface transpose input has an incompatible trace size.");
}
m_surface.applyJacobianTranspose(parameters, surfaceDual, parameterDual);
}
[[nodiscard]] int surfaceDisplacementSize() const noexcept { [[nodiscard]] int surfaceDisplacementSize() const noexcept {
return m_surface.surfaceDisplacementSize(); return m_surface.surfaceDisplacementSize();
} }
@@ -580,6 +625,30 @@ export namespace mean_field::deformation {
return m_implementation->parameterCount(); return m_implementation->parameterCount();
} }
[[nodiscard]] bool hasNodalRadialParameters() const noexcept {
return m_implementation->hasNodalRadialParameters();
}
[[nodiscard]] bool supportsSurfaceParameterMetric() const noexcept {
return m_implementation->supportsSurfaceParameterMetric();
}
void applySurfaceJacobian(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
mfem::Vector &surfaceDirection
) const {
m_implementation->applySurfaceJacobian(parameters, parameterDirection, surfaceDirection);
}
void applySurfaceJacobianTranspose(
const mfem::Vector &parameters,
const mfem::Vector &surfaceDual,
mfem::Vector &parameterDual
) const {
m_implementation->applySurfaceJacobianTranspose(parameters, surfaceDual, parameterDual);
}
[[nodiscard]] int surfaceDisplacementSize() const noexcept { [[nodiscard]] int surfaceDisplacementSize() const noexcept {
return m_implementation->surfaceDisplacementSize(); return m_implementation->surfaceDisplacementSize();
} }
@@ -654,10 +723,22 @@ export namespace mean_field::deformation {
private: private:
class Interface { class Interface {
public: public:
virtual ~Interface() = default; virtual ~Interface() = default;
[[nodiscard]] virtual DomainDeformationDescriptor descriptor() const noexcept = 0; [[nodiscard]] virtual DomainDeformationDescriptor descriptor() const noexcept = 0;
[[nodiscard]] virtual int parameterCount() const noexcept = 0; [[nodiscard]] virtual int parameterCount() const noexcept = 0;
[[nodiscard]] virtual bool hasNodalRadialParameters() const noexcept = 0;
[[nodiscard]] virtual bool supportsSurfaceParameterMetric() const noexcept = 0;
virtual void applySurfaceJacobian(
const mfem::Vector &,
const mfem::Vector &,
mfem::Vector &
) const = 0;
virtual void applySurfaceJacobianTranspose(
const mfem::Vector &,
const mfem::Vector &,
mfem::Vector &
) const = 0;
[[nodiscard]] virtual int surfaceDisplacementSize() const noexcept = 0; [[nodiscard]] virtual int surfaceDisplacementSize() const noexcept = 0;
[[nodiscard]] virtual int volumeDisplacementSize() const noexcept = 0; [[nodiscard]] virtual int volumeDisplacementSize() const noexcept = 0;
[[nodiscard]] virtual bool matchesCurrentDiscretization() const noexcept = 0; [[nodiscard]] virtual bool matchesCurrentDiscretization() const noexcept = 0;
@@ -705,6 +786,46 @@ export namespace mean_field::deformation {
[[nodiscard]] int parameterCount() const noexcept override { [[nodiscard]] int parameterCount() const noexcept override {
return m_preparedDeformation.parameterCount(); return m_preparedDeformation.parameterCount();
} }
[[nodiscard]] bool hasNodalRadialParameters() const noexcept override {
if constexpr (requires { m_preparedDeformation.hasNodalRadialParameters(); }) {
return m_preparedDeformation.hasNodalRadialParameters();
}
return false;
}
[[nodiscard]] bool supportsSurfaceParameterMetric() const noexcept override {
if constexpr (requires(const PreparedDeformation &prepared, const mfem::Vector &x, mfem::Vector &y) {
{ prepared.supportsSurfaceParameterMetric() } noexcept -> std::same_as<bool>;
{ prepared.applySurfaceJacobian(x, x, y) } -> std::same_as<void>;
{ prepared.applySurfaceJacobianTranspose(x, x, y) } -> std::same_as<void>;
}) {
return m_preparedDeformation.supportsSurfaceParameterMetric();
}
return false;
}
void applySurfaceJacobian(
const mfem::Vector &parameters,
const mfem::Vector &direction,
mfem::Vector &trace
) const override {
if constexpr (requires { m_preparedDeformation.applySurfaceJacobian(parameters, direction, trace); }) {
m_preparedDeformation.applySurfaceJacobian(parameters, direction, trace);
} else {
throw std::logic_error("This domain deformation does not expose its surface tangent.");
}
}
void applySurfaceJacobianTranspose(
const mfem::Vector &parameters,
const mfem::Vector &trace,
mfem::Vector &dual
) const override {
if constexpr (requires {
m_preparedDeformation.applySurfaceJacobianTranspose(parameters, trace, dual);
}) {
m_preparedDeformation.applySurfaceJacobianTranspose(parameters, trace, dual);
} else {
throw std::logic_error("This domain deformation does not expose its surface transpose.");
}
}
[[nodiscard]] int surfaceDisplacementSize() const noexcept override { [[nodiscard]] int surfaceDisplacementSize() const noexcept override {
return m_preparedDeformation.surfaceDisplacementSize(); return m_preparedDeformation.surfaceDisplacementSize();
} }

View File

@@ -44,6 +44,9 @@ export namespace mean_field::deformation {
class PreparedNodalRadialSurface final { class PreparedNodalRadialSurface final {
public: public:
static constexpr bool hasNodalRadialParameters = true;
// Trace entries follow dimension * canonical boundary-node index + component.
static constexpr bool hasCanonicalNodalVectorTrace = true;
[[nodiscard]] SurfaceDeformationDescriptor descriptor() const noexcept; [[nodiscard]] SurfaceDeformationDescriptor descriptor() const noexcept;
[[nodiscard]] int parameterCount() const noexcept; [[nodiscard]] int parameterCount() const noexcept;

View File

@@ -0,0 +1,140 @@
module;
#include <optional>
#include <span>
#include <vector>
#include <mfem/linalg/densemat.hpp>
export module mean_field:deformation.spherical_harmonic_surface;
export import :deformation.nodal_radial_surface;
export namespace mean_field::deformation {
struct SphericalHarmonicSurfaceOptions final {
int maximumDegree{4};
// A cutoff retains both real sine/cosine partners. Zero restricts the
// shape to axisymmetry; an omitted cutoff retains every order.
std::optional<int> maximumAzimuthalOrder{};
void validate() const;
};
struct SphericalHarmonicMode final {
int degree;
int order;
constexpr bool operator==(const SphericalHarmonicMode &) const = default;
};
class PreparedSphericalHarmonicSurface;
class SphericalHarmonicSurface final {
public:
using PreparedType = PreparedSphericalHarmonicSurface;
explicit SphericalHarmonicSurface(SphericalHarmonicSurfaceOptions options = {});
explicit SphericalHarmonicSurface(
mfem::Vector referenceCenter,
SphericalHarmonicSurfaceOptions options = {}
);
[[nodiscard]] const mfem::Vector &referenceCenter() const noexcept;
[[nodiscard]] const SphericalHarmonicSurfaceOptions &options() const noexcept;
[[nodiscard]] int modeCount() const noexcept;
[[nodiscard]] SurfaceDeformationDescriptor descriptor() const noexcept;
void validate() const;
private:
mfem::Vector m_referenceCenter;
SphericalHarmonicSurfaceOptions m_options;
};
// Coefficients have units of radial length. The fixed reference functions
// satisfy integral(Y_lm^2 dOmega)=1, with Condon--Shortley phase, negative
// orders denoting sine and positive orders cosine. FE interpolation of
// their nodal samples need not retain continuous spherical orthogonality.
class PreparedSphericalHarmonicSurface final {
public:
static constexpr bool hasNodalRadialParameters = false;
// Trace entries follow dimension * canonical boundary-node index + component.
static constexpr bool hasCanonicalNodalVectorTrace = true;
[[nodiscard]] SurfaceDeformationDescriptor descriptor() const noexcept;
[[nodiscard]] int parameterCount() const noexcept;
[[nodiscard]] long long globalParameterCount() const noexcept;
[[nodiscard]] long long globalParameterOffset() const noexcept;
[[nodiscard]] int spatialDimension() const noexcept;
[[nodiscard]] int surfaceNodeCount() const noexcept;
[[nodiscard]] int surfaceDisplacementSize() const noexcept;
[[nodiscard]] long long globalSurfaceDisplacementSize() const noexcept;
[[nodiscard]] long long globalSurfaceDisplacementOffset() const noexcept;
[[nodiscard]] int surfaceDisplacementDof(
int surfaceNode,
int component
) const;
[[nodiscard]] double radialDirection(
int surfaceNode,
int component
) const;
[[nodiscard]] double referenceRadius(int surfaceNode) const;
[[nodiscard]] const mfem::Vector &referenceCenter() const noexcept;
[[nodiscard]] const field::ScalarBoundaryDofMap &surfaceDofMap() const noexcept;
[[nodiscard]] std::span<const SphericalHarmonicMode> modes() const noexcept;
[[nodiscard]] const mfem::DenseMatrix &nodalBasis() const noexcept;
void buildSurfaceDisplacement(
const mfem::Vector &parameters,
mfem::Vector &surfaceDisplacement
) const;
void applyJacobian(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
mfem::Vector &surfaceDisplacementDirection
) const;
void applyJacobianTranspose(
const mfem::Vector &parameters,
const mfem::Vector &surfaceDisplacementDual,
mfem::Vector &parameterDual
) const;
void applyPullbackDerivative(
const mfem::Vector &parameters,
const mfem::Vector &parameterDirection,
const mfem::Vector &surfaceDisplacementDual,
mfem::Vector &parameterDualAction
) const;
private:
friend PreparedSphericalHarmonicSurface compileSurfaceDeformationPrescription(
const SphericalHarmonicSurface &,
const SurfaceDeformationCompilationContext &
);
PreparedSphericalHarmonicSurface(
PreparedNodalRadialSurface radialSurface,
std::vector<SphericalHarmonicMode> modes,
mfem::DenseMatrix nodalBasis
);
void requireParameters(const mfem::Vector &parameters) const;
void requireSurfaceSize(const mfem::Vector &surfaceDisplacement) const;
void applyEmbedding(
const mfem::Vector &parameters,
mfem::Vector &surfaceDisplacement
) const;
PreparedNodalRadialSurface m_radialSurface;
std::vector<SphericalHarmonicMode> m_modes;
mfem::DenseMatrix m_nodalBasis;
};
[[nodiscard]] PreparedSphericalHarmonicSurface compileSurfaceDeformationPrescription(
const SphericalHarmonicSurface &prescription,
const SurfaceDeformationCompilationContext &context
);
static_assert(SurfaceDeformationPrescription<SphericalHarmonicSurface>);
static_assert(PreparedSurfaceDeformationPrescription<PreparedSphericalHarmonicSurface>);
static_assert(SurfaceDeformationCompilable<
SphericalHarmonicSurface,
SurfaceDeformationCompilationContext>);
} // namespace mean_field::deformation

View File

@@ -0,0 +1,18 @@
module;
#include <mfem/linalg/vector.hpp>
export module mean_field:deformation.surface_metric;
export import :deformation.domain_deformation;
export import :fem;
export namespace mean_field::deformation {
// A reference parameter metric depends on the surface tangent, not the
// interior mesh lift. Assembly is out of line to keep MFEM quadrature
// implementation headers out of every normalization/preconditioner user.
[[nodiscard]] mfem::Vector assembleSurfaceParameterMassDiagonal(
const fem::FEM &finiteElements,
const PreparedDomainDeformationRuntime &deformation
);
} // namespace mean_field::deformation

View File

@@ -6,10 +6,12 @@ module;
#include <type_traits> #include <type_traits>
#include <utility> #include <utility>
#include <mfem/linalg/vector.hpp>
#include <mpi.h> #include <mpi.h>
export module mean_field:equilibrium.stellar_discretization; export module mean_field:equilibrium.stellar_discretization;
export import :deformation.domain_deformation;
export import :fem; export import :fem;
export import :mapping.domain_mapper; export import :mapping.domain_mapper;
export import :normalization.physical_riesz; export import :normalization.physical_riesz;
@@ -26,14 +28,40 @@ export namespace mean_field::equilibrium {
* references retained by prepared operators. The mapper is part of that * references retained by prepared operators. The mapper is part of that
* owned FEM and therefore has the same lifetime. * owned FEM and therefore has the same lifetime.
*/ */
template <normalization::NormalizationPrescription Normalization> class StellarDiscretizationFor final { template <
normalization::NormalizationPrescription Normalization,
deformation::SurfaceDeformationPrescription SurfaceDeformation = deformation::NodalRadialSurface,
deformation::InteriorDeformationExtension InteriorDeformation = deformation::PowerLawRadialInteriorExtension,
deformation::VacuumDeformationExtension VacuumDeformation = deformation::FixedInfinityRadialVacuumExtension>
requires deformation::SurfaceDeformationCompilable<
SurfaceDeformation,
deformation::SurfaceDeformationCompilationContext> &&
deformation::InteriorDeformationExtensionCompilable<
InteriorDeformation,
deformation::RadialDeformationExtensionCompilationContext> &&
deformation::VacuumDeformationExtensionCompilable<
VacuumDeformation,
deformation::RadialDeformationExtensionCompilationContext>
class StellarDiscretizationFor final {
public: public:
using NormalizationPrescriptionType = std::remove_cvref_t<Normalization>; using NormalizationPrescriptionType = std::remove_cvref_t<Normalization>;
using SurfaceDeformationPrescriptionType = SurfaceDeformation;
using InteriorDeformationExtensionType = InteriorDeformation;
using VacuumDeformationExtensionType = VacuumDeformation;
explicit StellarDiscretizationFor(fem::FEM &&finiteElementModel) explicit StellarDiscretizationFor(fem::FEM &&finiteElementModel)
requires std::same_as< requires std::same_as<
NormalizationPrescriptionType, NormalizationPrescriptionType,
normalization::Unnormalized> normalization::Unnormalized> &&
std::same_as<
SurfaceDeformation,
deformation::NodalRadialSurface> &&
std::same_as<
InteriorDeformation,
deformation::PowerLawRadialInteriorExtension> &&
std::same_as<
VacuumDeformation,
deformation::FixedInfinityRadialVacuumExtension>
: StellarDiscretizationFor( : StellarDiscretizationFor(
std::move(finiteElementModel), std::move(finiteElementModel),
normalization::Unnormalized{} normalization::Unnormalized{}
@@ -49,9 +77,55 @@ export namespace mean_field::equilibrium {
StellarDiscretizationFor( StellarDiscretizationFor(
fem::FEM &&finiteElementModel, fem::FEM &&finiteElementModel,
NormalizationPrescriptionType normalizationPrescription NormalizationPrescriptionType normalizationPrescription
)
requires std::same_as<
SurfaceDeformation,
deformation::NodalRadialSurface> &&
std::same_as<
InteriorDeformation,
deformation::PowerLawRadialInteriorExtension> &&
std::same_as<
VacuumDeformation,
deformation::FixedInfinityRadialVacuumExtension>
: StellarDiscretizationFor(
std::move(finiteElementModel),
std::move(normalizationPrescription),
MakeDefaultSurface(finiteElementModel),
InteriorDeformation{},
VacuumDeformation{}
) {
}
StellarDiscretizationFor(
fem::FEM &&finiteElementModel,
NormalizationPrescriptionType normalizationPrescription,
SurfaceDeformation surfaceDeformation
)
requires std::default_initializable<InteriorDeformation> && std::default_initializable<VacuumDeformation>
: StellarDiscretizationFor(
std::move(finiteElementModel),
std::move(normalizationPrescription),
std::move(surfaceDeformation),
InteriorDeformation{},
VacuumDeformation{}
) {
}
StellarDiscretizationFor(
fem::FEM &&finiteElementModel,
NormalizationPrescriptionType normalizationPrescription,
SurfaceDeformation surfaceDeformation,
InteriorDeformation interiorDeformation,
VacuumDeformation vacuumDeformation
) )
: m_finiteElementModel(TakeOwnership(std::move(finiteElementModel))), : m_finiteElementModel(TakeOwnership(std::move(finiteElementModel))),
m_normalizationPrescription(std::move(normalizationPrescription)) { m_normalizationPrescription(std::move(normalizationPrescription)),
m_surfaceDeformation(std::move(surfaceDeformation)),
m_interiorDeformation(std::move(interiorDeformation)),
m_vacuumDeformation(std::move(vacuumDeformation)) {
m_surfaceDeformation.validate();
m_interiorDeformation.validate();
m_vacuumDeformation.validate();
} }
StellarDiscretizationFor( StellarDiscretizationFor(
@@ -90,6 +164,21 @@ export namespace mean_field::equilibrium {
[[nodiscard]] const NormalizationPrescriptionType &normalizationPrescription() const && = delete; [[nodiscard]] const NormalizationPrescriptionType &normalizationPrescription() const && = delete;
[[nodiscard]] const SurfaceDeformation &surfaceDeformationPrescription() const & noexcept {
return m_surfaceDeformation;
}
[[nodiscard]] const SurfaceDeformation &surfaceDeformationPrescription() const && = delete;
[[nodiscard]] const InteriorDeformation &interiorDeformationExtension() const & noexcept {
return m_interiorDeformation;
}
[[nodiscard]] const InteriorDeformation &interiorDeformationExtension() const && = delete;
[[nodiscard]] const VacuumDeformation &vacuumDeformationExtension() const & noexcept {
return m_vacuumDeformation;
}
[[nodiscard]] const VacuumDeformation &vacuumDeformationExtension() const && = delete;
[[nodiscard]] bool isCurrent() const noexcept { [[nodiscard]] bool isCurrent() const noexcept {
return m_finiteElementModel != nullptr && m_finiteElementModel->okay(); return m_finiteElementModel != nullptr && m_finiteElementModel->okay();
} }
@@ -108,6 +197,15 @@ export namespace mean_field::equilibrium {
return *m_finiteElementModel; return *m_finiteElementModel;
} }
[[nodiscard]] static deformation::NodalRadialSurface MakeDefaultSurface(const fem::FEM &finiteElementModel) {
if (!finiteElementModel.okay()) {
throw std::invalid_argument("A default surface requires a complete finite-element model.");
}
mfem::Vector center(finiteElementModel.mesh->SpaceDimension());
center = 0.0;
return deformation::NodalRadialSurface{std::move(center)};
}
[[nodiscard]] static std::unique_ptr<fem::FEM> TakeOwnership(fem::FEM &&finiteElementModel) { [[nodiscard]] static std::unique_ptr<fem::FEM> TakeOwnership(fem::FEM &&finiteElementModel) {
if (!finiteElementModel.okay()) { if (!finiteElementModel.okay()) {
throw std::invalid_argument("A stellar discretization requires a complete finite-element model."); throw std::invalid_argument("A stellar discretization requires a complete finite-element model.");
@@ -117,6 +215,9 @@ export namespace mean_field::equilibrium {
std::unique_ptr<fem::FEM> m_finiteElementModel; std::unique_ptr<fem::FEM> m_finiteElementModel;
NormalizationPrescriptionType m_normalizationPrescription; NormalizationPrescriptionType m_normalizationPrescription;
SurfaceDeformation m_surfaceDeformation;
InteriorDeformation m_interiorDeformation;
VacuumDeformation m_vacuumDeformation;
}; };
template <normalization::NormalizationPrescription Normalization> template <normalization::NormalizationPrescription Normalization>
@@ -125,12 +226,43 @@ export namespace mean_field::equilibrium {
Normalization Normalization
) -> StellarDiscretizationFor<std::remove_cvref_t<Normalization>>; ) -> StellarDiscretizationFor<std::remove_cvref_t<Normalization>>;
template <
typename Normalization,
typename Surface>
StellarDiscretizationFor(
fem::FEM &&,
Normalization,
Surface
)
-> StellarDiscretizationFor<
std::remove_cvref_t<Normalization>,
std::remove_cvref_t<Surface>>;
template <
typename Normalization,
typename Surface,
typename Interior,
typename Vacuum>
StellarDiscretizationFor(
fem::FEM &&,
Normalization,
Surface,
Interior,
Vacuum
)
-> StellarDiscretizationFor<
std::remove_cvref_t<Normalization>,
std::remove_cvref_t<Surface>,
std::remove_cvref_t<Interior>,
std::remove_cvref_t<Vacuum>>;
using StellarDiscretization = StellarDiscretizationFor<normalization::Unnormalized>; using StellarDiscretization = StellarDiscretizationFor<normalization::Unnormalized>;
template <typename Candidate> struct IsStellarDiscretization : std::false_type { }; template <typename Candidate> struct IsStellarDiscretization : std::false_type { };
template <normalization::NormalizationPrescription Normalization> template <typename Normalization, typename Surface, typename Interior, typename Vacuum>
struct IsStellarDiscretization<StellarDiscretizationFor<Normalization>> : std::true_type { }; struct IsStellarDiscretization<StellarDiscretizationFor<Normalization, Surface, Interior, Vacuum>>
: std::true_type { };
template <typename Candidate> template <typename Candidate>
concept StellarDiscretizationType = IsStellarDiscretization<std::remove_cvref_t<Candidate>>::value; concept StellarDiscretizationType = IsStellarDiscretization<std::remove_cvref_t<Candidate>>::value;
@@ -145,6 +277,51 @@ export namespace mean_field::equilibrium {
}; };
} }
// Geometry is a numerical prescription. Keeping it on the discretization
// leaves the model's EOS, surface pressure and integral constraints intact.
template <
normalization::NormalizationPrescription Normalization,
deformation::SurfaceDeformationPrescription Surface>
requires deformation::SurfaceDeformationCompilable<
Surface,
deformation::SurfaceDeformationCompilationContext>
[[nodiscard]] auto makeStellarDiscretization(
fem::FEM &&finiteElementModel,
Normalization normalizationPrescription,
Surface surface
) {
return StellarDiscretizationFor<Normalization, Surface>{
std::move(finiteElementModel), std::move(normalizationPrescription), std::move(surface)
};
}
template <
normalization::NormalizationPrescription Normalization,
deformation::SurfaceDeformationPrescription Surface,
deformation::InteriorDeformationExtension Interior,
deformation::VacuumDeformationExtension Vacuum>
requires deformation::SurfaceDeformationCompilable<
Surface,
deformation::SurfaceDeformationCompilationContext> &&
deformation::InteriorDeformationExtensionCompilable<
Interior,
deformation::RadialDeformationExtensionCompilationContext> &&
deformation::VacuumDeformationExtensionCompilable<
Vacuum,
deformation::RadialDeformationExtensionCompilationContext>
[[nodiscard]] auto makeStellarDiscretization(
fem::FEM &&finiteElementModel,
Normalization normalizationPrescription,
Surface surface,
Interior interior,
Vacuum vacuum
) {
return StellarDiscretizationFor<Normalization, Surface, Interior, Vacuum>{
std::move(finiteElementModel), std::move(normalizationPrescription), std::move(surface),
std::move(interior), std::move(vacuum)
};
}
template <normalization::NormalizationPrescription Normalization> template <normalization::NormalizationPrescription Normalization>
StellarDiscretizationFor<std::remove_cvref_t<Normalization>> makeStellarDiscretization( StellarDiscretizationFor<std::remove_cvref_t<Normalization>> makeStellarDiscretization(
fem::FEM &, fem::FEM &,

View File

@@ -83,10 +83,12 @@ export import :material.thermodynamic_equations;
export import :deformation.descriptors; export import :deformation.descriptors;
export import :deformation.surface_prescription; export import :deformation.surface_prescription;
export import :deformation.nodal_radial_surface; export import :deformation.nodal_radial_surface;
export import :deformation.spherical_harmonic_surface;
export import :deformation.interior_extension; export import :deformation.interior_extension;
export import :deformation.vacuum_extension; export import :deformation.vacuum_extension;
export import :deformation.radial_extensions; export import :deformation.radial_extensions;
export import :deformation.domain_deformation; export import :deformation.domain_deformation;
export import :deformation.surface_metric;
export import :deformation.safe_newton_step; export import :deformation.safe_newton_step;
export import :model.stellar; export import :model.stellar;
export import :operators.root_manifest; export import :operators.root_manifest;

View File

@@ -1,10 +1,10 @@
module; module;
#include <concepts> #include <concepts>
#include <mpi.h>
#include <cstdint> #include <cstdint>
#include <expected> #include <expected>
#include <memory> #include <memory>
#include <mpi.h>
#include <span> #include <span>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@@ -15,6 +15,8 @@ module;
export module mean_field:normalization.stellar_equilibrium; export module mean_field:normalization.stellar_equilibrium;
import :deformation.surface_metric;
export import :normalization.operators; export import :normalization.operators;
export import :operators.stellar_equilibrium_compiler; export import :operators.stellar_equilibrium_compiler;
export import :operators.stellar_equilibrium_problem; export import :operators.stellar_equilibrium_problem;
@@ -23,15 +25,18 @@ export import :utils.domain;
namespace mean_field::normalization::detail { namespace mean_field::normalization::detail {
using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema; using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema;
[[nodiscard]] inline bool CanAssembleExactPartialDiagonal(mfem::ParFiniteElementSpace &space, bool hdiv) { [[nodiscard]] inline bool CanAssembleExactPartialDiagonal(
mfem::ParFiniteElementSpace &space,
bool hdiv
) {
// For hanging nodes, diag(P^T A P) contains cross terms. MFEM's PA // For hanging nodes, diag(P^T A P) contains cross terms. MFEM's PA
// diagonal is approximate there, so retain assembled exact diagonals. // diagonal is approximate there, so retain assembled exact diagonals.
int eligible = space.GetMesh()->Conforming() && !space.IsVariableOrder() && space.GetVDim() == 1; int eligible = space.GetMesh()->Conforming() && !space.IsVariableOrder() && space.GetVDim() == 1;
for (int e = 0; eligible && e < space.GetNE(); ++e) { for (int e = 0; eligible && e < space.GetNE(); ++e) {
const auto *fe = space.GetFE(e); const auto *fe = space.GetFE(e);
eligible = fe->GetGeomType() == mfem::Geometry::CUBE && eligible = fe->GetGeomType() == mfem::Geometry::CUBE &&
(hdiv ? dynamic_cast<const mfem::RT_HexahedronElement *>(fe) != nullptr : (hdiv ? dynamic_cast<const mfem::RT_HexahedronElement *>(fe) != nullptr
dynamic_cast<const mfem::NodalTensorFiniteElement *>(fe) != nullptr); : dynamic_cast<const mfem::NodalTensorFiniteElement *>(fe) != nullptr);
} }
int allEligible = 0; int allEligible = 0;
if (MPI_Allreduce(&eligible, &allEligible, 1, MPI_INT, MPI_MIN, space.GetComm()) != MPI_SUCCESS) if (MPI_Allreduce(&eligible, &allEligible, 1, MPI_INT, MPI_MIN, space.GetComm()) != MPI_SUCCESS)
@@ -40,7 +45,9 @@ namespace mean_field::normalization::detail {
} }
[[nodiscard]] inline mfem::Vector AssembleMassDiagonal( [[nodiscard]] inline mfem::Vector AssembleMassDiagonal(
mfem::ParBilinearForm &mass, mfem::ParFiniteElementSpace &space, bool partial mfem::ParBilinearForm &mass,
mfem::ParFiniteElementSpace &space,
bool partial
) { ) {
if (partial) { if (partial) {
mass.SetAssemblyLevel(mfem::AssemblyLevel::PARTIAL); mass.SetAssemblyLevel(mfem::AssemblyLevel::PARTIAL);
@@ -52,7 +59,8 @@ namespace mean_field::normalization::detail {
mass.Assemble(); mass.Assemble();
mass.Finalize(); mass.Finalize();
std::unique_ptr<mfem::HypreParMatrix> matrix(mass.ParallelAssemble()); std::unique_ptr<mfem::HypreParMatrix> matrix(mass.ParallelAssemble());
if (!matrix) throw std::runtime_error("Reference Riesz mass assembly failed."); if (!matrix)
throw std::runtime_error("Reference Riesz mass assembly failed.");
mfem::Vector diagonal; mfem::Vector diagonal;
matrix->GetDiag(diagonal); matrix->GetDiag(diagonal);
return diagonal; return diagonal;
@@ -61,7 +69,7 @@ namespace mean_field::normalization::detail {
[[nodiscard]] inline mfem::Vector AssembleScalarMassDiagonal( [[nodiscard]] inline mfem::Vector AssembleScalarMassDiagonal(
mfem::ParFiniteElementSpace &space, mfem::ParFiniteElementSpace &space,
mfem::Array<int> *domainMarker = nullptr, mfem::Array<int> *domainMarker = nullptr,
bool reference = false bool reference = false
) { ) {
mfem::ParBilinearForm mass(&space); mfem::ParBilinearForm mass(&space);
if (domainMarker == nullptr) { if (domainMarker == nullptr) {
@@ -72,20 +80,25 @@ namespace mean_field::normalization::detail {
return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, false)); return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, false));
} }
[[nodiscard]] inline mfem::Vector AssembleHDivMassDiagonal(mfem::ParFiniteElementSpace &space, bool reference = false) { [[nodiscard]] inline mfem::Vector AssembleHDivMassDiagonal(
mfem::ParFiniteElementSpace &space,
bool reference = false
) {
mfem::ParBilinearForm mass(&space); mfem::ParBilinearForm mass(&space);
mass.AddDomainIntegrator(new mfem::VectorFEMassIntegrator()); mass.AddDomainIntegrator(new mfem::VectorFEMassIntegrator());
return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, true)); return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, true));
} }
[[nodiscard]] inline bool CanAssembleDirectBoundaryDiagonal( [[nodiscard]] inline bool CanAssembleDirectBoundaryDiagonal(
mfem::ParFiniteElementSpace &space, int attribute mfem::ParFiniteElementSpace &space,
int attribute
) { ) {
int eligible = !space.Nonconforming() && !space.IsVariableOrder() && space.GetVDim() == 1 && int eligible = !space.Nonconforming() && !space.IsVariableOrder() && space.GetVDim() == 1 &&
dynamic_cast<const mfem::H1_FECollection *>(space.FEColl()) != nullptr; dynamic_cast<const mfem::H1_FECollection *>(space.FEColl()) != nullptr;
mfem::Array<int> dofs; mfem::Array<int> dofs;
for (int b = 0; eligible && b < space.GetNBE(); ++b) { for (int b = 0; eligible && b < space.GetNBE(); ++b) {
if (space.GetBdrAttribute(b) != attribute) continue; if (space.GetBdrAttribute(b) != attribute)
continue;
// A general DOF transformation would require transforming the // A general DOF transformation would require transforming the
// full element matrix before extracting its diagonal. // full element matrix before extracting its diagonal.
eligible = space.GetBdrElementVDofs(b, dofs) == nullptr; eligible = space.GetBdrElementVDofs(b, dofs) == nullptr;
@@ -97,7 +110,8 @@ namespace mean_field::normalization::detail {
} }
[[nodiscard]] inline mfem::Vector AssembleDirectBoundaryDiagonal( [[nodiscard]] inline mfem::Vector AssembleDirectBoundaryDiagonal(
mfem::ParFiniteElementSpace &space, int attribute mfem::ParFiniteElementSpace &space,
int attribute
) { ) {
mfem::Vector localDiagonal(space.GetVSize()); mfem::Vector localDiagonal(space.GetVSize());
localDiagonal = 0.0; localDiagonal = 0.0;
@@ -106,13 +120,14 @@ namespace mean_field::normalization::detail {
// GetNBE includes tagged internal interfaces. MFEM's boundary PA face // GetNBE includes tagged internal interfaces. MFEM's boundary PA face
// restriction uses FaceType::Boundary and omits the star/vacuum interface. // restriction uses FaceType::Boundary and omits the star/vacuum interface.
for (int b = 0; b < space.GetNBE(); ++b) { for (int b = 0; b < space.GetNBE(); ++b) {
if (space.GetBdrAttribute(b) != attribute) continue; if (space.GetBdrAttribute(b) != attribute)
const auto &element = *space.GetBE(b); continue;
const auto &element = *space.GetBE(b);
auto &transformation = *space.GetBdrElementTransformation(b); auto &transformation = *space.GetBdrElementTransformation(b);
space.GetBdrElementVDofs(b, dofs); space.GetBdrElementVDofs(b, dofs);
shape.SetSize(element.GetDof()); shape.SetSize(element.GetDof());
elementDiagonal.SetSize(element.GetDof()); elementDiagonal.SetSize(element.GetDof());
elementDiagonal = 0.0; elementDiagonal = 0.0;
// Identical rule and physical shapes to MassIntegrator's default // Identical rule and physical shapes to MassIntegrator's default
// element assembly; only the diagonal of w * shape * shape^T is formed. // element assembly; only the diagonal of w * shape * shape^T is formed.
const auto &rule = mfem::MassIntegrator::GetRule(element, element, transformation); const auto &rule = mfem::MassIntegrator::GetRule(element, element, transformation);
@@ -183,14 +198,25 @@ export namespace mean_field::normalization {
// full assembled true-DOF diagonal; production uses exact PA for supported // full assembled true-DOF diagonal; production uses exact PA for supported
// volume spaces and direct marked-boundary quadrature for supported surfaces. // volume spaces and direct marked-boundary quadrature for supported surfaces.
[[nodiscard]] inline mfem::Vector AssembleReferenceScalarMassDiagonal( [[nodiscard]] inline mfem::Vector AssembleReferenceScalarMassDiagonal(
mfem::ParFiniteElementSpace &space, mfem::Array<int> *domainMarker = nullptr, bool reference = false mfem::ParFiniteElementSpace &space,
) { return detail::AssembleScalarMassDiagonal(space, domainMarker, reference); } mfem::Array<int> *domainMarker = nullptr,
bool reference = false
) {
return detail::AssembleScalarMassDiagonal(space, domainMarker, reference);
}
[[nodiscard]] inline mfem::Vector AssembleReferenceHDivMassDiagonal( [[nodiscard]] inline mfem::Vector AssembleReferenceHDivMassDiagonal(
mfem::ParFiniteElementSpace &space, bool reference = false mfem::ParFiniteElementSpace &space,
) { return detail::AssembleHDivMassDiagonal(space, reference); } bool reference = false
) {
return detail::AssembleHDivMassDiagonal(space, reference);
}
[[nodiscard]] inline mfem::Vector AssembleReferenceSurfaceMassDiagonal( [[nodiscard]] inline mfem::Vector AssembleReferenceSurfaceMassDiagonal(
const fem::FEM &finiteElements, const field::ScalarBoundaryDofMap &surfaceMap, bool reference = false const fem::FEM &finiteElements,
) { return detail::AssembleSurfaceMassDiagonal(finiteElements, surfaceMap, reference); } const field::ScalarBoundaryDofMap &surfaceMap,
bool reference = false
) {
return detail::AssembleSurfaceMassDiagonal(finiteElements, surfaceMap, reference);
}
/* /*
* Runtime preparation paired with the compile-time normalization plan. * Runtime preparation paired with the compile-time normalization plan.
@@ -544,9 +570,20 @@ export namespace mean_field::normalization {
detail::AssembleScalarMassDiagonal(*finiteElements.gravityPotentialFes), detail::AssembleScalarMassDiagonal(*finiteElements.gravityPotentialFes),
gravityContext.GetGravityPotentialMap(), "gravity-potential" gravityContext.GetGravityPotentialMap(), "gravity-potential"
); );
const field::ScalarBoundaryDofMap surfaceMap = const mfem::Vector surfaceDiagonal = [&]() {
field::make_stellar_surface_scalar_dof_map<detail::DomainSchema>(*finiteElements.surfaceDeformationFes); const auto &domain = physical.GetDomainDeformation();
const mfem::Vector surfaceDiagonal = detail::AssembleSurfaceMassDiagonal(finiteElements, surfaceMap); if (domain.hasNodalRadialParameters()) {
const field::ScalarBoundaryDofMap surfaceMap =
field::make_stellar_surface_scalar_dof_map<detail::DomainSchema>(
*finiteElements.surfaceDeformationFes
);
return detail::AssembleSurfaceMassDiagonal(finiteElements, surfaceMap);
}
// Modal coefficients are not surface nodal values. Pulling back
// the reference trace metric gives one primal Gram entry per
// actual parameter; the builder supplies its dual residual scale.
return deformation::assembleSurfaceParameterMassDiagonal(finiteElements, domain);
}();
if (surfaceDiagonal.Size() != physical.GetDomainDeformation().parameterCount()) { if (surfaceDiagonal.Size() != physical.GetDomainDeformation().parameterCount()) {
throw std::logic_error("The reference surface Gram diagonal does not match the root surface block."); throw std::logic_error("The reference surface Gram diagonal does not match the root surface block.");
} }

View File

@@ -154,7 +154,10 @@ export namespace mean_field::equilibrium {
m_discretization.domainMapper(), m_discretization.domainMapper(),
m_stellarModel, m_stellarModel,
operators::PressureSurfaceConstraintView{m_compiledSurfaceConstraint}, operators::PressureSurfaceConstraintView{m_compiledSurfaceConstraint},
CompileDefaultDomainDeformation(finiteElementModel) CompileDomainDeformation(
m_discretization,
finiteElementModel
)
) { ) {
VerifyProblem(); VerifyProblem();
} }
@@ -313,19 +316,13 @@ export namespace mean_field::equilibrium {
); );
} }
[[nodiscard]] static deformation::PreparedDomainDeformationRuntime [[nodiscard]] static deformation::PreparedDomainDeformationRuntime CompileDomainDeformation(
CompileDefaultDomainDeformation(fem::FEM &finiteElementModel) { const DiscretizationType &discretization,
MFEM_VERIFY( fem::FEM &finiteElementModel
finiteElementModel.mesh != nullptr, ) {
"Default stellar domain-deformation compilation requires a physical mesh."
);
mfem::Vector referenceCenter(finiteElementModel.mesh->SpaceDimension());
referenceCenter = 0.0;
return deformation::PreparedDomainDeformationRuntime{deformation::compileDomainDeformation( return deformation::PreparedDomainDeformationRuntime{deformation::compileDomainDeformation(
deformation::NodalRadialSurface{std::move(referenceCenter)}, discretization.surfaceDeformationPrescription(), discretization.interiorDeformationExtension(),
deformation::PowerLawRadialInteriorExtension{}, deformation::FixedInfinityRadialVacuumExtension{}, discretization.vacuumDeformationExtension(), finiteElementModel
finiteElementModel
)}; )};
} }

View File

@@ -17,6 +17,8 @@ module;
export module mean_field:preconditioning.material_surface; export module mean_field:preconditioning.material_surface;
import :deformation.surface_metric;
export import :operators.prepared_stellar_equilibrium; export import :operators.prepared_stellar_equilibrium;
export import :operators.stellar_equilibrium_problem; export import :operators.stellar_equilibrium_problem;
export import :preconditioning.backend_implementations; export import :preconditioning.backend_implementations;
@@ -1440,6 +1442,14 @@ export namespace mean_field::preconditioning {
using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema; using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const fem::FEM &finiteElements = operation.GetHydrostaticOperator().GetFEM(); const fem::FEM &finiteElements = operation.GetHydrostaticOperator().GetFEM();
if (!operation.GetDomainDeformation().hasNodalRadialParameters()) {
// The inverse acts on the parameter dual, not an ambient
// nodal trace. Keep the existing diagonal approximation but
// form it in the prescription's actual coordinate space.
return deformation::assembleSurfaceParameterMassDiagonal(
finiteElements, operation.GetDomainDeformation()
);
}
MFEM_VERIFY( MFEM_VERIFY(
finiteElements.mesh != nullptr && finiteElements.surfaceDeformationFes != nullptr, finiteElements.mesh != nullptr && finiteElements.surfaceDeformationFes != nullptr,
"The material/free-surface Riesz surrogate requires the surface finite-element space." "The material/free-surface Riesz surrogate requires the surface finite-element space."
@@ -1492,10 +1502,26 @@ export namespace mean_field::preconditioning {
using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema; using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const fem::FEM &finiteElements = m_operation->GetHydrostaticOperator().GetFEM(); const fem::FEM &finiteElements = m_operation->GetHydrostaticOperator().GetFEM();
const field::ScalarBoundaryDofMap surfaceMap = const bool nodal = m_operation->GetDomainDeformation().hasNodalRadialParameters();
field::make_stellar_surface_scalar_dof_map<DomainSchema>(*finiteElements.surfaceDeformationFes); field::ScalarBoundaryDofMap surfaceMap;
if (surfaceMap.local_size() != m_surfaceDiagonal.Size()) { if (nodal) {
throw std::logic_error("Surface Riesz calibration received an incompatible boundary coordinate map."); surfaceMap =
field::make_stellar_surface_scalar_dof_map<DomainSchema>(*finiteElements.surfaceDeformationFes);
if (surfaceMap.local_size() != m_surfaceDiagonal.Size()) {
throw std::logic_error(
"Surface Riesz calibration received an incompatible boundary coordinate map."
);
}
} else {
int ranks = 0;
if (MPI_Comm_size(Communicator(*m_operation), &ranks) != MPI_SUCCESS || ranks != 1) {
throw std::invalid_argument(
"Surface parameter calibration currently requires one MPI rank for nonnodal coordinates."
);
}
if (m_surfaceDiagonal.Size() != m_operation->GetDomainDeformation().parameterCount()) {
throw std::logic_error("Surface Riesz calibration received an incompatible parameter metric.");
}
} }
mfem::Vector probe(m_surfaceDiagonal.Size()); mfem::Vector probe(m_surfaceDiagonal.Size());
@@ -1513,7 +1539,11 @@ export namespace mean_field::preconditioning {
double localDenominator = 0.0; double localDenominator = 0.0;
for (int sample = 0; sample < calibration.probeCount; ++sample) { for (int sample = 0; sample < calibration.probeCount; ++sample) {
for (int index = 0; index < probe.Size(); ++index) { for (int index = 0; index < probe.Size(); ++index) {
std::uint64_t value = static_cast<std::uint64_t>(surfaceMap.global_boundary_dof(index)); // Preserve nodal probes exactly. Nonnodal serial probes
// use the immutable ordered parameter index instead of
// pretending that each coefficient owns a boundary node.
std::uint64_t value =
static_cast<std::uint64_t>(nodal ? surfaceMap.global_boundary_dof(index) : index);
value += 0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(sample + 1); value += 0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(sample + 1);
value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL;
value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL;
@@ -1871,6 +1901,12 @@ export namespace mean_field::preconditioning {
[[nodiscard]] static field::ScalarBoundaryDofMap [[nodiscard]] static field::ScalarBoundaryDofMap
SurfaceMap(const operators::PreparedStellarEquilibriumOperator &operation) { SurfaceMap(const operators::PreparedStellarEquilibriumOperator &operation) {
if (!operation.GetDomainDeformation().hasNodalRadialParameters()) {
throw std::invalid_argument(
"SurfaceH1MassStiffness requires nodal radial parameters; a parameter-space H1 surrogate "
"has not been implemented for this surface prescription."
);
}
using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema; using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const fem::FEM &finiteElements = operation.GetHydrostaticOperator().GetFEM(); const fem::FEM &finiteElements = operation.GetHydrostaticOperator().GetFEM();
return field::make_stellar_surface_scalar_dof_map<DomainSchema>(*finiteElements.surfaceDeformationFes); return field::make_stellar_surface_scalar_dof_map<DomainSchema>(*finiteElements.surfaceDeformationFes);

View File

@@ -118,7 +118,8 @@ int main(
normalization::PhysicalRieszDiagonal{ normalization::PhysicalRieszDiagonal{
dimensions::LengthValue{radius}, dimensions::LengthValue{radius},
gravitationalConstant gravitationalConstant
} },
deformation::SphericalHarmonicSurface{deformation::SphericalHarmonicSurfaceOptions{.maximumDegree = 4}}
); );
auto preconditioner = preconditioning::makePreconditioner(); auto preconditioner = preconditioning::makePreconditioner();

View File

@@ -0,0 +1,352 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_surface_tests {
namespace deformation = mean_field::deformation;
namespace field = mean_field::field;
namespace domain = mean_field::utils::domain;
using Schema = domain::CoreEnvelopeVacuumDomainSchema;
int ranks() {
int count = 0;
MPI_Comm_size(MPI_COMM_WORLD, &count);
return count;
}
auto context(const mean_field::fem::FEM &fem) {
return deformation::SurfaceDeformationCompilationContext{
*fem.surfaceDeformationFes, field::make_stellar_surface_scalar_dof_map<Schema>(*fem.surfaceDeformationFes)
};
}
int modeIndex(
const deformation::PreparedSphericalHarmonicSurface &surface,
int degree,
int order
) {
int index = 0;
for (const auto mode : surface.modes()) {
if (mode.degree == degree && mode.order == order)
return index;
++index;
}
throw std::logic_error("Required analytic harmonic mode was not compiled.");
}
double relativeDifference(
const mfem::Vector &a,
const mfem::Vector &b
) {
REQUIRE(a.Size() == b.Size());
mfem::Vector difference(a);
difference -= b;
return difference.Norml2() / std::max(b.Norml2(), 1e-300);
}
void fill(
mfem::Vector &values,
double scale = 1.
) {
for (int i = 0; i < values.Size(); ++i)
values(i) = scale * std::sin(.37 * (i + 1));
}
} // namespace spherical_harmonic_surface_tests
TEST_CASE(
"Spherical Harmonic Surface Validates Physical Mode Selection And Type Contracts",
tags::spherical_harmonic_surface_validation
) {
namespace d = mean_field::deformation;
STATIC_CHECK(d::SurfaceDeformationPrescription<d::SphericalHarmonicSurface>);
STATIC_CHECK(d::PreparedSurfaceDeformationPrescription<d::PreparedSphericalHarmonicSurface>);
STATIC_CHECK(d::SurfaceDeformationCompilable<d::SphericalHarmonicSurface, d::SurfaceDeformationCompilationContext>);
STATIC_CHECK_FALSE(d::PreparedSphericalHarmonicSurface::hasNodalRadialParameters);
const d::SphericalHarmonicSurface full;
const d::SphericalHarmonicSurface axisymmetric(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 0}
);
const d::SphericalHarmonicSurface orderOne(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 1}
);
const d::SphericalHarmonicSurface orderTwo(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 2}
);
const d::SphericalHarmonicSurface monopole(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 0});
CHECK(full.modeCount() == 25);
CHECK(axisymmetric.modeCount() == 5);
CHECK(orderOne.modeCount() == 13);
CHECK(orderTwo.modeCount() == 19);
CHECK(monopole.modeCount() == 1);
CHECK(full.referenceCenter().Size() == 3);
CHECK(full.referenceCenter().Norml2() == 0.);
CHECK(full.descriptor().motionKind == d::SurfaceMotionKind::Radial);
CHECK(full.descriptor().linearOnReferenceGeometry);
CHECK(full.descriptor().supportsExactNewtonLinearization());
for (const auto options :
{d::SphericalHarmonicSurfaceOptions{.maximumDegree = -1},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = -1},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 5},
d::SphericalHarmonicSurfaceOptions{.maximumDegree = std::numeric_limits<int>::max()}})
CHECK_THROWS_AS(d::SphericalHarmonicSurface(options), std::invalid_argument);
mfem::Vector center(2);
center = 0.;
CHECK_THROWS_AS(d::SphericalHarmonicSurface(center), std::invalid_argument);
center.SetSize(3);
center = 0.;
center(1) = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(d::SphericalHarmonicSurface(center), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Matches Analytic Real Harmonics And Radial Lengths",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto context = t::context(fem);
if (t::ranks() != 1) {
CHECK_THROWS_AS(
d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context), std::invalid_argument
);
return;
}
const auto surface = d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context);
CHECK(surface.parameterCount() == 25);
CHECK(surface.globalParameterCount() == 25);
CHECK(surface.globalParameterOffset() == 0);
CHECK(surface.surfaceDisplacementSize() == 3 * surface.surfaceNodeCount());
REQUIRE(surface.surfaceNodeCount() > surface.parameterCount());
CHECK(surface.nodalBasis().Height() == surface.surfaceNodeCount());
CHECK(surface.nodalBasis().Width() == surface.parameterCount());
const double pi = std::numbers::pi;
for (int node = 0; node < surface.surfaceNodeCount(); ++node) {
const double x = surface.radialDirection(node, 0), y = surface.radialDirection(node, 1),
z = surface.radialDirection(node, 2);
const auto check = [&](int l, int m, double exact) {
CHECK(std::abs(surface.nodalBasis()(node, t::modeIndex(surface, l, m)) - exact) < 2e-14);
};
check(0, 0, 1. / std::sqrt(4. * pi));
check(1, 0, std::sqrt(3. / (4. * pi)) * z);
check(1, 1, -std::sqrt(3. / (4. * pi)) * x);
check(1, -1, -std::sqrt(3. / (4. * pi)) * y);
check(2, 0, std::sqrt(5. / (16. * pi)) * (3. * z * z - 1.));
check(2, -2, std::sqrt(15. / (4. * pi)) * x * y);
check(2, 2, std::sqrt(15. / (16. * pi)) * (x * x - y * y));
}
mfem::Vector parameters(surface.parameterCount()), displacement(surface.surfaceDisplacementSize());
parameters = 0.;
parameters(0) = .125 * std::sqrt(4. * pi);
surface.buildSurfaceDisplacement(parameters, displacement);
for (int node = 0; node < surface.surfaceNodeCount(); ++node)
for (int component = 0; component < 3; ++component)
CHECK(
std::abs(
displacement(surface.surfaceDisplacementDof(node, component)) -
.125 * surface.radialDirection(node, component)
) < 1e-15
);
const auto axisymmetric = d::compileSurfaceDeformationPrescription(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 0}),
context
);
const auto restricted = d::compileSurfaceDeformationPrescription(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 4, .maximumAzimuthalOrder = 1}),
context
);
REQUIRE(axisymmetric.parameterCount() == 5);
REQUIRE(restricted.parameterCount() == 13);
for (int j = 0; j < axisymmetric.parameterCount(); ++j) {
CHECK(axisymmetric.modes()[j].degree == j);
CHECK(axisymmetric.modes()[j].order == 0);
for (int i = 0; i < surface.surfaceNodeCount(); ++i)
CHECK(axisymmetric.nodalBasis()(i, j) == surface.nodalBasis()(i, t::modeIndex(surface, j, 0)));
}
for (const auto mode : restricted.modes())
CHECK(std::abs(mode.order) <= 1);
const d::SphericalHarmonicSurface unresolved(
d::SphericalHarmonicSurfaceOptions{.maximumDegree = surface.surfaceNodeCount()}
);
CHECK_THROWS_AS(d::compileSurfaceDeformationPrescription(unresolved, context), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Preserves Its Exact Derivative And Dual Work",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto context = t::context(fem);
if (t::ranks() != 1) {
CHECK_THROWS_AS(
d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context), std::invalid_argument
);
return;
}
const auto surface = d::compileSurfaceDeformationPrescription(d::SphericalHarmonicSurface{}, context);
const mfem::DenseMatrix originalBasis(surface.nodalBasis());
mfem::Vector parameters(surface.parameterCount()), direction(surface.parameterCount()),
dual(surface.surfaceDisplacementSize()), action(surface.surfaceDisplacementSize()),
pulledBack(surface.parameterCount()), second(surface.parameterCount());
t::fill(parameters, .03);
t::fill(direction, .07);
t::fill(dual);
surface.applyJacobian(parameters, direction, action);
surface.applyJacobianTranspose(parameters, dual, pulledBack);
const double scale = std::max({1., action.Norml2() * dual.Norml2(), direction.Norml2() * pulledBack.Norml2()});
CHECK(std::abs((action * dual) - (direction * pulledBack)) < 2e-13 * scale);
constexpr double epsilon = 1e-5;
mfem::Vector plus(parameters), minus(parameters), plusValue(action.Size()), minusValue(action.Size());
plus.Add(epsilon, direction);
minus.Add(-epsilon, direction);
surface.buildSurfaceDisplacement(plus, plusValue);
surface.buildSurfaceDisplacement(minus, minusValue);
plusValue -= minusValue;
plusValue /= 2. * epsilon;
CHECK(t::relativeDifference(plusValue, action) < 2e-10);
second = 7.;
surface.applyPullbackDerivative(parameters, direction, dual, second);
CHECK(second.Norml2() == 0.);
CHECK(
std::memcmp(
originalBasis.GetData(), surface.nodalBasis().GetData(),
sizeof(mfem::real_t) * originalBasis.Height() * originalBasis.Width()
) == 0
);
mfem::Vector wrong(surface.parameterCount() + 1);
wrong = 0.;
CHECK_THROWS_AS(surface.buildSurfaceDisplacement(wrong, action), std::invalid_argument);
CHECK_THROWS_AS(surface.applyJacobian(parameters, wrong, action), std::invalid_argument);
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, wrong), std::invalid_argument);
CHECK_THROWS_AS(surface.applyPullbackDerivative(parameters, direction, dual, wrong), std::invalid_argument);
direction(0) = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS_AS(surface.applyJacobian(parameters, direction, action), std::invalid_argument);
dual(0) = std::numeric_limits<double>::infinity();
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, pulledBack), std::invalid_argument);
// Finite entries can still overflow an accumulated modal force; accepting
// an infinite dual would contaminate the complete Newton merit silently.
for (int node = 0; node < surface.surfaceNodeCount(); ++node)
for (int component = 0; component < 3; ++component)
dual(surface.surfaceDisplacementDof(node, component)) =
std::numeric_limits<double>::max() * surface.radialDirection(node, component);
for (int i = 0; i < dual.Size(); ++i)
REQUIRE(std::isfinite(dual(i)));
CHECK_THROWS_AS(surface.applyJacobianTranspose(parameters, dual, pulledBack), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Surface Rejects Aliased Modes Even With Enough Nodes",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
if (t::ranks() != 1) {
SUCCEED("MPI compilation rejection is covered by the full surface fixture.");
return;
}
auto serial = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 1., 1., 1.);
for (int b = 0; b < serial.GetNBE(); ++b)
serial.GetBdrElement(b)->SetAttribute(1);
mfem::ParMesh mesh(MPI_COMM_WORLD, serial);
mfem::H1_FECollection collection(1, 3);
mfem::ParFiniteElementSpace space(&mesh, &collection);
const auto map = t::field::make_stellar_surface_scalar_dof_map<t::Schema>(space);
const d::SurfaceDeformationCompilationContext context(space, map);
mfem::Vector center(3);
center = .5;
const d::SphericalHarmonicSurface axisymmetric(
center, d::SphericalHarmonicSurfaceOptions{.maximumDegree = 2, .maximumAzimuthalOrder = 0}
);
// Eight cube corners offer only two distinct cos(theta), so the three
// axisymmetric polynomials cannot define three independent coordinates.
if (t::ranks() == 1)
REQUIRE(map.local_size() == 8);
CHECK_THROWS_AS(d::compileSurfaceDeformationPrescription(axisymmetric, context), std::invalid_argument);
}
TEST_CASE(
"Spherical Harmonic Domain Couples All Volume Fields Through The Existing Extensions",
tags::spherical_harmonic_surface_validation
) {
namespace t = spherical_harmonic_surface_tests;
namespace d = mean_field::deformation;
auto args = test_utils::setup_args();
auto fem = mean_field::fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(fem.okay());
const auto make = [&]() {
return d::compileDomainDeformation(
d::SphericalHarmonicSurface(d::SphericalHarmonicSurfaceOptions{.maximumDegree = 2}),
d::PowerLawRadialInteriorExtension{}, d::FixedInfinityRadialVacuumExtension{}, fem
);
};
if (t::ranks() != 1) {
CHECK_THROWS_AS(make(), std::invalid_argument);
return;
}
auto harmonic = make();
const auto &surface = harmonic.surfaceDeformationPrescription();
mfem::Vector center(3);
center = 0.;
auto nodal = d::compileDomainDeformation(
d::NodalRadialSurface(center), d::PowerLawRadialInteriorExtension{}, d::FixedInfinityRadialVacuumExtension{},
fem
);
mfem::Vector parameters(harmonic.parameterCount()), direction(harmonic.parameterCount()),
nodalParameters(nodal.parameterCount()), volume(harmonic.volumeDisplacementSize()),
nodalVolume(nodal.volumeDisplacementSize()), action(volume.Size()), dual(volume.Size()),
pulledBack(parameters.Size()), pullbackDerivative(parameters.Size());
REQUIRE(harmonic.parameterCount() == 9);
REQUIRE(harmonic.surfaceDisplacementSize() == nodal.surfaceDisplacementSize());
REQUIRE(harmonic.volumeDisplacementSize() == nodal.volumeDisplacementSize());
t::fill(parameters, .001);
t::fill(direction, .004);
t::fill(dual);
surface.nodalBasis().Mult(parameters, nodalParameters);
harmonic.buildVolumeDisplacement(parameters, volume);
nodal.buildVolumeDisplacement(nodalParameters, nodalVolume);
CHECK(t::relativeDifference(volume, nodalVolume) < 3e-13);
harmonic.applyJacobian(parameters, direction, action);
harmonic.applyJacobianTranspose(parameters, dual, pulledBack);
CHECK(
std::abs((action * dual) - (direction * pulledBack)) <
1e-11 * std::max({1., action.Norml2() * dual.Norml2(), direction.Norml2() * pulledBack.Norml2()})
);
harmonic.applyPullbackDerivative(parameters, direction, dual, pullbackDerivative);
CHECK(pullbackDerivative.Norml2() == 0.);
constexpr double epsilon = 1e-5;
mfem::Vector plus(parameters), minus(parameters), plusVolume(volume.Size()), minusVolume(volume.Size());
plus.Add(epsilon, direction);
minus.Add(-epsilon, direction);
harmonic.buildVolumeDisplacement(plus, plusVolume);
harmonic.buildVolumeDisplacement(minus, minusVolume);
plusVolume -= minusVolume;
plusVolume /= 2. * epsilon;
CHECK(t::relativeDifference(plusVolume, action) < 2e-9);
const auto infinity =
t::field::make_scalar_boundary_dof_map<t::domain::InfinitySurface, t::Schema>(*fem.surfaceDeformationFes);
for (int i = 0; i < infinity.local_size(); ++i)
for (int component = 0; component < 3; ++component)
CHECK(volume(infinity.volume_true_dof(i) + component * fem.surfaceDeformationFes->GetTrueVSize()) == 0.);
}

View File

@@ -0,0 +1,294 @@
#include <cmath>
#include <concepts>
#include <numbers>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace harmonic_surface_metric_tests {
// Use analytic Cartesian formulas and direct boundary quadrature, rather
// than either the harmonic basis implementation or its assembled mass
// action. This distinguishes vector trace work from scalar radial work.
double analyticReferenceWork(
const mean_field::fem::FEM &fem,
int degree
) {
mfem::VectorFunctionCoefficient coefficient(3, [degree](const mfem::Vector &position, mfem::Vector &value) {
const double radius = position.Norml2();
if (!(radius > 0.)) {
// Interior center values cannot affect the boundary trace.
value.SetSize(3);
value = 0.;
return;
}
const double z = position(2) / radius;
const double harmonic =
degree == 0 ? 1. / std::sqrt(4. * std::numbers::pi) : std::sqrt(3. / (4. * std::numbers::pi)) * z;
value.SetSize(3);
for (int component = 0; component < 3; ++component)
value(component) = harmonic * position(component) / radius;
});
mfem::ParGridFunction tangent(fem.displacementFes.get());
tangent.ProjectCoefficient(coefficient);
mfem::Array<int> dofs;
mfem::Vector local, shape;
long double work = 0.;
for (int b = 0; b < fem.mesh->GetNBE(); ++b) {
if (fem.mesh->GetBdrAttribute(b) != 1)
continue;
const auto &element = *fem.displacementFes->GetBE(b);
auto &transformation = *fem.displacementFes->GetBdrElementTransformation(b);
REQUIRE(fem.displacementFes->GetBdrElementVDofs(b, dofs) == nullptr);
tangent.GetSubVector(dofs, local);
REQUIRE(local.Size() == 3 * element.GetDof());
shape.SetSize(element.GetDof());
// Match the declared FE quadrature rule, not the assembled matrix
// or its diagonal; the integrand is independently reconstructed.
const auto &rule = mfem::MassIntegrator::GetRule(element, element, transformation);
for (int q = 0; q < rule.GetNPoints(); ++q) {
const auto &point = rule.IntPoint(q);
transformation.SetIntPoint(&point);
element.CalcShape(point, shape);
long double normSquared = 0.;
for (int component = 0; component < 3; ++component) {
long double value = 0.;
for (int i = 0; i < element.GetDof(); ++i)
value += static_cast<long double>(shape(i)) * local(i + component * element.GetDof());
normSquared += value * value;
}
work += point.weight * transformation.Weight() * normSquared;
}
}
REQUIRE(std::isfinite(work));
REQUIRE(work > 0.);
return static_cast<double>(work);
}
// A valid surface implementation does not automatically promise the
// canonical nodal ordering required by this particular Riesz metric.
class SurfaceWithoutTraceMetadata final {
public:
explicit SurfaceWithoutTraceMetadata(mean_field::deformation::PreparedSphericalHarmonicSurface surface)
: m_surface(std::move(surface)) {
}
auto descriptor() const noexcept {
return m_surface.descriptor();
}
int parameterCount() const noexcept {
return m_surface.parameterCount();
}
int surfaceDisplacementSize() const noexcept {
return m_surface.surfaceDisplacementSize();
}
void buildSurfaceDisplacement(
const mfem::Vector &x,
mfem::Vector &u
) const {
m_surface.buildSurfaceDisplacement(x, u);
}
void applyJacobian(
const mfem::Vector &x,
const mfem::Vector &v,
mfem::Vector &u
) const {
m_surface.applyJacobian(x, v, u);
}
void applyJacobianTranspose(
const mfem::Vector &x,
const mfem::Vector &u,
mfem::Vector &v
) const {
m_surface.applyJacobianTranspose(x, u, v);
}
void applyPullbackDerivative(
const mfem::Vector &x,
const mfem::Vector &v,
const mfem::Vector &u,
mfem::Vector &result
) const {
m_surface.applyPullbackDerivative(x, v, u, result);
}
private:
mean_field::deformation::PreparedSphericalHarmonicSurface m_surface;
};
static_assert(mean_field::deformation::PreparedSurfaceDeformationPrescription<SurfaceWithoutTraceMetadata>);
} // namespace harmonic_surface_metric_tests
TEST_CASE(
"Harmonic Surface Coordinates Receive Their Own Primal And Dual Riesz Scales Before Physical Preparation",
"[normalization][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1)
SKIP("The current radial domain extension supports one MPI rank.");
// Both configurations have the same ambient trace space. Only the
// independent surface coordinate count changes, so a nodal-diagonal
// shortcut would fail the block-size and scale checks below.
deformation::SphericalHarmonicSurface prescription({.maximumDegree = 4});
int expectedModes = 25;
SECTION("All real azimuthal modes") {
}
SECTION("Axisymmetric modes") {
prescription = deformation::SphericalHarmonicSurface({.maximumDegree = 4, .maximumAzimuthalOrder = 0});
expectedModes = 5;
}
const auto args = test_utils::setup_args();
auto finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
const normalization::PhysicalRieszDiagonal policy{dimensions::LengthValue{utils::RADIUS}, utils::G};
auto stellarModel = model::StellarModel(
eos::Polytrope({.n = 1.0, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
auto problem = equilibrium::discretize(
stellarModel, equilibrium::makeStellarDiscretization(std::move(finiteElements), policy, prescription)
);
using Problem = std::remove_cvref_t<decltype(problem)>;
using Form = typename Problem::FormType;
using ShapeValue = utils::blocks::surface_deformation::parameters::value;
using ShapeResidual = utils::blocks::surface_deformation::shape_equilibrium::residual;
constexpr int valueBlock = utils::blocks::type_index_v<ShapeValue, typename Form::value_blocks>;
constexpr int residualBlock = utils::blocks::type_index_v<ShapeResidual, typename Form::residual_blocks>;
STATIC_CHECK(normalization::NormalizableStellarEquilibriumProblem<Problem>);
const auto &layout = problem.GetManifest().layout();
const int valueOffset = layout.value_offsets()[valueBlock];
const int residualOffset = layout.residual_offsets()[residualBlock];
REQUIRE(layout.value_offsets()[valueBlock + 1] - valueOffset == expectedModes);
REQUIRE(layout.residual_offsets()[residualBlock + 1] - residualOffset == expectedModes);
REQUIRE(problem.StateSize() == problem.EquationSize());
const auto &physical = problem.GetPhysicalOperator();
const auto &domain = physical.GetDomainDeformation();
REQUIRE_FALSE(domain.hasNodalRadialParameters());
REQUIRE(domain.parameterCount() == expectedModes);
REQUIRE_FALSE(physical.IsPrepared());
const auto generation = problem.GetPreparationGeneration();
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(problem);
CHECK_FALSE(normalized.IsPrepared());
CHECK_FALSE(physical.IsPrepared());
CHECK(problem.GetPreparationGeneration() == generation);
const auto diagonal =
deformation::assembleSurfaceParameterMassDiagonal(physical.GetHydrostaticOperator().GetFEM(), domain);
REQUIRE(diagonal.Size() == expectedModes);
const auto &referenceFEM = physical.GetHydrostaticOperator().GetFEM();
CHECK(
diagonal(0) ==
Catch::Approx(harmonic_surface_metric_tests::analyticReferenceWork(referenceFEM, 0)).epsilon(5e-13)
);
const int axialDipole = expectedModes == 5 ? 1 : 2;
CHECK(
diagonal(axialDipole) ==
Catch::Approx(harmonic_surface_metric_tests::analyticReferenceWork(referenceFEM, 1)).epsilon(5e-13)
);
const auto scales = normalization::deriveStellarCharacteristicScales(policy, stellarModel);
const double valueScale = normalization::physicalScale<ShapeValue>(scales);
const double residualScale = normalization::physicalScale<ShapeResidual>(scales);
const auto &stateFactors = normalized.GetNormalization().StateFactors();
const auto &residualFactors = normalized.GetNormalization().ResidualFactors();
REQUIRE(stateFactors.Size() == problem.StateSize());
REQUIRE(residualFactors.Size() == problem.EquationSize());
for (int i = 0; i < expectedModes; ++i) {
REQUIRE(std::isfinite(diagonal[i]));
REQUIRE(diagonal[i] > 0.0);
CHECK(stateFactors[valueOffset + i] == Catch::Approx(std::sqrt(diagonal[i]) / valueScale).epsilon(3.e-14));
CHECK(
residualFactors[residualOffset + i] ==
Catch::Approx(1.0 / (residualScale * std::sqrt(diagonal[i]))).epsilon(3.e-14)
);
// Primal and dual factors act on different root block offsets. Their
// Gram weights cancel in the work pairing, leaving physical scales.
CHECK(
stateFactors[valueOffset + i] * residualFactors[residualOffset + i] ==
Catch::Approx(1.0 / (valueScale * residualScale)).epsilon(3.e-14)
);
}
bool allFactorsValid = true;
for (int i = 0; i < stateFactors.Size(); ++i)
allFactorsValid = allFactorsValid && std::isfinite(stateFactors[i]) && stateFactors[i] > 0.0 &&
std::isfinite(residualFactors[i]) && residualFactors[i] > 0.0;
CHECK(allFactorsValid);
CHECK_FALSE(physical.IsPrepared());
CHECK(problem.GetPreparationGeneration() == generation);
}
TEST_CASE(
"Harmonic Reference Metric Rejects Unrelated Current Spaces And Unadvertised Trace Layouts",
"[normalization][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1)
SKIP("The current radial domain extension supports one MPI rank.");
const auto args = test_utils::setup_args();
auto finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
deformation::SphericalHarmonicSurface prescription({.maximumDegree = 2});
auto prepared = deformation::compileDomainDeformation(
prescription, deformation::PowerLawRadialInteriorExtension{}, deformation::FixedInfinityRadialVacuumExtension{},
finiteElements
);
deformation::PreparedDomainDeformationRuntime runtime(std::move(prepared));
REQUIRE(runtime.matchesCurrentDiscretization());
REQUIRE(runtime.supportsSurfaceParameterMetric());
SECTION("A different current FEM with identical dimensions is not interchangeable") {
auto other = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(other.okay());
REQUIRE(other.surfaceDeformationFes->GetTrueVSize() == finiteElements.surfaceDeformationFes->GetTrueVSize());
REQUIRE(other.displacementFes->GetTrueVSize() == finiteElements.displacementFes->GetTrueVSize());
REQUIRE(other.mesh.get() != finiteElements.mesh.get());
CHECK_THROWS_AS(deformation::assembleSurfaceParameterMassDiagonal(other, runtime), std::invalid_argument);
CHECK(runtime.matchesCurrentDiscretization());
}
SECTION("A surface tangent alone cannot certify canonical nodal vector ordering") {
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const deformation::SurfaceDeformationCompilationContext surfaceContext{
*finiteElements.surfaceDeformationFes,
field::make_stellar_surface_scalar_dof_map<Schema>(*finiteElements.surfaceDeformationFes)
};
auto surface = deformation::compileSurfaceDeformationPrescription(prescription, surfaceContext);
auto extensionContext = deformation::makeRadialDeformationExtensionCompilationContext<Schema>(
*finiteElements.surfaceDeformationFes, *finiteElements.displacementFes, *finiteElements.logicalReferenceMesh
);
auto custom = deformation::composePreparedDomainDeformation(
harmonic_surface_metric_tests::SurfaceWithoutTraceMetadata(std::move(surface)),
deformation::compileInteriorDeformationExtension(
deformation::PowerLawRadialInteriorExtension{}, extensionContext
),
deformation::compileVacuumDeformationExtension(
deformation::FixedInfinityRadialVacuumExtension{}, extensionContext
),
*finiteElements.surfaceDeformationFes, *finiteElements.displacementFes, *finiteElements.logicalReferenceMesh
);
STATIC_CHECK(deformation::PreparedDomainDeformationOperator<decltype(custom)>);
REQUIRE_FALSE(custom.supportsSurfaceParameterMetric());
deformation::PreparedDomainDeformationRuntime customRuntime(std::move(custom));
REQUIRE(customRuntime.matchesCurrentDiscretization());
REQUIRE_FALSE(customRuntime.supportsSurfaceParameterMetric());
mfem::Vector parameters(customRuntime.parameterCount()), direction(parameters.Size()),
trace(customRuntime.surfaceDisplacementSize());
parameters = 0.;
direction = 1.;
CHECK_NOTHROW(customRuntime.applySurfaceJacobian(parameters, direction, trace));
CHECK(trace.Norml2() > 0.);
CHECK_THROWS_AS(
deformation::assembleSurfaceParameterMassDiagonal(finiteElements, customRuntime), std::invalid_argument
);
}
}

View File

@@ -0,0 +1,427 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <optional>
#include <span>
#include <string_view>
#include <type_traits>
#include <utility>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_equilibrium_test {
using namespace mean_field;
using Block = operators::RootBlockDescriptor;
[[nodiscard]] const Block &findBlock(
const std::span<const Block> blocks,
const std::string_view name
) {
const auto found =
std::find_if(blocks.begin(), blocks.end(), [name](const Block &block) { return block.stableId == name; });
REQUIRE(found != blocks.end());
return *found;
}
[[nodiscard]] mfem::Vector readBlock(
const mfem::Vector &vector,
const Block &block
) {
REQUIRE(block.offset >= 0);
REQUIRE(block.offset + block.size <= vector.Size());
mfem::Vector result(block.size);
for (int i = 0; i < block.size; ++i)
result(i) = vector(block.offset + i);
return result;
}
void writeBlock(
mfem::Vector &vector,
const Block &block,
const mfem::Vector &values
) {
REQUIRE(values.Size() == block.size);
for (int i = 0; i < block.size; ++i)
vector(block.offset + i) = values(i);
}
void checkClose(
const mfem::Vector &actual,
const mfem::Vector &expected,
const double relative = 2.0e-9,
const double absolute = 2.0e-12
) {
REQUIRE(actual.Size() == expected.Size());
mfem::Vector difference(actual);
difference -= expected;
const double scale = std::max(actual.Norml2(), expected.Norml2());
INFO("error = " << difference.Norml2() << ", scale = " << scale);
REQUIRE(std::isfinite(difference.Norml2()));
CHECK(difference.Norml2() <= absolute + relative * scale);
}
void checkBlocks(
const mfem::Vector &actual,
const mfem::Vector &expected,
const std::span<const Block> blocks,
const double relative = 2.0e-9,
const double absolute = 2.0e-12
) {
for (const Block &block : blocks) {
INFO("residual block = " << block.stableId);
checkClose(readBlock(actual, block), readBlock(expected, block), relative, absolute);
}
}
[[nodiscard]] operators::StellarEquilibriumDependencies dependencies(const std::uint64_t revision) {
return {
.discretization = {.identity = 8101, .revision = 1},
.density = {.identity = 8103, .revision = revision},
.surfaceDeformation = {.identity = 8107, .revision = revision},
.gravityGradient = {.identity = 8111, .revision = revision},
.gravityPotential = {.identity = 8117, .revision = revision},
.enthalpy = {.identity = 8123, .revision = revision},
.bernoulliConstant = {.identity = 8129, .revision = revision},
.rotation = {.identity = 8131, .revision = revision},
.targetMass = {.identity = 8147, .revision = 1}
};
}
[[nodiscard]] physics::RigidRotation zeroRotation() {
mfem::Vector omega(3), center(3);
omega = 0.0;
center = 0.0;
return {omega, center};
}
template <bool GeneratedRotation> [[nodiscard]] auto makeModel() {
if constexpr (GeneratedRotation) {
return model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}}),
integral::FixedAngularMomentum({.Jtotal = dimensions::AngularMomentumValue{0.01}})
);
} else {
return model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
}
}
template <typename Problem>
void prepare(
Problem &problem,
const mfem::Vector &state,
const std::uint64_t revision
) {
if constexpr (Problem::hasFixedAngularMomentum) {
REQUIRE(problem.TryPrepare(state, dependencies(revision)).has_value());
} else {
REQUIRE(problem.TryPrepare(state, dependencies(revision), zeroRotation()).has_value());
}
}
// The oracle is the existing nodal problem, with explicit coefficient and
// dual-force maps. It never calls the native modal residual or Jacobian to
// construct an expected coupled result.
template <
typename ModalProblem,
typename NodalProblem>
[[nodiscard]] mfem::Vector liftState(
const ModalProblem &modal,
const NodalProblem &nodal,
const mfem::DenseMatrix &basis,
const mfem::Vector &source
) {
mfem::Vector result(nodal.StateSize());
result = 0.0;
for (const Block &destination : nodal.GetManifest().valueBlocks()) {
const Block &origin = findBlock(modal.GetManifest().valueBlocks(), destination.stableId);
const mfem::Vector values = readBlock(source, origin);
if (destination.stableId == "surface_deformation") {
REQUIRE(basis.Width() == origin.size);
REQUIRE(basis.Height() == destination.size);
mfem::Vector lifted(destination.size);
basis.Mult(values, lifted);
writeBlock(result, destination, lifted);
} else {
writeBlock(result, destination, values);
}
}
return result;
}
template <
typename ModalProblem,
typename NodalProblem>
[[nodiscard]] mfem::Vector pullResidual(
const ModalProblem &modal,
const NodalProblem &nodal,
const mfem::DenseMatrix &basis,
const mfem::Vector &source
) {
mfem::Vector result(modal.EquationSize());
result = 0.0;
for (const Block &destination : modal.GetManifest().residualBlocks()) {
const Block &origin = findBlock(nodal.GetManifest().residualBlocks(), destination.stableId);
const mfem::Vector values = readBlock(source, origin);
if (destination.stableId == "surface_shape_balance") {
REQUIRE(basis.Height() == origin.size);
REQUIRE(basis.Width() == destination.size);
mfem::Vector pulled(destination.size);
basis.MultTranspose(values, pulled);
writeBlock(result, destination, pulled);
} else {
writeBlock(result, destination, values);
}
}
return result;
}
template <bool GeneratedRotation> void checkCoupledEquivalence() {
const utils::Args args = test_utils::setup_args();
fem::FEM modalFem = fem::setup_fem(args.mesh_file, args, 0);
fem::FEM nodalFem = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(modalFem.okay());
REQUIRE(nodalFem.okay());
const deformation::SphericalHarmonicSurface prescription{{.maximumDegree = 4}};
using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema;
const auto surfaceMap = field::make_stellar_surface_scalar_dof_map<Schema>(*modalFem.surfaceDeformationFes);
const auto preparedSurface = deformation::compileSurfaceDeformationPrescription(
prescription, deformation::SurfaceDeformationCompilationContext{*modalFem.surfaceDeformationFes, surfaceMap}
);
const mfem::DenseMatrix basis(preparedSurface.nodalBasis());
REQUIRE(basis.Width() == 25);
const normalization::PhysicalRieszDiagonal policy{dimensions::LengthValue{1.0}};
const auto stellarModel = makeModel<GeneratedRotation>();
auto modal = equilibrium::discretize(
stellarModel, equilibrium::makeStellarDiscretization(std::move(modalFem), policy, prescription)
);
auto nodal =
equilibrium::discretize(stellarModel, equilibrium::makeStellarDiscretization(std::move(nodalFem), policy));
using ModalProblem = std::remove_cvref_t<decltype(modal)>;
STATIC_CHECK_FALSE(ModalProblem::hasFixedCentralDensity);
STATIC_CHECK(ModalProblem::hasFixedAngularMomentum == GeneratedRotation);
STATIC_CHECK(ModalProblem::symbolicallySquare);
REQUIRE(modal.StateSize() == modal.EquationSize());
const Block &shape = findBlock(modal.GetManifest().valueBlocks(), "surface_deformation");
const Block &shapeRows = findBlock(modal.GetManifest().residualBlocks(), "surface_shape_balance");
REQUIRE(shape.size == 25);
REQUIRE(shapeRows.size == shape.size);
CHECK(nodal.StateSize() - modal.StateSize() == basis.Height() - basis.Width());
mfem::Vector state(modal.StateSize()), direction(modal.StateSize());
state = 0.0;
for (int i = 0; i < direction.Size(); ++i)
direction(i) = 0.02 * std::sin(0.31 * (i + 1));
const auto view = modal.GetManifest().stateView(state);
view.block(utils::blocks::density_field.mass_term) = 1.0;
view.block(utils::blocks::enthalpy_field.specific_term) = 0.7;
view.block(utils::blocks::fixed_total_mass_constraint.mass_normalization_term) = 0.8;
if constexpr (GeneratedRotation) {
view.block(utils::blocks::fixed_angular_momentum_constraint.angular_velocity_term) = 0.04;
modal.GetManifest().stateView(direction).block(
utils::blocks::fixed_angular_momentum_constraint.angular_velocity_term
) = 0.015;
}
// Nonzero orders on both real branches make an accidental m=0-only
// integration fail. Positive h keeps fractional powers differentiable;
// its nonzero boundary value is handled by the physical replacement rows.
for (int mode = 0; mode < shape.size; ++mode) {
state(shape.offset + mode) = 0.001 * std::sin(0.7 * (mode + 1));
direction(shape.offset + mode) = 0.002 * std::cos(0.41 * (mode + 1));
}
for (const std::string_view name : {"gravity_gradient", "gravity_potential"}) {
const Block &block = findBlock(modal.GetManifest().valueBlocks(), name);
for (int i = 0; i < block.size; ++i)
state(block.offset + i) = 0.01 * std::cos(0.13 * (i + 1));
}
const mfem::Vector nodalState = liftState(modal, nodal, basis, state);
const mfem::Vector nodalDirection = liftState(modal, nodal, basis, direction);
prepare(modal, state, 1);
prepare(nodal, nodalState, 1);
checkClose(
modal.GetPhysicalOperator().GetGeneratedVolumeDisplacement(),
nodal.GetPhysicalOperator().GetGeneratedVolumeDisplacement(), 2.0e-12, 2.0e-14
);
const auto &modalDomain = modal.GetPhysicalOperator().GetDomainDeformation();
const auto &nodalDomain = nodal.GetPhysicalOperator().GetDomainDeformation();
CHECK_FALSE(modalDomain.hasNodalRadialParameters());
CHECK(nodalDomain.hasNodalRadialParameters());
const Block &nodalShape = findBlock(nodal.GetManifest().valueBlocks(), "surface_deformation");
const mfem::Vector modalParameters = readBlock(state, shape);
const mfem::Vector nodalParameters = readBlock(nodalState, nodalShape);
mfem::Vector modalTrace(modalDomain.surfaceDisplacementSize());
mfem::Vector nodalTrace(nodalDomain.surfaceDisplacementSize());
modalDomain.applySurfaceJacobian(modalParameters, readBlock(direction, shape), modalTrace);
nodalDomain.applySurfaceJacobian(nodalParameters, readBlock(nodalDirection, nodalShape), nodalTrace);
checkClose(modalTrace, nodalTrace, 3.0e-13, 2.0e-14);
mfem::Vector traceDual(modalTrace.Size());
for (int i = 0; i < traceDual.Size(); ++i)
traceDual(i) = std::sin(0.23 * (i + 1));
mfem::Vector modalDual(shape.size), nodalDual(nodalShape.size), expectedDual(shape.size);
modalDomain.applySurfaceJacobianTranspose(modalParameters, traceDual, modalDual);
nodalDomain.applySurfaceJacobianTranspose(nodalParameters, traceDual, nodalDual);
basis.MultTranspose(nodalDual, expectedDual);
checkClose(modalDual, expectedDual, 3.0e-13, 2.0e-13);
mfem::Vector residual, nodalResidual, action, nodalAction;
modal.BuildResidual(residual);
nodal.BuildResidual(nodalResidual);
modal.ApplyLinearization(direction, action);
nodal.ApplyLinearization(nodalDirection, nodalAction);
checkBlocks(residual, pullResidual(modal, nodal, basis, nodalResidual), modal.GetManifest().residualBlocks());
checkBlocks(action, pullResidual(modal, nodal, basis, nodalAction), modal.GetManifest().residualBlocks());
// A surface-only perturbation must also reach the retained field rows;
// a full coupled comparison alone could overlook an inert shape block.
mfem::Vector shapeDirection(direction.Size());
shapeDirection = 0.0;
writeBlock(shapeDirection, shape, readBlock(direction, shape));
mfem::Vector shapeAction;
modal.ApplyLinearization(shapeDirection, shapeAction);
CHECK(
readBlock(shapeAction, findBlock(modal.GetManifest().residualBlocks(), "poisson_balance")).Norml2() >
1.0e-10
);
CHECK(readBlock(shapeAction, shapeRows).Norml2() > 1.0e-10);
constexpr double step = 2.0e-5;
mfem::Vector plus(state), minus(state), plusResidual, minusResidual;
plus.Add(step, direction);
minus.Add(-step, direction);
prepare(modal, plus, 2);
modal.BuildResidual(plusResidual);
prepare(modal, minus, 3);
modal.BuildResidual(minusResidual);
plusResidual -= minusResidual;
plusResidual /= 2.0 * step;
checkBlocks(plusResidual, action, modal.GetManifest().residualBlocks(), 2.0e-5, 2.0e-9);
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(modal);
const mfem::Vector fixedStateFactors(normalized.GetNormalization().StateFactors());
const mfem::Vector fixedResidualFactors(normalized.GetNormalization().ResidualFactors());
REQUIRE(fixedStateFactors.Size() == modal.StateSize());
REQUIRE(fixedResidualFactors.Size() == modal.EquationSize());
for (int i = 0; i < fixedStateFactors.Size(); ++i) {
REQUIRE(std::isfinite(fixedStateFactors(i)));
REQUIRE(fixedStateFactors(i) > 0.0);
}
for (int i = 0; i < fixedResidualFactors.Size(); ++i) {
REQUIRE(std::isfinite(fixedResidualFactors(i)));
REQUIRE(fixedResidualFactors(i) > 0.0);
}
const auto prepareNormalized = [&](const mfem::Vector &physical, const std::uint64_t revision) {
mfem::Vector coordinates;
normalized.NormalizeState(physical, coordinates);
if constexpr (GeneratedRotation) {
(void)normalized.Prepare(coordinates, dependencies(revision));
} else {
(void)normalized.Prepare(coordinates, dependencies(revision), zeroRotation());
}
REQUIRE(normalized.IsPrepared());
};
prepareNormalized(state, 4);
checkClose(normalized.GetPhysicalState(), state, 3.0e-14, 2.0e-14);
mfem::Vector normalizedDirection, normalizedAction, expectedAction;
normalized.NormalizeState(direction, normalizedDirection);
normalized.Mult(normalizedDirection, normalizedAction);
normalized.NormalizeResidual(action, expectedAction);
checkBlocks(normalizedAction, expectedAction, modal.GetManifest().residualBlocks(), 3.0e-10, 2.0e-12);
mfem::Vector normalizedResidual, expectedResidual;
normalized.BuildResidual(normalizedResidual);
normalized.NormalizeResidual(residual, expectedResidual);
checkBlocks(normalizedResidual, expectedResidual, modal.GetManifest().residualBlocks(), 3.0e-10, 2.0e-12);
// Repeated accepted-state-sized changes must not silently rotate or
// rescale persistent harmonic coordinates through a current-shape metric.
prepareNormalized(plus, 5);
prepareNormalized(state, 6);
for (int i = 0; i < fixedStateFactors.Size(); ++i)
CHECK(normalized.GetNormalization().StateFactors()(i) == fixedStateFactors(i));
for (int i = 0; i < fixedResidualFactors.Size(); ++i)
CHECK(normalized.GetNormalization().ResidualFactors()(i) == fixedResidualFactors(i));
mfem::Vector restoredResidual, restoredAction;
modal.BuildResidual(restoredResidual);
modal.ApplyLinearization(direction, restoredAction);
checkBlocks(restoredResidual, residual, modal.GetManifest().residualBlocks());
checkBlocks(restoredAction, action, modal.GetManifest().residualBlocks());
}
} // namespace spherical_harmonic_equilibrium_test
TEST_CASE(
"Native Harmonic Stellar Equilibrium Equals The Nodal Pullback Without A Central Constraint",
tags::stellar_equilibrium_problem_integration & tags::spherical_harmonic_surface
) {
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1) {
SUCCEED("The current radial domain extension and harmonic prescription require one MPI rank.");
return;
}
SECTION("Prescribed zero rotation") {
spherical_harmonic_equilibrium_test::checkCoupledEquivalence<false>();
}
SECTION("Generated rotation from nonzero target angular momentum") {
spherical_harmonic_equilibrium_test::checkCoupledEquivalence<true>();
}
}
TEST_CASE(
"Harmonic Degree And Azimuthal Cutoffs Determine Native Coupled Block Sizes",
tags::stellar_equilibrium_problem_integration & tags::spherical_harmonic_surface
) {
using namespace mean_field;
using namespace spherical_harmonic_equilibrium_test;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1) {
SUCCEED("The current radial domain extension and harmonic prescription require one MPI rank.");
return;
}
struct Case final {
int degree;
std::optional<int> order;
int count;
};
const std::array<Case, 3> cases{{{0, std::nullopt, 1}, {4, 0, 5}, {4, std::nullopt, 25}}};
for (const Case &test : cases) {
INFO("maximum degree = " << test.degree << ", expected parameters = " << test.count);
const utils::Args args = test_utils::setup_args();
fem::FEM finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
auto problem = equilibrium::discretize(
makeModel<false>(),
equilibrium::makeStellarDiscretization(
std::move(finiteElements), normalization::PhysicalRieszDiagonal{dimensions::LengthValue{1.0}},
deformation::SphericalHarmonicSurface{
{.maximumDegree = test.degree, .maximumAzimuthalOrder = test.order}
}
)
);
CHECK(findBlock(problem.GetManifest().valueBlocks(), "surface_deformation").size == test.count);
CHECK(findBlock(problem.GetManifest().residualBlocks(), "surface_shape_balance").size == test.count);
CHECK(problem.StateSize() == problem.EquationSize());
auto normalized = normalization::makeNormalizedStellarEquilibriumOperator(problem);
REQUIRE(normalized.GetNormalization().StateSize() == problem.StateSize());
REQUIRE(normalized.GetNormalization().ResidualSize() == problem.EquationSize());
for (const mfem::Vector *factors :
{&normalized.GetNormalization().StateFactors(), &normalized.GetNormalization().ResidualFactors()}) {
for (int i = 0; i < factors->Size(); ++i) {
REQUIRE(std::isfinite((*factors)(i)));
REQUIRE((*factors)(i) > 0.0);
}
}
}
}

View File

@@ -0,0 +1,182 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
import test_helpers;
namespace spherical_harmonic_preconditioner_test {
using namespace mean_field;
[[nodiscard]] operators::StellarEquilibriumDependencies dependencies() {
return {
.discretization = {.identity = 9201, .revision = 1},
.density = {.identity = 9203, .revision = 1},
.surfaceDeformation = {.identity = 9209, .revision = 1},
.gravityGradient = {.identity = 9221, .revision = 1},
.gravityPotential = {.identity = 9227, .revision = 1},
.enthalpy = {.identity = 9239, .revision = 1},
.bernoulliConstant = {.identity = 9241, .revision = 1},
.rotation = {.identity = 9257, .revision = 1},
.targetMass = {.identity = 9277, .revision = 1}
};
}
void checkClose(
const mfem::Vector &actual,
const mfem::Vector &expected
) {
REQUIRE(actual.Size() == expected.Size());
mfem::Vector difference(actual);
difference -= expected;
const double error = difference.Norml2();
const double scale = std::max(actual.Norml2(), expected.Norml2());
REQUIRE(std::isfinite(error));
REQUIRE(std::isfinite(scale));
INFO("inverse error = " << error << ", scale = " << scale);
CHECK(error <= 2.e-12 + 3.e-11 * scale);
}
// Construct the advertised triangular surrogate's RHS from a known
// correction. This checks the inverse's parameter ordering and material
// coupling, without asking an approximate inverse to invert the full J.
template <typename Prepared>
void checkTriangularInverse(
const Prepared &inverse,
const int modes
) {
const auto &couplings = inverse.GetCoupledOperator();
const auto &offsets = couplings.GetOffsets();
REQUIRE(offsets[2] - offsets[1] == modes);
REQUIRE(inverse.Width() == offsets[3]);
REQUIRE(inverse.Height() == inverse.Width());
REQUIRE(inverse.GetSurfaceDiagonal().Size() == modes);
mfem::Vector expected(inverse.Height()), rhs(inverse.Width()), actual(inverse.Height());
for (int i = 0; i < expected.Size(); ++i)
expected(i) = 0.02 * std::sin(0.31 * (i + 1));
const mfem::Vector density(expected.GetData(), offsets[1]);
const mfem::Vector shape(expected.GetData() + offsets[1], modes);
const mfem::Vector enthalpy(expected.GetData() + offsets[2], offsets[3] - offsets[2]);
mfem::Vector densityCoupling(density.Size()), enthalpyCoupling(enthalpy.Size()), cross(density.Size());
couplings.ApplySurfaceToMaterial(shape, densityCoupling, enthalpyCoupling);
couplings.ApplyEnthalpyToDensity(enthalpy, cross);
for (int i = 0; i < density.Size(); ++i)
rhs(i) = inverse.GetDensityDiagonal()(i) * density(i) + densityCoupling(i) + cross(i);
for (int i = 0; i < modes; ++i)
rhs(offsets[1] + i) = inverse.GetSurfaceDiagonal()(i) * shape(i);
for (int i = 0; i < enthalpy.Size(); ++i)
rhs(offsets[2] + i) = inverse.GetEnthalpyDiagonal()(i) * enthalpy(i) + enthalpyCoupling(i);
actual = 0.0;
inverse.Mult(rhs, actual);
checkClose(actual, expected);
const mfem::Vector actualShape(actual.GetData() + offsets[1], modes);
checkClose(actualShape, shape);
rhs = 0.0;
actual = 1.0;
inverse.Mult(rhs, actual);
CHECK(actual.Norml2() == 0.0);
CHECK(inverse.IsCurrent());
}
} // namespace spherical_harmonic_preconditioner_test
TEST_CASE(
"Harmonic Surface Mass And Calibrated Inverses Use Native Parameters And Reject Nodal H1",
"[preconditioning][material_surface][spherical_harmonic][integration][serial]"
) {
using namespace mean_field;
using namespace spherical_harmonic_preconditioner_test;
namespace backend = preconditioning::backend;
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1)
SKIP("The current radial domain extension supports one MPI rank.");
const auto args = test_utils::setup_args();
auto finiteElements = fem::setup_fem(args.mesh_file, args, 0);
REQUIRE(finiteElements.okay());
const auto stellarModel = model::StellarModel(
eos::Polytrope({.n = 1.5, .K = 0.25}), surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.0}})
);
// A restricted azimuthal set also rules out using (L+1)^2 as the block
// length when the actual mode catalog has fewer independent coefficients.
const deformation::SphericalHarmonicSurface prescription{{.maximumDegree = 2, .maximumAzimuthalOrder = 1}};
constexpr int modes = 7;
auto problem = equilibrium::discretize(
stellarModel,
equilibrium::makeStellarDiscretization(std::move(finiteElements), normalization::Unnormalized{}, prescription)
);
mfem::Vector state(problem.StateSize());
state = 0.0;
const auto view = problem.GetManifest().stateView(state);
view.block(utils::blocks::density_field.mass_term) = 1.0;
view.block(utils::blocks::enthalpy_field.specific_term) = 0.7;
view.block(utils::blocks::fixed_total_mass_constraint.mass_normalization_term) = 0.8;
auto shape = view.block(utils::blocks::surface_deformation_field.parameters_term);
REQUIRE(shape.Size() == modes);
for (int i = 0; i < modes; ++i)
shape(i) = 0.001 * std::sin(0.7 * (i + 1));
mfem::Vector omega(3), center(3);
omega = 0.0;
center = 0.0;
const auto preparation = problem.TryPrepare(state, dependencies(), physics::RigidRotation{omega, center});
REQUIRE(preparation.has_value());
const auto &physical = problem.GetPhysicalOperator();
REQUIRE(physical.IsPrepared());
REQUIRE_FALSE(physical.GetDomainDeformation().hasNodalRadialParameters());
const auto block = preconditioning::materialSurfaceBlock(problem);
STATIC_CHECK(
std::is_same_v<
typename std::remove_cvref_t<decltype(block)>::SurfaceSurrogate, preconditioning::SurfaceMassDiagonal>
);
auto prepared = preconditioning::prepare(problem, block);
CHECK(prepared.GetStatistics().surfaceRieszAssemblies == 1);
CHECK(prepared.GetStatistics().surfaceJacobianProbes == 0);
CHECK(prepared.GetSurfaceDiagonalQuality().minimumAbsoluteEntryBeforeRegularization > 0.0);
checkTriangularInverse(prepared, modes);
const auto unchanged = prepared.Refresh(physical);
CHECK_FALSE(unchanged.DidAnyWork());
const auto calibratedBlock = preconditioning::materialSurfaceBlock(
problem, backend::Diagonal{}, backend::Diagonal{}, preconditioning::SurfaceThenMaterialTriangular{},
{.surfaceCalibration = {
.target = preconditioning::SurfaceRieszCalibrationTarget::surface_jacobian,
.probeCount = 3,
.objective = preconditioning::SurfaceRieszCalibrationObjective::operator_action
}}
);
auto calibrated = preconditioning::prepare(problem, calibratedBlock);
const auto &calibration = calibrated.GetSurfaceCalibration();
REQUIRE(calibration.WasCalibrated());
REQUIRE(std::isfinite(calibration.scale));
REQUIRE(calibration.scale != 0.0);
CHECK(calibration.probeCount == 3);
CHECK(calibrated.GetStatistics().surfaceJacobianProbes == 3);
for (int i = 0; i < modes; ++i)
CHECK(
calibrated.GetSurfaceDiagonal()(i) ==
Catch::Approx(calibration.scale * prepared.GetSurfaceDiagonal()(i)).epsilon(2.e-13)
);
checkTriangularInverse(calibrated, modes);
using FixedAMG = backend::HypreBoomerAMG<backend::FixedCycles>;
const auto h1 = preconditioning::materialSurfaceBlock(
problem, backend::Diagonal{}, FixedAMG{backend::FixedCycles{.cycles = 1}},
preconditioning::ApproximateMaterialSurfaceLDU{}, preconditioning::SurfaceH1MassStiffness{}
);
CHECK_THROWS_WITH(
preconditioning::prepare(problem, h1), Catch::Matchers::ContainsSubstring("requires nodal radial parameters")
);
CHECK(prepared.IsCurrent());
CHECK(calibrated.IsCurrent());
}

View File

@@ -347,6 +347,14 @@ export namespace tags {
nodal_radial_surface & integration & accuracy & make_tag("analytic_comparison"); nodal_radial_surface & integration & accuracy & make_tag("analytic_comparison");
inline constexpr auto nodal_radial_surface_linearization = inline constexpr auto nodal_radial_surface_linearization =
nodal_radial_surface & integration & make_tag("jacobian") & make_tag("adjoint"); nodal_radial_surface & integration & make_tag("jacobian") & make_tag("adjoint");
inline constexpr auto spherical_harmonic_surface =
surface & geometry & make_tag("surface_deformation") & make_tag("spherical_harmonic");
inline constexpr auto spherical_harmonic_surface_validation =
spherical_harmonic_surface & unit & make_tag("validation");
inline constexpr auto spherical_harmonic_surface_analytic =
spherical_harmonic_surface & integration & accuracy & make_tag("analytic_comparison");
inline constexpr auto spherical_harmonic_surface_linearization =
spherical_harmonic_surface & integration & make_tag("jacobian") & make_tag("adjoint");
inline constexpr auto radial_deformation_extension = deformation & mapping & make_tag("radial_extension"); inline constexpr auto radial_deformation_extension = deformation & mapping & make_tag("radial_extension");
inline constexpr auto radial_deformation_extension_validation = inline constexpr auto radial_deformation_extension_validation =
radial_deformation_extension & unit & make_tag("validation"); radial_deformation_extension & unit & make_tag("validation");