Add native spherical harmonic surface coordinates to coupled equilibrium
This commit is contained in:
76
experiments/SPHERICAL_HARMONIC_SURFACE_2026-09-19.md
Normal file
76
experiments/SPHERICAL_HARMONIC_SURFACE_2026-09-19.md
Normal 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.
|
||||
397
experiments/audit_spherical_harmonic_surface.py
Normal file
397
experiments/audit_spherical_harmonic_surface.py
Normal 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())
|
||||
@@ -32,6 +32,7 @@ import mean_field;
|
||||
#include "plateau_polynomial_certificate.hpp"
|
||||
#include "shape_field_weak_diagnostics.hpp"
|
||||
#include "reduced_surface_diagnostics.hpp"
|
||||
#include "spherical_harmonic_surface_diagnostics.hpp"
|
||||
|
||||
using namespace mean_field;
|
||||
namespace {
|
||||
@@ -44,7 +45,8 @@ struct Options {
|
||||
bool preconditionerProbe = false, nonlinearStepProbe = false;
|
||||
bool weakShapeProbe = false, weakShapeFinal = false;
|
||||
bool reducedSurfaceProbe = false, reducedSurfaceAlgebraCheck = false;
|
||||
int surfaceDegree = 4;
|
||||
int surfaceDegree = 4, surfaceMaximumOrder = -1;
|
||||
std::string surfacePrescription = "nodal";
|
||||
bool coupledTrialProbe = false, coupledSeedControl = false;
|
||||
bool dampedCoupledProbe = false;
|
||||
std::string coupledSearch = "near";
|
||||
@@ -122,6 +124,10 @@ Options Parse(int argc, char **argv) {
|
||||
o.reducedSurfaceProbe = true;
|
||||
else if (a == "--reduced-surface-algebra-check")
|
||||
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")
|
||||
o.surfaceDegree = std::stoi(v());
|
||||
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)
|
||||
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) || o.maxNewton != 0 ||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
int count = -1;
|
||||
input >> count;
|
||||
@@ -560,6 +592,8 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
|
||||
<< "\ngravity_iterations=" << o.gravityIterations << "\ngravity_restart=" << o.gravityRestart
|
||||
<< "\npreconditioner_probe=" << o.preconditionerProbe
|
||||
<< "\nnonlinear_step_probe=" << o.nonlinearStepProbe
|
||||
<< "\nsurface_prescription=" << o.surfacePrescription
|
||||
<< "\nsurface_maximum_order=" << o.surfaceMaximumOrder
|
||||
<< "\nreduced_surface_probe=" << o.reducedSurfaceProbe << "\nsurface_degree=" << o.surfaceDegree
|
||||
<< "\nweak_shape_probe=" << o.weakShapeProbe << "\nweak_shape_final=" << o.weakShapeFinal
|
||||
<< "\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);
|
||||
auto out = File(o.output / (label + "_physical.csv"));
|
||||
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}) {
|
||||
auto metrics = experiment::plateau_diagnostics::MeasurePhysical(physical, o.n, scales.K, q);
|
||||
metrics["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm;
|
||||
@@ -1065,9 +1104,11 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
|
||||
});
|
||||
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(
|
||||
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) {
|
||||
using Recipe = decltype(recipe);
|
||||
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}});
|
||||
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) {
|
||||
const auto scales = Derive(o.n);
|
||||
std::cout << "n=" << o.n << " K=" << scales.K << " rho_c=" << scales.rho << " J=" << o.angularMomentum << std::endl;
|
||||
|
||||
150
experiments/spherical_harmonic_surface_cases_2026-09-19.json
Normal file
150
experiments/spherical_harmonic_surface_cases_2026-09-19.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
125
experiments/spherical_harmonic_surface_diagnostics.hpp
Normal file
125
experiments/spherical_harmonic_surface_diagnostics.hpp
Normal 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
|
||||
Reference in New Issue
Block a user