perf(allocations): reduced overall allocations by 95%, increaseed jacobian applicatin by 2x

This commit uses global pre allocated work space to dramatically reduce memory usage and allocation time
This commit is contained in:
2026-09-10 06:50:56 -04:00
parent b3c04d507a
commit 75cc638739
66 changed files with 207183 additions and 99552 deletions

View File

@@ -0,0 +1,301 @@
# Geometry quality experiment
`geometry_quality_experiment` observes the production stellar-equilibrium context,
normalization, analytic Jacobian, preconditioner, volume extension, mapper, and
geometry-safe-step estimator. It does not implement a second Newton solver or
change the physical equations. This document describes methods and usage, not
conclusions from a particular run.
## Build and run
The executable is an opt-in CMake target (`EXCLUDE_FROM_ALL`). From the repository
root, using an existing configured release build:
```sh
cmake --build cmake-build-release-homebrew --target geometry_quality_experiment -j 2
./cmake-build-release-homebrew/geometry_quality_experiment --output geometry_quality_results_baseline
```
If the build directory predates the target, regenerate it using the same CMake
configuration first. Building this target does not build or run the full test
suite. The executable does not automatically launch tests.
Use exactly one MPI rank. The executable and geometry observer reject multi-rank
runs: the per-element diagnostic invokes a collective estimator separately for
each local element, which is not a valid distributed loop when rank-local element
counts differ.
The output directory must **not already exist**. Select a new directory for every
run so comparisons cannot accidentally overwrite earlier evidence. The default
mesh path is relative to the current working directory; run from the repository
root or supply `--mesh` explicitly.
Useful initial runs:
```sh
# Geometry and reference-mesh probes only; no diagnostic Newton correction.
./cmake-build-release-homebrew/geometry_quality_experiment --geometry-only --output geometry_quality_results_geometry
# One frozen-seed correction, without costly residual derivative/block-column checks.
./cmake-build-release-homebrew/geometry_quality_experiment --tolerances 0.03 --no-fd --no-block-actions --output geometry_quality_results_fast
# Compare linear accuracy at the identical seed, including default checks.
./cmake-build-release-homebrew/geometry_quality_experiment --tolerances 0.03,0.003 --output geometry_quality_results_tolerances
# Inspect a state reached by up to three production Newton iterations.
./cmake-build-release-homebrew/geometry_quality_experiment --advance 3 --tolerances 0.03 --no-fd --no-block-actions --output geometry_quality_results_advance3_cold
# Repeat that state with the production correction warm-start vector.
./cmake-build-release-homebrew/geometry_quality_experiment --advance 3 --warm --tolerances 0.03 --no-fd --no-block-actions --output geometry_quality_results_advance3_warm
# Replay a previously saved seed correction and inspect additional geometry controls.
./cmake-build-release-homebrew/geometry_quality_experiment --replay-vectors geometry_quality_results_saved/newton_0_vectors.csv --no-fd --no-block-actions --output geometry_quality_results_replay
# Focus a seed replay on diagonal and exact-vertex probes.
./cmake-build-release-homebrew/geometry_quality_experiment --replay-vectors geometry_quality_results_saved/newton_0_vectors.csv --diagonal-only --output geometry_quality_results_vertices
```
Even geometry-only mode constructs the complete production context. Context
construction includes physical preparation and preconditioner setup, so this is
not a lightweight mesh-only program.
## Options
| Option | Default | Meaning |
| --- | --- | --- |
| `--mesh FILE` | `sandbox.smesh` | STROID mesh used by production FEM setup. |
| `--output DIRECTORY` | `geometry_quality_results` | New artifact directory; an existing path is rejected. |
| `--tolerances LIST` | `0.03,0.003` | Comma-separated positive relative linear tolerances less than one. |
| `--max-linear-iterations N` | `200` | Iteration cap for diagnostic linear solves. |
| `--advance N` | `0` | Run production Newton for at most N iterations before freezing the state. |
| `--warm` | Off | Start every diagnostic solve from the correction left in the production context. |
| `--geometry-only` | Off | Run prescribed-direction/reference geometry diagnostics and return. |
| `--no-fd` | Off | Skip full normalized residual directional finite differences. |
| `--no-block-actions` | Off | Skip Jacobian actions with individual correction blocks. |
| `--save-vectors` | Off | Save complete physical/normalized state and correction coefficients. |
| `--replay-vectors FILE` | Off | Load a saved correction after checking its accepted state against this context; evaluate its true linear residual without another linear solve. |
| `--diagonal-only` | Off | Seed replay only: skip full per-element scans, projected-volume controls, and residual/block-column checks; retain diagonal and vertex probes. |
| `--help` | | Print the command-line summary. |
The advancement phase uses production Newton with relative nonlinear tolerance
`1e-8`, default backtracking, and linear tolerance `0.03` / cap `200`. Its linear
settings do not change with `--tolerances` or `--max-linear-iterations`. Check
`trajectory.csv` and the printed accepted-step count: advancement can terminate
before the requested number of iterations.
## Frozen-state protocol
The model matches the current sandbox: nonrotating n=1 polytrope, fixed mass and
angular momentum, fixed central density, and isobaric zero-pressure surface.
The context uses production physical Riesz normalization, the default physical
preconditioner, and FGMRES restart length 40.
All listed diagnostic linear tolerances are evaluated at the same accepted state.
Each starts cold unless `--warm` is present; warm mode reuses the same captured
production correction for each tolerance, not the result of the preceding
diagnostic solve. At the untouched seed that warm vector is zero.
Replay mode reads the exact `--save-vectors` CSV format and checks both saved
physical and normalized accepted-state coefficients against the reconstructed
context, using `1e-12 * (1 + abs(saved_value))` per coefficient. It loads the
normalized correction, denormalizes through the current context, and recomputes
`Jp+F`. It uses only the first requested tolerance, and that tolerance classifies
the verified residual; it does not trigger a new linear solve. Consequently,
`iterations=0` and the replay wall time are **not** fresh linear-solve performance
measurements. A saved direction that does not meet the requested tolerance gets
status `maximum_iterations` as the current diagnostic classification, even though
no Krylov iteration limit was exercised. Replay verifies a saved state, not a
complete source/build/mesh identity.
`--diagonal-only` requires replay, `--advance 0`, and no `--geometry-only` flag.
It disables finite-difference and block-column checks. It still constructs the
full production context, independently checks the replayed correction, and calls
the ordinary global production preflight once, so `solves.csv` retains the
production quadrature boundary as a reference. It avoids repeated full
per-element scans; it is not a no-preflight or mesh-only mode.
No diagnostic correction is accepted. Finite-difference candidates use production
trial preparation, then restore accepted-state preparation before a subsequent
linear solve. The internal diagnostic access scope requires no live solver and
restores accepted-state preparation on exit.
The first diagnostic correction is additionally split into an unweighted mean
radial surface component and the remainder. These are geometry-only directional
comparisons, not independent solutions of the Newton equation. Uniform contraction
is another deliberately prescribed surface direction: each surface parameter is
minus its reference radius, and its volume displacement is generated by the
production extension.
Two additional prescribed-direction controls bypass the surface extension: the
physical-coordinate fields `u(X) = -X` and `u(X) = -(|X|/R) X` are projected into
the existing displacement FE space. Their geometry checks use only core elements
(attribute 1). These are diagnostic volume directions, not alternative
production surface prescriptions and not Newton corrections. At the default
orders, P3 displacement interpolation does not exactly represent the P4 physical
mesh coordinate field; even the nominally affine control therefore tests an
interpolant rather than an exact continuum affine map. Exterior geometry is not
certified by these core-only controls.
When inspecting an unadvanced seed (`--advance 0`) **with replay**, two additional
controls compile the existing production radial-interior prescription at powers
3 and 4 instead of 2. They apply the identical saved surface correction, retaining
the production surface and exterior prescriptions, then inspect all production
geometry rules. These are geometry-only interventions: the correction has not
been recomputed for the changed parameterization, so an improved geometry boundary
does not establish nonlinear convergence or equilibrium accuracy. These controls
do not run in ordinary non-replay or advanced-state inspection.
## Artifacts and interpretation
| File | Contents |
| --- | --- |
| `metadata.txt` | Mesh path/size, compile/compiler identifiers, polynomial increment, model/scaling description, MPI count, requested options, state size, and accepted residual. |
| `blocks.csv` | Accepted state/residual, corrections, and true linear residual `Jp+F`, separated by manifest block. |
| `solves.csv` | Linear tolerance/status/iterations, verified true residual, elapsed whole-solve time, geometry boundary, and differences from the first correction. |
| `surface.csv` | Surface reference coordinates and accepted/correction displacement divided by reference radius. |
| `surface_summary.csv` | Unweighted mean/RMS surface correction fraction, RMS nonmean component, and extrema. |
| `block_actions.csv` | Each individual correction block's contribution to each equation block, including its dot product with the accepted residual. First correction only. |
| `finite_differences.csv` | Blockwise directional derivative errors for the first correction. Header-only when disabled. |
| `extension_checks.csv` | Volume-direction consistency against finite differences of the freshly prepared generated volume displacement, plus trial residual norms. Uses the same first-correction perturbations as the residual derivative checks; header-only with `--no-fd`. |
| `trajectory.csv` | Accepted production iterations before inspection; present with `--advance`. |
| `CASE_vectors.csv` | Optional raw coefficient snapshots from `--save-vectors`. |
| `CASE_geometry_elements.csv` | Sorted per-element geometry boundaries, limiting samples, positions, directional gradients, determinant polynomials, and singular values. |
| `CASE_geometry_mapping_checks.csv` | Directly rebuilt mapped geometry versus the affine prediction at selected limiting samples. |
| `CASE_geometry_limiting_matrices.csv` | Base/directional mapping and reference/physical element matrices for the most limiting elements. |
| `CASE_vertices_geometry_*.csv` | Same geometry diagnostics and unchanged estimator, but with vertex-only sampling on eight selected core elements. Present for seed replay. |
| `CASE_core_diagonal.csv` | Fresh production FE direction along the negative core-corner diagonal, compared with the continuous logical-radius-squared profile. Written for `uniform_contraction` and `newton_0` on recognized sandbox geometry. |
| `uniform_contraction_reference_corner_probes.csv` | Reference transformation probes at and just inside core-element vertices, without inverse-Jacobian evaluation. |
Case names `newton_0`, `newton_1`, etc. follow the requested tolerance order.
`newton_0_mean` and `newton_0_nonmean` refer to the surface split. Geometry probes
also run for `uniform_contraction`.
Core-only controls use `core_projected_affine_contraction` and
`core_projected_physical_radial_contraction`.
Replay power controls use `newton_0_radial_power_3` and
`newton_0_radial_power_4`. The `action_difference_over_F` column in `solves.csv`
is the norm of the change in `Jp` from the first correction, divided by the accepted
residual norm. Compare it with the correction difference when investigating weakly
determined directions.
`solves.csv` writes the production `LinearSolveStatus` enumeration numerically:
`0` means converged, `1` maximum iterations, `2` breakdown, `3` non-finite, and
`4` backend failure.
A diagnostic run can still inspect and save an unconverged correction; check
status and the verified true residual before interpreting it as a Newton solve.
### Norms
`physical_l2` and `physical_linf` are Euclidean/max norms of physical FE
**coefficients**, not spatial integrals or physical field extrema. Different
blocks have different units, so summing or directly comparing their unscaled
physical coefficient norms is generally not meaningful.
Normalized norms use the production frozen scaling; their Euclidean combination
is the single-rank norm used by this experiment's Newton/linear diagnostics.
`linear_residual_over_block_F` divides by that equation block's initial normalized
residual. Retain the absolute numerator when interpreting it: a nearly zero
denominator can make a harmless small absolute residual look relatively large.
Surface summary statistics are unweighted over surface parameters, not area-
weighted spherical averages or a spherical-harmonic decomposition.
### Geometry boundaries and ties
The per-element boundary uses the same union of production quadrature rules as
Newton preflight. It is the first sampled determinant boundary in **[0, 1]** along
the supplied direction, not a search over arbitrary positive step sizes.
`limited=0,boundary_step=1` means no boundary was found in that interval; it does
not locate a boundary at one. The safety step is 90% of a limiting boundary.
Each CSV row represents one distinct element. Counts within one part per million
and within one percent of the global smallest boundary measure near-ties between
elements, not the potentially much larger number of near-tied quadrature points.
Element numbers and rule indices are local; this executable uses rank zero only.
For a limited element the row's sample is its actual limiting quadrature point.
For an unlimited element it is the element center, and rule/point indices are -1.
Thus directional-gradient entries are point samples, **not maxima over an entire
element**. The reported minimum accepted/full-step determinants, by contrast,
come from all sampled rules in that element.
Reference-element singular values describe the map from the element integration
coordinates to the undeformed reference mesh. Mapped singular values describe
the DomainMapper map relative to that reference mesh. Total physical element
values combine both Jacobians. Distinguishing these avoids attributing a poor
reference element to the Newton displacement map alone.
The worst 12 distinct elements receive detailed fresh-mapping checks at fractions
`0, 0.25, 0.5, 0.9, 0.99, 0.999` of their own boundary. Mapping-matrix error is
relative to the predicted matrix norm; determinant error is normalized by the
accepted determinant, not by the small near-boundary determinant. Geometry
validity between quadrature samples is not certified by these checks.
For unadvanced seed replay, additional `CASE_vertices` diagnostics pass all eight
vertices of each selected core element (0, 9, 18, 27, 36, 45, 54, 63) to the
**unchanged production estimator**. These use a different sample set, not different
geometry physics or altered tests. Compare vertex and ordinary quadrature
boundaries explicitly: one does not subsume the other. Vertex-only minimum
determinants are minima over those vertices, not over the element interiors or
the full mesh. The cases include uniform contraction, the replayed correction,
its mean/nonmean split, and radial-power controls. Selected elements are filtered
for core attribute 1 and hexahedral geometry; their numbering remains specific
to the sandbox mesh.
Corner probes cover elements 0, 9, 18, 27, 36, 45, 54, and 63, all their vertices,
and inward fractions `0, 1e-5, 1e-4, 0.001, 0.01, 0.05, 0.1` toward each element
center. These element IDs are specifically useful for the sandbox mesh, not a
universal classification for arbitrary `--mesh` inputs. Exact-vertex probes
evaluate only the reference transformation and its Jacobian; they deliberately
avoid inverses at potentially singular vertices.
The diagonal probe samples element 0 at equal integration coordinates `s`, using
`s = 0, 0.005, 0.010885670926971493, 0.02, 0.05, 0.1, 0.2,
0.276393202250021, 0.5, 0.723606797749979, 0.9, 1`. This includes the production
limiting sample and the P3 GaussLobatto interpolation nodes along that diagonal.
It reads the generated direction's true DOFs into a fresh grid function and
directly evaluates FE values and derivatives. The continuous comparison is
`u_radial = corner_surface_amplitude * r_logical^2`; its derivative uses the
logical transformation and the physical reference-radius derivative. Actual and
desired radial derivatives are both divided by `dr_physical/ds` to obtain radial
gradients, exposing separately the nodal interpolant and coordinate amplification.
No Jacobian inverse is used. The comparison is skipped unless element 0 matches
the sandbox's negative diagonal from logical coordinate -1/4 to -1/8, and assumes
logical stellar-surface radius one. It is not a general core-mode decomposition.
For power-control cases the continuous comparison uses the corresponding radial
power instead of two. Appended determinant coefficients represent
`det(J_reference + alpha * dU/dxi) / det(J_reference)`, evaluated directly by
column multilinearity, including at exact vertices. They apply to the
undeformed seed; the caller restricts these probes to that state. These
coefficients allow independent checks outside the production quadrature sample
set and require no matrix inverse.
### Derivative checks
Residual checks use forward differences
`(F(x + epsilon*p) - F(x))/epsilon`, with epsilon equal to the production safe
step times `1e-2`, `1e-3`, and `1e-4`. They compare against the complete normalized
`Jp`, using frozen normalization and fresh production trial preparation.
These are one-sided first-order checks, not central differences. Expect truncation
error to decrease with epsilon until cancellation or preparation/solve error
dominates. A single small error or three nonmonotone errors do not by themselves
establish or refute a Jacobian defect. Forward steps stay in the predicted
positive-direction interval; negative perturbations are not preflighted.
The accompanying extension check compares
`(generated_volume(x + epsilon*p) - generated_volume(x))/epsilon`
against `BuildVolumeDisplacementDirection(physical_p)`. Its relative error is a
Euclidean true-DOF vector norm divided by the expected direction norm. This checks
normalization, surface perturbation, and production volume generation together;
it is distinct from checking the mapper's element-local analytic variation.
## Reproducibility limits
Preserve the console log with the artifact directory. Metadata records useful
configuration identifiers but does not contain a mesh checksum, a complete
compiler flag dump, or a source/worktree snapshot. For controlled comparisons,
also retain the exact mesh, configured build options, and source revision plus
local diff. Do not equate runs with different initial residuals merely because
their executable or mesh filename matches.
An investigator may additionally save `provenance.txt` beside these artifacts;
that file is not currently produced automatically by the executable.

View File

@@ -0,0 +1,237 @@
# Geometry failure diagnosis — 2026-09-08
## Conclusion
The first production Newton correction is small in surface amplitude but produces
large, oscillatory radial displacement gradients near the eight core corners.
The tensor-P3 representation of the prescribed extension distorts the intended
ray-wise profile; the poorly conditioned reference mesh amplifies its gradients.
There is also a verified geometry-safety defect: the production quadrature union
misses inversion between its samples. At the first accepted step length
`alpha = 0.086127771767489855`, the relative mapping determinant is **-0.670218 at
the core corner and -0.428915 at an interior point close to it**, while the
reported limiting quadrature point has determinant **+0.100006**.
Thus this is not merely an approach toward a future singularity: the first
accepted step already corresponds to a folded represented geometry. The
production operators can continue evaluating because their own sampled points
remain admissible.
No physical equations, mapping/extension implementations, Newton policies, or
production tolerances were changed. This investigation implemented an opt-in
experiment and an internal, guarded diagnostic-access hook only.
## Reproduction and artifacts
Build:
```sh
cmake --build cmake-build-release-homebrew --target geometry_quality_experiment -j 6
```
Runs performed, all single-rank Release:
```sh
./cmake-build-release-homebrew/geometry_quality_experiment --output geometry_quality_results_2026-09-08_baseline --tolerances 0.03,0.003 --max-linear-iterations 100 --save-vectors
./cmake-build-release-homebrew/geometry_quality_experiment --output geometry_quality_results_2026-09-08_profile --replay-vectors geometry_quality_results_2026-09-08_baseline/newton_0_vectors.csv --no-fd --no-block-actions
./cmake-build-release-homebrew/geometry_quality_experiment --output geometry_quality_results_2026-09-08_vertices --replay-vectors geometry_quality_results_2026-09-08_baseline/newton_0_vectors.csv --diagonal-only
python3 experiments/summarize_geometry_quality.py geometry_quality_results_2026-09-08_baseline
python3 experiments/summarize_geometry_quality.py geometry_quality_results_2026-09-08_vertices
```
The runs took 510.721, 152.037, and 92.3526 seconds, respectively. Replay checks
the saved accepted state against a fresh context and verifies `Jp+F`, rather than
re-solving for a new correction. Output directories are refused if they already
exist. Help, invalid-tolerance rejection, existing-directory rejection, and normal
context restoration were exercised. `git diff --check` passed. No test suite was
built or run.
The existing build cache contained a nonexistent Eigen 5.0.1 include path. CMake
dependency discovery was refreshed with `-U '*eigen3*'`, locating installed Eigen
3.4.0. See the run provenance and metadata files. The fresh baseline reproduces
the earlier sandbox's seed residual, 23 Krylov iterations, and first safe step.
## 1. Element 0 is the representative of a core-corner pattern
The eight limiting elements are **0, 9, 18, 27, 36, 45, 54, 63**. They are core
elements (attribute 1), not compactified exterior elements. For the Newton
direction their quadrature boundaries differ by only about 0.013%; element 0
has the smallest value. They are near-ties, not exact ties. For uniform
contraction they agree within one part per million.
The actual Newton limiting sample is on element 0's body diagonal:
- Integration coordinates: `(0.010885670927, 0.010885670927, 0.010885670927)`.
- Physical reference position: approximately `(-0.144337126, -0.144337126, -0.144337126)`.
- Physical radius: `0.249999235`, immediately inside the core boundary.
- Reference element Jacobian singular values: minimum `5.51738e-5`, maximum
`0.108818`, condition number approximately **1,972**.
- At the exact corner the corresponding condition number is approximately **3,151**.
The relative displacement mapping at the seed is the identity. Its determinant
of one therefore conceals the conditioning of the underlying reference element.
Evidence: baseline `newton_0_geometry_elements.csv`,
`uniform_contraction_reference_corner_probes.csv`.
## 2. The dangerous correction is small and nonuniform
Unweighted surface-parameter statistics for the first correction:
| Quantity | Fraction of reference radius |
| --- | ---: |
| Mean | -0.000201759 |
| RMS | 0.000360659 |
| Nonmean RMS | 0.000298945 |
| Most negative | -0.000751271 |
| Most positive | +0.000661834 |
The largest displacement is only **0.0751% of the radius**. The negative extrema
occur at the eight cube-corner surface directions; positive extrema occur near
the twelve edge-center directions. The pattern is nearly invariant under cube
symmetries: the largest spread among symmetry-equivalent nodal directions is
`1.3544e-6`, compared with the full amplitude range `0.0014131`.
The mean-only correction has no sampled boundary through alpha=1. Removing the
mean changes the quadrature boundary from `0.0956975242` to `0.0957208926`, only
0.0244%. The nonmean component therefore accounts for almost the entire local
compression. It is not an oversized uniform radius change or a warm-start-only
artifact; the first solve starts cold.
## 3. Direct diagonal inspection identifies the interpolation mechanism
On the negative core-corner ray, the target surface point is fixed. For the
default radial-power-2 prescription and logical stellar radius one, the intended
continuous radial displacement is exactly
`u_r(s) = a_corner * r_logical(s)^2`, with `a_corner = -0.0007504485104433`.
At the Newton limiting sample:
| Quantity | Intended continuous ray profile | Represented P3 FE field |
| --- | ---: | ---: |
| Radial displacement | -4.6393850e-5 | -3.5188044e-5 |
| Physical radial derivative | -0.4881315 | **-10.4495911** |
The derivative is amplified **21.4 times relative to the intended profile**.
The values agree at the P3 interpolation nodes, but disagree between them. At
`s=0.1`, the FE radial displacement even becomes positive although the intended
profile remains negative.
The code constructs the extension from logical-radius weights and surface-trace
interpolation at volume nodes, then represents it in the tensor-P3 volume space.
The corner element spans three logical max-coordinate sectors, projecting toward
three surface faces. The resulting angular/radial composition is not a single
low-degree tensor polynomial inside that element. Its nodal interpolant need not
preserve the prescribed ray-wise radial behavior.
The physical gradient further multiplies by the inverse reference Jacobian.
Near the core corner, the physical radial coordinate changes extremely slowly
along the logical ray. At the limiting sample, `dr_logical/ds=-0.125` but
`dr_physical/ds=-9.55639e-5`.
This explanation is supported by the measured saved mesh, not just by mesher
source. Sibling STROID source suggests why the core map flattens there, but its
working tree is dirty and was not treated as proof of mesh provenance.
Evidence: profile/vertices `newton_0_core_diagonal.csv`; production implementation
`libmeanfield/impl/deformation/radial_extensions.cpp` and field registry P3
displacement versus saved mesh P4 geometry.
## 4. Quadrature safety is not element safety
The unchanged production estimator was run with a second sampling plan containing
the eight vertices of each of the eight core-corner elements. Independently,
the diagnostic formed the polynomial
`det(J_reference + alpha * dU/dxi) / det(J_reference)`
directly from element matrices, without inverse Jacobians. The methods agree.
| Direction | Production quadrature boundary | Vertex boundary |
| --- | ---: | ---: |
| Original Newton correction, radial power 2 | 0.0956975 | **0.0515682** |
| Same surface correction, radial power 3 | 0.382625 | **0.206273** |
| Same surface correction, radial power 4 | No boundary through 1 | **0.825091** |
For the original correction at alpha=0.0861277718:
| Diagonal coordinate s | Relative determinant |
| --- | ---: |
| 0, exact corner | **-0.670218** |
| 0.005, interior point | **-0.428915** |
| 0.01088567, production limiting quadrature point | +0.100006 |
| 0.02 | +0.609367 |
The negative interior value establishes an actual fold, not merely a singular
boundary vertex. Adding vertices would catch this case, but vertex sampling alone
is not a general positivity certificate: the uniform-contraction control has a
much stricter interior quadrature boundary than vertex boundary.
## 5. Controls and ruled-down explanations
### Linear accuracy
| Requested linear tolerance | Krylov iterations | Verified relative residual | Geometry-safe step |
| --- | ---: | ---: | ---: |
| 0.03 | 23 | 0.0244757 | 0.0861278 |
| 0.003 | 37 | 0.00299622 | 0.0865524 |
Tenfold tightening changes the normalized surface correction by 1.30% and the
safe step by only 0.49%. Linear inaccuracy is not the main explanation for this
first-step failure, although this does not establish behavior at every later
state or arbitrary tolerance.
### Mapping and derivative consistency
- Fresh mapped geometry agrees with the preflight affine prediction to about
`6e-16` for the actual Newton correction.
- Finite differences of generated volume displacement agree with the production
extension direction to about `1.6e-16`.
- Material/shape/hydrostatic directional residual differences improve roughly
tenfold with the first tenfold perturbation reduction, reaching relative errors
around `1e-7`, then show roundoff/cancellation.
- Gravity checks improve initially but have smaller net Jacobian actions and
noisier relative errors. Mass finite differences are cancellation-sensitive;
the smallest perturbation is worse. These checks do not prove the entire
Jacobian correct, but reveal no gross inconsistency along this correction.
- The central-density relative error of one has absolute magnitude `1.22e-23`;
it is not evidence of an important phase-border defect.
### Extension and representation controls
Uniform contraction through the default extension has a quadrature boundary of
0.04819 (4.82% surface contraction), independent of Newton/EOS balances.
Core-only P3 projections of physical affine and physical-radius-squared
displacements have boundaries 0.2014 and 0.7693. These are diagnostic bypass
controls, not production solutions. Even physical affine scaling is not exactly
representable by P3 displacement on P4 geometry.
Higher radial powers reduce core sensitivity, but **power 4 is not a demonstrated
fix**: despite all production quadrature points permitting alpha=1, its corner
determinant at alpha=1 is **-0.211997**, and a nearby interior determinant is also
negative. Increasing order or power alone should not be assumed to solve the
problem. In particular, faithfully interpolating the original logical-radial
profile can make the uniform control more singular near a poorly conditioned
reference corner; interpolation sometimes suppresses as well as amplifies it.
## Recommended next work
1. Add this replay direction and the negative interior/corner determinant as a
targeted geometry regression. Extend admissibility sampling to vertices and
near-corner/edge regions, then consider adaptive or bounded determinant
certification for high-order elements. Do not simply reduce the minimum step.
2. Design an extension that controls gradients in physical reference coordinates
and preserves simple radial/affine modes in the represented FE space. Treat
reference-mesh quality and displacement/geometry order compatibility together.
3. Rebuild the coupled residual/Jacobian and solve anew under any changed
extension. Do not accept a post-processed old Newton direction as though it
solved the new Newton equation.
4. Recheck the mesh-symmetric nonuniform surface pattern and block residuals after
repairing the geometry representation. The precise origin of that discrete
pattern, and full nonlinear convergence after a repair, remain unestablished.
The immediate geometric failure mechanism and the sampling defect are now
reproduced. A production remedy and its nonlinear convergence are deliberately
not claimed by this diagnostic experiment.

View File

@@ -0,0 +1,90 @@
# One-level uniform h-refinement — 2026-09-09
## Scope and reproducibility
This study adds exactly one `stroid.refinement.UniformRefinement(mesh, 1)`
to the **loaded coarse solve's input**, without regenerating the initial mesh,
changing field orders, or changing the model or solver tolerances. The original
`sandbox.smesh`, sandbox executable, and all existing coarse data are preserved.
The current MeanField release links an older STROID archive in `/usr/local`
which lacks the multiblock configuration field. Its refinement operation would
therefore not preserve the intended mapping strategy. Instead,
`experiments/refine_polytrope_mesh.py` uses the installed STROID 0.5.0 Python
binding in the `stroidDev` environment to load, refine and save a complete mesh.
The validation executable loads that refined snapshot with `extraRefine=0`.
No STROID installation, production-library edit or rebuild is involved.
STROID refines the logical reference mesh and reprojects high-order geometry.
The study therefore measures joint geometry/field h-convergence, not subdivision
of an unchanged physical polynomial geometry. The separately refined analytic
control measures the geometry contribution.
| Property | Coarse | Refined |
| --- | ---: | ---: |
| Total hex elements | 1,216 | 9,728 |
| Stellar hex elements | 832 | 6,656 |
| Stored refinement level | 2 | 3 |
| Geometry order | 4 | 4 |
| Density/potential DG order | 2 | 2 |
| Enthalpy/displacement H1 order | 3 | 3 |
| Gravity RT index (reported element order) | 2 (3) | 2 (3) |
| Nonlinear absolute/relative tolerance | 1e-8 / 1e-8 | 1e-8 / 1e-8 |
| Linear relative tolerance / iteration cap | 0.03 / 80 | 0.03 / 80 |
| Volume quadrature orders | 14, 18 | 14, 18 |
The model remains nonrotating n=1, G=M=R=1, K=2/pi, central density pi/4,
zero surface pressure. The coarse accepted solution is reused, not re-solved.
Interior physical-volume errors, shape and virial balance are the acceptance
focus; exterior profiles are diagnostic only insofar as they influence the
interior. Two levels give an observed reduction, not proof of asymptotic order.
## Artifacts and provenance
- Coarse solve: `polytrope_solution_2026-09-09/`.
- Coarse analytic control: `polytrope_analytic_checked_2026-09-09/`.
- Refined mesh and binding provenance: `polytrope_h1_mesh_2026-09-09/`.
- Refined analytic control: `polytrope_h1_analytic_2026-09-09/`.
- Refined solve: `polytrope_h1_solution_2026-09-09/`.
SHA-256 hashes at study start:
~~~text
coarse input / sandbox.smesh
ce11ae99e21e6c3bbfbee402acd2e191c1da0d8261d2227b4203f73f2f337a74
refined.smesh
39cf76d21c74730ffaacf50ae66e150d2e88dcb66caf6a499339b7e90a1f8182
release polytrope_validation_experiment
90caa65be08c5d9dd17ddab267d02159ea440b94304e7d0d4aa7807e688009af
release sandbox (not run in this study)
a904b39935a5add938129e5c3edb72f94b416f0e09ba5b3b937a8004fa463057
~~~
The refinement JSON additionally records the actual STROID native-extension
path/hash, interpreter, regional element counts and configuration checks.
Current mesh-based outputs each retain the fully refined `input.smesh`, so
ordinary saved-field replay requires no new refinement or regeneration.
## Analytic geometry control
All 140 independent analytic self-checks and all 24 refined physical-control
screens passed. These are analytic fields evaluated on the represented mesh,
not an FE equilibrium solution: zero volume field errors are by construction.
| Control | Coarse | Refined |
| --- | ---: | ---: |
| Surface-radius relative RMS error | 8.91509e-6 | 4.29640e-7 |
| Volume-radius relative error | 9.062e-9 | 7.63599e-11 |
| Relative mass error | 3.922e-10 | 5.01155e-13 |
| Virial error | 2.615e-10 | 7.19869e-13 |
| Invalid stellar corner/inset samples | 0 | 0 |
| Profile points located | 4,020 / 4,020 | 4,020 / 4,020 |
The analytic surface error decreases about 20.75-fold. The refined virial is
already near its observed quadrature sensitivity (2.43e-13), so its enormous
two-level ratio should not be interpreted as a convergence order.
## Refined equilibrium
The refined solve is in progress. No equilibrium h-convergence conclusion is
available until its accepted fields have been measured.

View File

@@ -0,0 +1,377 @@
# Nonrotating n=1 polytrope verification
This experiment separates nonlinear stopping criteria from physical accuracy.
It uses a closed-form reference independent of the production LaneEmden seed,
then measures the actual mapped mesh and, optionally, the last accepted
production state. It does not modify the physical equations, nonlinear policy,
mesh, or existing sandbox.
The target is `polytrope_validation_experiment`, declared `EXCLUDE_FROM_ALL`.
Build only that target:
~~~sh
cmake --build cmake-build-release-homebrew --target polytrope_validation_experiment -j 6
~~~
The executable requires one MPI rank and uses the CPU device. The following
sequence is deliberately bounded: analytic self-checks, an analytic-field mesh
check, then **one** production solve. Choose fresh output directories; existing
directories are refused.
~~~sh
./cmake-build-release-homebrew/polytrope_validation_experiment --self-check --output polytrope_self_checks
./cmake-build-release-homebrew/polytrope_validation_experiment --analytic-mesh --mesh sandbox.smesh --output polytrope_analytic_mesh
./cmake-build-release-homebrew/polytrope_validation_experiment --solve --mesh sandbox.smesh --output polytrope_solution
~~~
Inspect each stage before proceeding. The commands are usage examples, not a
claim that the corresponding runs passed. Results belong in a separate findings
report. No full test suite or parameter/resolution sweep is part of this
workflow.
For an existing completed output with profiles, the standard-library-only
summarizer can generate a static SVG and Markdown report without rerunning the
solver or changing its CSVs:
~~~sh
python3 experiments/summarize_polytrope_validation.py polytrope_solution --control-directory polytrope_analytic_mesh
~~~
## One additional uniform h-refinement
Use the saved coarse input, not a newly generated mesh, and retain a separate
refined snapshot. The helper calls `UniformRefinement(loaded_mesh, 1)`, verifies
eight children per hexahedron in each material region, and checks saved-mesh
reload and input immutability. It records mesh and STROID extension hashes.
~~~sh
/opt/homebrew/anaconda3/envs/stroidDev/bin/python3.14 experiments/refine_polytrope_mesh.py polytrope_solution_2026-09-09/input.smesh polytrope_h1_mesh
env OMPI_MCA_btl=self ./cmake-build-release-homebrew/polytrope_validation_experiment --analytic-mesh --mesh polytrope_h1_mesh/refined.smesh --output polytrope_h1_analytic
env OMPI_MCA_btl=self ./cmake-build-release-homebrew/polytrope_validation_experiment --solve --mesh polytrope_h1_mesh/refined.smesh --output polytrope_h1_solution
python3 experiments/compare_polytrope_refinement.py polytrope_solution_2026-09-09 polytrope_h1_solution --coarse-control polytrope_analytic_checked_2026-09-09 --fine-control polytrope_h1_analytic --output polytrope_h_comparison
~~~
Inspect the analytic control before launching the solve. Keep field orders,
model, quadrature and solver tolerances fixed. The full refined solve may take
well over an hour; reuse the coarse solve rather than repeating it.
The Python environment above contains multiblock-capable STROID 0.5.0. The
current MeanField build links an older `/usr/local` STROID that can load the
saved geometry but cannot correctly regenerate multiblock geometry from its
configuration. Therefore refinement is performed by the current Python binding,
not by that old linked library. No installation or relinking is needed.
The helper fails if `core_mapping` support is absent.
STROID refines the logical mesh and **reprojects the high-order geometry**;
this is not subdivision of a fixed physical polynomial geometry. Consequently
the comparison includes geometry approximation as well as FE field refinement.
Run the analytic control on both levels to quantify the geometry contribution.
The refined file is complete: the experiment uses `extraRefine=0`, and its
ordinary `input.smesh`/GF snapshots can be replayed without additional refinement
or the newer STROID library.
Two-level ratios `E_coarse/E_fine` and `log2(E_coarse/E_fine)` are observed
reductions, not proof of asymptotic order. Prioritize stellar volume field
errors, shape and virial balance. Exterior profile accuracy is diagnostic only,
relevant insofar as it affects the interior solution. Near-zero integral errors
and errors near the nonlinear/quadrature floor do not give reliable h-rates.
## Modes and controls
| Option | Meaning/default |
| --- | --- |
| `--self-check` | Check the independent reference without loading a mesh or constructing a solver. |
| `--analytic-mesh` | Evaluate exact fields at physical points on the actual mesh; exercise volume/surface integration and radial location without a Newton context. |
| `--solve` | Default mode. Construct the production n=1 problem, solve within the stated budget, and measure the last accepted state even after nonlinear failure. |
| `--replay DIRECTORY` | Re-measure saved nonrotating solve-mode fields using that directory's `input.smesh`; no Newton context or solve. Requires a new `--output` directory. |
| `--mesh FILE` | Default `sandbox.smesh`; input is not overwritten. |
| `--output DIRECTORY` | Default `polytrope_validation_results`; must not already exist. Its parent directory must exist. |
| `--absolute-tolerance VALUE` | Nonlinear absolute tolerance, default `1e-8`. |
| `--relative-tolerance VALUE` | Nonlinear relative tolerance, default `1e-8`. |
| `--linear-tolerance VALUE` | Relative linear tolerance, default `0.03`. |
| `--max-newton N` | Default 8 nonlinear iterations. |
| `--max-linear-iterations N` | Default 80 linear iterations per solve; FGMRES restart length is 40. |
| `--quadrature-order N` | Base physical-volume quadrature order, default 14. |
| `--check-quadrature-order N` | Independent higher order, default 18; must exceed the base order. Also used for surface integration. |
| `--skip-profiles` | Omit physical-shell/ray location and its output; other measurements remain enabled. |
| `--mu-points N`, `--phi-points N` | Angular quadrature sizes, default 6 and 12. Increase during cheap replay to check spherical-mean sampling sensitivity. |
| `--exterior-shells N` | Number of finite-exterior radii between 1.001R and 2R, default 8. Increase during replay to resolve radial structure. |
Select one mode explicitly. The parser accepts the last mode flag if several
are provided. Every mode first runs the independent analytic self-checks.
Exit codes are `0` for all requested checks passing, `1` for nonlinear failure,
`2` for an execution/input error, and `3` for verification failure. In solve
mode, nonlinear failure takes precedence over a subsequent physical-screen
result; consult the saved metrics and metadata as well as the exit code.
Replay retains that precedence using the source's historical convergence status.
`--analytic-mesh` is **not** an FE projection test or a numerical equilibrium
solution. Exact fields are evaluated directly, so volume field-versus-reference
errors are zero by construction at the same successfully evaluated points.
Profile comparisons instead use the requested physical radius and therefore
also test the locator's accuracy. The mode checks integration, physical
location, reference-domain geometry, and integral identities on that domain.
### Diagnostic replay without another solve
After a solve-mode output has saved its grid functions and completed
`physical_metrics.csv`, physical postprocessing can be repeated independently:
~~~sh
./cmake-build-release-homebrew/polytrope_validation_experiment --replay polytrope_solution --output polytrope_solution_replay
~~~
Replay requires an original `mode=solve` output for this nonrotating n=1
benchmark, matching compiled \(G,M,R\), and saved angular velocity exactly zero.
It loads the saved mesh and the five GF files into compatible FE spaces. The
saved accepted coefficients need not have passed the physical screen, but all
required source artifacts must be present. Keep the original solve output:
a replay output is not itself an accepted replay source.
Volume/surface metrics and requested profiles are recomputed. Solver convergence,
bordered/unbordered residual norms, central-border action/value, Bernoulli
constant, and angular-velocity norm are historical source diagnostics, **not
recomputed**. Metadata records
`solver_diagnostics=copied_from_source_not_recomputed`. In particular, a replay
pass is not a new residual evaluation or convergence claim, and solver-tolerance
options do not trigger a fresh solve. The source's nonlinear failure still
produces exit code `1` after successful physical postprocessing.
## Fixed analytic reference and normalization
The benchmark uses the compiled `utils::G`, `utils::MASS`, and `utils::RADIUS`
as fixed positive \(G,M,R\); their values are saved in `metadata.txt`. It does
not fit mass, radius, central density, or a potential offset to the numerical
solution. For \(\xi=\pi r/R\), the stellar solution is
\[
\theta=\frac{\sin\xi}{\xi},\qquad
K=\frac{2GR^2}{\pi},\qquad
\rho_c=\frac{\pi M}{4R^3},\qquad h_c=\frac{GM}{R},
\]
\[
\rho=\rho_c\theta,\qquad h=h_c\theta,\qquad P=K\rho^2,\qquad
\Phi=-h_c(1+\theta),\qquad
m(r)=\frac{M}{\pi}(\sin\xi-\xi\cos\xi).
\]
The radial potential gradient is outward-positive \(g_r=d\Phi/dr=Gm(r)/r^2\);
the acceleration is its negative. Outside the star the analytic material fields
are zero, \(\Phi=-GM/r\), and \(g_r=GM/r^2\). The implementation uses origin
series and a small-distance-to-surface expression, including exact values at
the center and surface. Reference radii must be finite and nonnegative.
The profile normalizations are fixed:
\[
\theta_\rho=\rho/\rho_c,\qquad
\theta_h=h/h_c,\qquad
\theta_\Phi=-R\Phi/(GM)-1.
\]
All three agree with \(\theta\) inside the analytic star. The normalized vacuum
potential is negative outside \(R\), approaching \(-1\), and is not clipped.
Numerical density and enthalpy are likewise never clipped. Negative samples and
their minima are reported. Since \(P_\rho=K\rho^2\) and
\(P_h=h^2/(4K)\) are positive even for negative arguments, pressure checks alone
do not establish physical positivity.
The independent integral references are
\[
\Pi=\int P\,dV=\frac{GM^2}{4R},\qquad
W=-\frac{3GM^2}{4R},\qquad
I_z=\frac23\left(1-\frac6{\pi^2}\right)MR^2.
\]
The self-checks use independent radial Simpson integration, including nonunit
scales, origin regularity, surface/vacuum joins, EOS and hydrostatic identities,
and the gravitational-field energy with its exterior contribution.
## Physical balances and interpretation
The volume measurements integrate over the current **stellar material**
elements with the full physical Jacobian. They compare fields at their actual
physical positions, not at logical radii. Relative volume \(L^2\) errors use
the analytic field's \(L^2\) norm over that same domain. Surface and
volume-equivalent radius errors separately measure the domain discrepancy.
Let \(g\) denote the reconstructed physical mixed gravity field and
\(\Psi=|\Omega\times x|^2/2\). The reported energies are
\[
T=\int\rho\Psi\,dV,\quad
W_\Phi=\tfrac12\int\rho\Phi\,dV,\quad
W_g=-\int\rho\,x\cdot g\,dV,\quad \Pi=\int P_\rho\,dV.
\]
The scalar virial error is
\(|2T+W_\Phi+3\Pi|/|W_\Phi|\); `virial_signed` retains its sign and
`virial_ratio` is \((2T+3\Pi)/|W_\Phi|\), which should approach one.
The force virial replaces \(W_\Phi\) by \(W_g\), keeping the same denominator.
`gravity_energy_consistency` measures \(|W_\Phi-W_g|/|W_\Phi|\).
These are complementary checks: an inaccurate potential and an inaccurate
mixed gravity field need not fail identically.
The central-density constraint is imposed through central enthalpy and an
additional hydrostatic border. Its reported achieved density is inferred from
that enthalpy; it is not an independently sampled DG density value. The
`unbordered` residual removes only the artificial central-border action from
hydrostatic rows, then uses the same production normalization. It retains the
physical Bernoulli constant and all scalar constraint rows. A small bordered
solver residual does not by itself establish a small unbordered physical
residual.
The potential is discontinuous across elements. Its elementwise, or *broken*,
gradient omits interface jumps; it is not the same discrete object as the
mixed H(div) gravity field. Their reported gradient mismatch is a diagnostic,
not a requirement of pointwise equality at finite resolution. The strong
enthalpy-gradient balance is also distinct from the weak assembled residual.
Bernoulli statistics concern \(h+\Phi-\Psi\). Both its mean error against the
fixed analytic constant \(-h_c\) and its spatial variation are saved. Computing
a centered variance does not fit or subtract a potential gauge from the fields.
### Weak EOS closure and the pressure projection floor
The default density space is DG/L2 order 2, while enthalpy is continuous H1
order 3; see [the field registry](../libmeanfield/interface/field/field_registry.cppm).
The [prepared closure](../libmeanfield/impl/operators/prepared_barotropic_closure.cpp)
assembles \(F_i=\int q_i(\rho-h/(2K))\,dV\) at \(n=1\), using the full physical
volume weight and density-space test functions. Thus, at fixed geometry and
zero closure residual, density is the quadrature-weighted \(L^2\) projection of
\(h/(2K)\), not necessarily its pointwise value. The seed's independent
coefficient projections do not themselves enforce this orthogonality.
The [pressure-force kernel](../libmeanfield/impl/operators/prepared_pressure_force.cpp)
uses \(P(h)=h^2/(4K)\), rather than the diagnostic's primary
\(P(\rho)=K\rho^2\); these formulas follow from the
[polytropic EOS](../libmeanfield/interface/eos/polytropic.cppm) for admissible
nonnegative enthalpy. With \(\delta=h-2K\rho\), define
\[
E=\int\frac{\delta^2}{4K}\,dV,\qquad
D=\Pi_h-\Pi_\rho.
\]
The algebraic identity, using the same domain and quadrature, is
\[
D=E+\int\rho\delta\,dV,\qquad
D-E=-2K\,\boldsymbol{\rho}^{\,T}\mathbf F_{\rm closure}.
\]
Consequently, exact weak closure gives \(D=E\ge0\), even when the strong EOS
mismatch \(\delta\) is nonzero. A pointwise mismatch can therefore persist at
a well-converged discrete solution without indicating an EOS/Jacobian algebra
bug. Changing diagnostic quadrature introduces an additional discrepancy in
the residual-pairing identity and should be checked separately.
The observer records `closure_projection_pressure_gap` (\(E\)),
`closure_density_inner_product` (\(\int\rho\delta\,dV\)), and
`closure_projection_pressure_gap_relative_defect`
(\((D-E)/\Pi_{\rm reference}\)). Older metric files also allow reconstruction
of \(E=Vh_c^2\,\text{eos_enthalpy_scaled_rms}^2/(4K)\).
Inspect these alongside the complete `barotropic_closure` residual block:
a small single global pairing can conceal cancellation and does not prove
every closure equation is satisfied.
`enthalpy_virial_error` and `enthalpy_force_virial_error` substitute \(\Pi_h\)
into the two virial diagnostics. They complement, not replace, the original
density-pressure checks. The predetermined pointwise EOS screening budget is
not relaxed: it may identify a finite-resolution projection floor requiring
further discretization study. Diagnostic replay can produce the additional
metrics from saved fields without another Newton solve.
## Shell and ray sampling
Default profiles use the origin, 32 interior radii through \(0.99R\), one
additional radius at \(0.999R\), and eight exterior radii from \(1.001R\) to
\(2R\). Each noncentral sphere uses six Gauss points in \(\mu=\cos\vartheta\)
and twelve uniformly spaced azimuths. The weights sum to one; angular moments
and exact constant-field weighted mean/variance are self-checked before
measurement. Separate 26-ray profiles contain six axes,
twelve face diagonals, and eight body diagonals. Rays are not angular quadrature.
The locator inverts the full physical mapping. Sampled element bounds prioritize
searches but do not exclude elements; missing points may therefore be expensive.
Location error, attempts, and coverage are reported.
Shell means are conditional on successfully located finite values. Density and
enthalpy are additionally conditional on **stellar material**. In particular,
their means near a displaced surface are not whole-sphere density/enthalpy
averages: inspect material and valid-weight coverage. Missing/exterior material
values are not silently replaced by zero. Angular spread and total RMS error
against the fixed radial reference are both written. Axis/diagonal DG values
can be one-sided traces at element interfaces. The origin is explicitly a
single trace, not an angular average; a radial gravity component is undefined
there.
## Output files
| File | Contents |
| --- | --- |
| `metadata.txt` | Mode, input mesh/snapshot, compiled scales/settings, FE orders, and available solve/measurement status and timing. |
| `input.smesh` | Exact input-mesh copy used to construct the FE spaces in each mesh-loading mode, including replay. |
| `analytic_self_checks.csv` | Independent check, observed/expected values, fixed scale, errors, tolerance, pass flag. |
| `volume_metrics_base.csv` | Physical-volume metrics at the base quadrature order. |
| `quadrature_comparison.csv` | Base/check values and their absolute/relative changes. Relative changes of nearly zero metrics require caution. |
| `physical_metrics.csv` | Higher-order volume metrics, surface/corner metrics, optional profile summaries, and available solver/border diagnostics. |
| `seed_physical_metrics.csv` | Production seed's lower-order volume metrics and residuals, measured before Newton to expose any physical degradation during correction. |
| `verification_checks.csv` | Declared screening budgets, observations, and individual pass flags. |
| `radial_profiles.csv` | Physical-shell means, angular spreads, analytic values, scaled errors, coverage, and normalized profiles. |
| `directional_profiles.csv` | Axis/diagonal values, analytic values, element/material identity, and location errors. |
| `newton_history.csv` | Solve-mode iteration residuals, accepted steps, trial counts, and linear/nonlinear diagnostics. |
| `residual_blocks.csv` | Solve-mode physical and normalized block norms, with and without the central border. |
| `field_reconstruction.csv` | Reduced/full field sizes and coefficient round-trip errors. |
| `accepted_state.txt`, `state_layout.csv` | Solve-mode last accepted coefficient vector and block layout. |
| `density.gf`, `enthalpy.gf`, `potential.gf`, `gravity_gradient_reference.gf`, `displacement.gf` | Solve-mode reconstructed grid functions saved before expensive postprocessing. |
| `polytrope_profiles.svg`, `polytrope_summary.md` | Optional summarizer products, generated from existing CSVs; may be regenerated independently. |
The text/GF data are **experiment artifacts, not a production checkpoint or a
supported solver-restart format**. Their supported reuse is the constrained
diagnostic replay described above, not continuation of Newton iterations.
Preserve the original solve output, saved input mesh, and metadata. In
particular, `gravity_gradient_reference.gf` contains the reference-mesh Piola
representation, not the already transformed physical gravity field; its
interpretation requires the corresponding mapping and displacement. Unsupported
material coefficients in saved grid functions are not numerical vacuum data.
## Screening budgets and limits
Budgets are declared in the driver before solving. The default screen requires:
- Relative mass, radius, density/enthalpy/potential/gravity \(L^2\), binding
energy, pressure integral, and axial inertia errors at most `1e-4`.
- Bernoulli scaled RMS variation at most `1e-4`.
- Scalar/force virial errors, gravity-energy disagreement, and EOS enthalpy
scaled RMS at most `1e-6`.
- Virial quadrature change and binding-energy relative quadrature change at
most `1e-8`; normalized unbordered residual at most `1e-8` when available
(fresh in solve mode, historical in replay).
- Maximum negative density/enthalpy excursions divided by their fixed central
scales at most `1e-8`; the underlying negative values are not altered.
- Zero invalid stellar corner samples and, when enabled, zero missing profile
points; kinetic energy at most `1e-14` in the compiled benchmark units.
- In `--analytic-mesh` mode with profiles enabled, maximum scaled pointwise
profile error at most `1e-8` and maximum scaled angular RMS at most `1e-10`
as independent location and spherical-scatter control gates.
All raw metrics remain available, including quantities without pass/fail
budgets. The corner checks include vertices and nearby interior points but
cannot certify positivity everywhere in a high-order element. Two quadrature
orders test integration sensitivity, not spatial-discretization convergence.
A passing single-resolution screen is not a physical convergence certificate;
a small nonlinear residual is not one either. Future work should separate
quadrature, mesh/order, and nonlinear-tolerance errors using planned, bounded
resolution studies, rather than starting broad sweeps automatically.
The user provided an independent ESTER executable at
`/Users/tboudreaux/Programming/ESTER_polytrope.pub/ester` and the command
`ester < dati_ester_polytrope`. This is a future cross-validation path only:
ESTER is not integrated or run by this experiment. Resolve the relative input
in its intended project working directory, and reconcile units, boundary
conditions, rotation, potential gauge, and diagnostic definitions before a
future comparison.

View File

@@ -0,0 +1,252 @@
# Physical validation findings — 2026-09-09
## Outcome
The nonrotating n=1 model reaches a well-converged **discrete** equilibrium, and
its global virial balance passes the initial 1e-6 screening budget. It does **not**
yet pass the full analytic-accuracy screen. In particular, the exterior potential
has roughly 1.31.4% errors near the surface, and several interior field/shape
errors exceed the declared 1e-4 budgets. This is not yet a physical-accuracy
sign-off for performance work.
These results do not establish that the formulation is wrong: one discretization
cannot distinguish ordinary approximation error from a formulation bias or
implementation defect. They do establish that further Newton convergence alone
is not an adequate verification strategy.
The implemented experiment and commands are documented in
[POLYTROPE_VALIDATION.md](POLYTROPE_VALIDATION.md).
## Scope and reproducibility
- Added an opt-in `polytrope_validation_experiment` target and experiment-only
reference, reconstruction, integration, profile, replay, and reporting code.
- No production physics, solver defaults, sandbox source/executable, or input
mesh was changed by this implementation.
- Ran **one** production Newton solve, approximately 864 seconds including
context setup and diagnostics. Three subsequent saved-field replays required
no Newton context or linear solves. Timings are not an isolated performance
benchmark; a diagnostic build overlapped part of the solve.
- Ran 140 independent analytic checks, mesh-based analytic controls, and angular
sampling checks. Did not run the full test suite or ESTER.
- The input mesh and sandbox executable SHA-256 hashes were unchanged:
`ce11ae99e21e6c3bbfbee402acd2e191c1da0d8261d2227b4203f73f2f337a74`
and `a904b39935a5add938129e5c3edb72f94b416f0e09ba5b3b937a8004fa463057`,
respectively. Every mesh-based run retains its own `input.smesh` copy.
The model has G=M=R=1, K=2/pi, central density pi/4, zero angular momentum,
and zero surface pressure. The current mesh has 1,216 elements and the state has
178,074 values. Density and potential use DG order 2, enthalpy and displacement
use H1 order 3. Gravity uses MFEM RT index 2 (reported element order 3).
The geometry's order 4 does not make all solution fields fourth order.
## Data and plots
| Artifact | Location |
|---|---|
| Original solve, seed baseline, coefficient/GF snapshots, residual blocks | [polytrope_solution_2026-09-09](../polytrope_solution_2026-09-09/) |
| Corrected analytic-mesh control | [polytrope_analytic_checked_2026-09-09](../polytrope_analytic_checked_2026-09-09/) |
| Default 6x12 angular replay | [polytrope_replay_2026-09-09](../polytrope_replay_2026-09-09/) |
| 12x24 angular replay, 64 exterior shells | [polytrope_replay_dense_2026-09-09](../polytrope_replay_dense_2026-09-09/) |
| 24x48 angular replay, 64 exterior shells | [polytrope_replay_angular24_2026-09-09](../polytrope_replay_angular24_2026-09-09/) |
| Main generated numerical report | [polytrope_summary.md](../polytrope_replay_angular24_2026-09-09/polytrope_summary.md) |
![Fixed-reference profiles, mean errors, angular scatter, and coverage](../polytrope_replay_angular24_2026-09-09/polytrope_profiles.svg)
The original `polytrope_analytic_mesh_2026-09-09` control is retained for
provenance, but its angular RMS statistic contained the roundoff artifact
described below. Use the **checked** control above for current interpretation.
The default-grid saved GF replay reproduced every pre-existing aggregate physical metric exactly;
new pressure/projection metrics were then added without rerunning Newton.
## Independent controls
All 140 closed-form checks pass, including non-unit scales, central/surface
limits, exterior potential, derivatives, and independent radial mass/energy
integrals. No production LaneEmden integration or seed helper supplies the
reference values.
On the actual undeformed mesh, exact analytic fields give:
| Control | Result |
|---|---:|
| Relative mass error | 3.92e-10 |
| Relative binding-energy error | 2.62e-10 |
| Virial error | 2.61e-10 |
| Force-based virial error | 5.23e-10 |
| Change in virial between quadrature orders 14 and 18 | 1.38e-13 |
| Located profile points | 4,020 / 4,020 |
| Maximum scaled pointwise sampling error | 4.96e-11 |
| Corrected maximum scaled angular RMS | 2.12e-11 |
The first weighted variance update initially introduced a one-ulp contribution
to the second moment, creating spurious angular RMS values near 1e-9. Exact
first-sample initialization fixed this; a constant-field zero-variance check now
guards it. This did not change the numerical volume integrals or Newton solve.
Control limitations matter: volume field errors are zero by construction because
the same independent reference supplies the analytic fields and comparisons.
Those zeros are not tests of FE representability. The nonzero global integral
errors test integration/geometry, and requested-radius profile errors test the
inverse mapping. Quadrature agreement alone does not resolve the extremely thin
layer where the approximate surface crosses the exact analytic support.
The undeformed surface has RMS radius error **8.92e-6 R**, despite a much smaller
volume-equivalent radius bias of 9.06e-9 R. Signed surface errors cancel in the
volume; this is not 1e-8 local surface accuracy.
## Nonlinear and physical results
The experiment used nonlinear absolute tolerance 1e-8, relative tolerance 1e-8,
linear relative tolerance 0.03, and an 80-iteration linear limit. It stopped
before attempting another increasingly expensive near-floor correction.
| Accepted step | Nonlinear residual afterward | Linear iterations | Step length |
|---|---:|---:|---:|
| Initial seed | 1.81187e-4 | — | — |
| 1 | 7.41173e-6 | 24 | 1 |
| 2 | 2.20576e-7 | 25 | 1 |
| 3 | 6.60533e-9 | 28 | 1 |
All field L2 errors below use the full three-dimensional, physical-volume-weighted
stellar domain and fixed analytic scales/radii, not fitted spherical profiles.
| Diagnostic | Final value | Initial screening budget |
|---|---:|---:|
| Relative mass error | 2.36e-11 | 1e-4 |
| W = 0.5 integral rho Phi | -0.750000539807 | Exact -0.75 |
| Integral P(rho) | 0.250000102482 | Exact 0.25 |
| Virial ratio 3 integral P(rho) / abs(W) | 0.999999690184 | Exact 1 |
| Virial error using P(rho) | 3.10e-7 | 1e-6 |
| Force-based virial error using P(rho) | 3.73e-7 | 1e-6 |
| Virial error using production P(h) | 2.62e-7 | Same 1e-6 comparison |
| Force-based virial error using P(h) | 3.25e-7 | Same 1e-6 comparison |
| Gravity-energy consistency error | 6.30e-8 | 1e-6 |
| Density relative L2 error | 4.70e-4 | 1e-4 — fails |
| Enthalpy relative L2 error | 3.27e-4 | 1e-4 — fails |
| Potential relative L2 error, stellar interior only | 1.26e-4 | 1e-4 — fails |
| Gravity-gradient relative L2 error | 5.98e-4 | 1e-4 — fails |
| Surface radius RMS error / R | 2.75e-4 | 1e-4 — fails |
| Volume-equivalent radius error / R | 2.07e-4 | 1e-4 — fails |
The full screen also flags pointwise EOS mismatch and Bernoulli variation. All
original budgets remain visible and unchanged. No density/enthalpy negativity
was found at the volume quadrature samples. Higher-order integration changes
the numerical virial by only 3.14e-14: these discrepancies are not explained by
the diagnostic volume quadrature order.
Comparing seed and final state at the **same quadrature order**, density error
worsens 1.43x, enthalpy error 12.8x, and potential error 1.31x. Gravity error falls
to 0.694 of its initial value; mass, binding energy, moment of inertia, and virial
balance improve. Newton solves the discrete equations, not the continuum
reference-error minimization problem.
## What is and is not responsible
### Geometry remains healthy; spherical accuracy does not
All 19,968 stellar corner/inset samples are valid. The worst element condition
number is 3.466, versus 3.464 before the solve. The smallest sampled relative
mapping determinant is 0.99632 at the corner/inset samples. This is not the old
folding/step-collapse mechanism.
Nevertheless, surface RMS radius error grows approximately 31x. The final mean
radius is 0.999793481 R, and sampled radii range from 0.999381621 R to
1.000574426 R. There is both a mean contraction and an aspherical component
(approximately 1.82e-4 R RMS). Center-of-mass displacement is only 1.79e-6 R and
cannot explain that shape error.
### The central-density border is small
The central border coefficient is -4.64e-13. Its normalized residual action is
1.25e-9; removing it changes the full residual norm from 6.61e-9 to 6.72e-9.
The unbordered equations still satisfy the 1e-8 screening budget. This is not a
large artificial center force masking the observed 1e-4-level field errors.
The constrained central enthalpy is exactly 1; the independently sampled central
DG density is 0.7853956513 versus the prescribed pi/4 = 0.7853981634.
### The pointwise EOS mismatch has a verified projection component
The code enforces closure weakly in the density space, while the pressure force
uses P(h). Put delta = h - 2K rho. Then
integral P(h) - integral P(rho)
= integral delta^2/(4K) + integral rho delta.
For a converged density-space projection, the last term vanishes, but the
nonnegative squared-error term can remain because density and enthalpy use
different spaces. Numerically:
- Measured pressure-integral difference: 1.20602555e-8.
- Predicted squared projection contribution: 1.20605303e-8.
- Difference: -2.75e-13, or -1.10e-12 of the analytic pressure integral.
- Normalized closure-block residual: 6.71e-12.
- Pointwise EOS RMS scaled by central enthalpy: 8.57e-5.
Thus the remaining pointwise EOS RMS is not evidence that Newton failed to solve
the weak closure. This explanation does **not** remove the independent field,
shape, or exterior-potential discrepancies.
## Spatial discrepancies that global virial balance misses
### Exterior potential
At r=1.001 R, the 24x48 angular sample has mean potential error **+0.012866 GM/R**,
approximately 1.29% of the analytic potential magnitude there. The angular RMS
is only 8.76e-5 GM/R: the error is predominantly radial.
The worst saved directional sample has error 0.0139741 GM/R on the (+,-,+) body
diagonal, in exterior element 923; all eight body diagonals have almost the same
error. Its inverse-location error is only 1.73e-15 R. This is not a single-element
or locator accident.
Along that ray, samples from 1.001 R through 2 R all lie in the same exterior
element. The error changes sign with radius rather than behaving like a constant
potential offset. A coarse exterior potential representation is a plausible
cause, but an exact-space approximation comparison is needed to establish it.
The small stellar-interior potential L2 error in the earlier table excludes this
vacuum region.
### Cube-aligned gravity traces and angular sampling
At r=0.556875 R, fixed rays give gravity-gradient errors of approximately
+0.01167 GM/R^2 on body diagonals, -0.00355 on face diagonals, and +0.000674 on
axes. Each symmetry family is internally close. These are significant localized
trace errors, not deteriorated element conditioning. Because RT tangential
components can have one-sided traces on block seams, their solid-angle extent
has not yet been established.
Angular sampling sensitivity is measurable:
| Angular grid | Mean radial-gravity error at 0.556875 R | Mean potential error at 1.001 R |
|---|---:|---:|
| 6x12 | -6.0520e-4 | +1.27950e-2 |
| 12x24 | +5.3099e-4 | +1.29368e-2 |
| 24x48 | -1.8616e-5 | +1.28660e-2 |
Errors use fixed GM/R^2 and GM/R scales, respectively. The exact radial means
should not yet be treated as angularly converged. The roughly 1.3% exterior
potential discrepancy survives all three grids. The full 3D volume errors and
energy integrals are independent of this spherical sampling choice. The densest
replay located all 114,268 requested points and used 64 finite-exterior shells.
## Recommended next work
1. Use the saved fields for a cheap representation audit: compare the exterior
potential with the best approximation of exact -GM/r in the identical
potential space on the finite first exterior cells; densely sample both
one-sided interface traces. Do not form a global physical L2 potential norm
over the entire infinite exterior, where the exact 1/r potential is not
square-integrable.
2. Perturb the axis/face/body-diagonal rays slightly off the block seams to
determine whether the gravity extrema occupy finite angular regions or are
mainly trace effects.
3. Perform a controlled mesh/order convergence study, retaining separate field,
surface, virial, and border metrics. Do not replace this with a tighter Newton
tolerance on the same discretization.
4. Only after the analytic case is satisfactory, use the separately planned
ESTER comparison for rotating/nonanalytic models. ESTER was not run here.
The tools needed to separate nonlinear convergence from physical accuracy are
now in place. Global balance is encouraging; the field and exterior checks
show why it is too early to certify this model as physically verified.

View File

@@ -0,0 +1,482 @@
#!/usr/bin/env python3
"""Compare two completed n=1 solves separated by one uniform h-refinement.
Standard library only; never runs Newton or modifies input data. Exit 0 means
the comparison is usable, not that physical verification passed; 3 indicates
incompatible/incomplete comparison data, and 2 an execution/input error.
"""
import argparse
import csv
import hashlib
import math
from pathlib import Path
import sys
MODEL = "nonrotating_n1_fixed_mass_fixed_central_density_zero_surface_pressure"
TEXT_KEYS = ("model", "normalization")
CONSTANT_KEYS = ("G", "M", "R", "K", "rho_c")
ORDER_KEYS = ("polynomial_increment", "density_order", "enthalpy_order",
"potential_order", "gravity_flux_order", "displacement_order")
TOLERANCE_KEYS = ("absolute_tolerance", "relative_tolerance", "linear_tolerance")
ITERATION_KEYS = ("max_newton", "max_linear_iterations")
# Optional rows were added after the original coarse solve. Their absence is
# visible but does not erase the usable rows from that historical dataset.
METRICS = (
("density_relative_l2_error", "Interior density relative L2", True),
("enthalpy_relative_l2_error", "Interior enthalpy relative L2", True),
("potential_relative_l2_error", "Interior potential relative L2", True),
("gravity_gradient_relative_l2_error", "Interior gravity-gradient relative L2", True),
("pressure_relative_l2_error", "Interior pressure relative L2", True),
("surface_radius_relative_rms_error", "Surface radius RMS / R", True),
("volume_radius_relative_error", "Volume-equivalent radius relative error", True),
("virial_error", "Virial error, P(rho)", True),
("force_virial_error", "Force virial error, P(rho)", True),
("enthalpy_virial_error", "Virial error, P(h)", False),
("enthalpy_force_virial_error", "Force virial error, P(h)", False),
("gravity_energy_consistency", "Gravity-energy consistency error", True),
("mass_relative_error", "Mass relative error", True),
("binding_relative_error", "Binding-energy relative error", True),
("pressure_integral_relative_error", "Pressure-integral relative error", True),
("moment_of_inertia_relative_error", "Moment-of-inertia relative error", True),
("eos_enthalpy_scaled_rms", "Pointwise EOS RMS / central enthalpy", True),
("bernoulli_scaled_rms_variation", "Bernoulli RMS variation / GM/R", True),
("bernoulli_scaled_range", "Sampled Bernoulli range / GM/R", False),
("bernoulli_mean_scaled_error", "Bernoulli mean scaled error", False),
("normalized_bordered_residual", "Normalized bordered residual", True),
("normalized_unbordered_residual", "Normalized unbordered residual", True),
("normalized_central_border_action", "Normalized central-border action", False),
("quadrature_virial_absolute_change", "Virial quadrature-order change", True),
("quadrature_binding_relative_change", "Binding-energy quadrature-order change", True),
)
EXTERIOR = (
("potential_mean_error_scaled", "Finite-exterior maximum absolute shell-mean potential error"),
("potential_rms_error_scaled", "Finite-exterior maximum shell RMS potential error"),
("gravity_radial_mean_error_scaled", "Finite-exterior maximum absolute shell-mean radial-gravity error"),
("gravity_radial_rms_error_scaled", "Finite-exterior maximum shell RMS radial-gravity error"),
)
def number(value):
try:
return float(value)
except (ValueError, TypeError):
return math.nan
def read_metadata(path):
result = {}
for line in path.read_text().splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
if key in result:
raise ValueError(f"Duplicate metadata key {key!r}: {path}")
result[key] = value
return result
def read_metrics(path):
result = {}
with path.open(newline="") as stream:
reader = csv.DictReader(stream)
if reader.fieldnames != ["metric", "value"]:
raise ValueError(f"Expected metric,value CSV schema: {path}")
for row in reader:
key = row["metric"]
if not key or key in result or None in row:
raise ValueError(f"Malformed/duplicate metric row: {path}")
result[key] = number(row["value"])
return result
def read_dataset(directory):
directory = directory.resolve()
return {"directory": directory,
"metadata": read_metadata(directory / "metadata.txt"),
"metrics": read_metrics(directory / "physical_metrics.csv")}
def integer(value):
try:
# Unlike float->int, this rejects a nonintegral or nonfinite count.
return int(value)
except (ValueError, TypeError):
return None
def compatibility(coarse, fine, coarse_control=None, fine_control=None):
checks = []
def check(name, okay, detail):
checks.append((name, None if okay is None else bool(okay), detail))
def match(left, right, keys, numeric=False, integral=False, prefix="solve"):
for key in keys:
a, b = left.get(key), right.get(key)
if integral:
okay = integer(a) is not None and integer(a) == integer(b)
elif numeric:
okay = math.isfinite(number(a)) and number(a) == number(b)
else:
okay = a is not None and a == b
check(f"{prefix}: matching {key}", okay, f"{a!r} / {b!r}")
for name, data in (("coarse", coarse), ("fine", fine)):
meta = data["metadata"]
check(f"{name}: completed converged solve",
meta.get("mode") == "solve" and meta.get("solver_converged") == "1"
and meta.get("physical_screen_passed") in ("0", "1"),
f"mode={meta.get('mode')}, converged={meta.get('solver_converged')}, "
f"physical screen={meta.get('physical_screen_passed')} (not required to pass)")
check(f"{name}: supported model and MPI ranks",
meta.get("model") == MODEL and meta.get("mpi_ranks") == "1",
"Requires this single-rank nonrotating n=1 benchmark.")
check(f"{name}: positive finite constants",
all(math.isfinite(number(meta.get(key))) and number(meta.get(key)) > 0
for key in CONSTANT_KEYS), "G, M, R, K, rho_c must be positive and finite.")
check(f"{name}: zero rotation", data["metrics"].get("angular_velocity_norm") == 0.0,
"Requires a saved angular_velocity_norm of exactly zero.")
for metric, _, required in METRICS:
if required:
value = data["metrics"].get(metric)
check(f"{name}: usable {metric}",
value is not None and math.isfinite(value) and value >= 0,
f"Saved value: {value!r}")
a, b = coarse["metadata"], fine["metadata"]
match(a, b, TEXT_KEYS)
match(a, b, CONSTANT_KEYS + TOLERANCE_KEYS, numeric=True)
match(a, b, ORDER_KEYS, integral=True)
match(a, b, ITERATION_KEYS, integral=True)
elements_a, elements_b = integer(a.get("elements")), integer(b.get("elements"))
check("one uniform hexahedral level: 8x elements",
elements_a is not None and elements_a > 0 and elements_b == 8 * elements_a,
f"{elements_a} -> {elements_b}; element counts alone do not establish mesh ancestry.")
quad_a, quad_b = coarse["metrics"].get("quadrature_order"), fine["metrics"].get("quadrature_order")
check("matching diagnostic quadrature order",
quad_a is not None and math.isfinite(quad_a) and quad_a == quad_b,
f"{quad_a!r} / {quad_b!r}")
for name, solve, control in (("coarse control", coarse, coarse_control),
("fine control", fine, fine_control)):
if control is None:
continue
meta = control["metadata"]
check(f"{name}: completed passing analytic control",
meta.get("mode") == "analytic-mesh" and meta.get("physical_screen_passed") == "1"
and meta.get("mpi_ranks") == "1", "Analytic-control status is read, not fabricated.")
match(solve["metadata"], meta, TEXT_KEYS, prefix=name)
match(solve["metadata"], meta, CONSTANT_KEYS, numeric=True, prefix=name)
match(solve["metadata"], meta, ORDER_KEYS + ("elements",), integral=True, prefix=name)
left = solve["metrics"].get("quadrature_order")
right = control["metrics"].get("quadrature_order")
check(f"{name}: matching diagnostic quadrature order",
left is not None and math.isfinite(left) and left == right, f"{left!r} / {right!r}")
solve_path = solve.get("directory")
control_path = control.get("directory")
solve_snapshot = solve_path / "input.smesh" if solve_path is not None else None
control_snapshot = control_path / "input.smesh" if control_path is not None else None
if solve_snapshot is not None and control_snapshot is not None and solve_snapshot.is_file() and control_snapshot.is_file():
solve_hash, control_hash = digest(solve_snapshot), digest(control_snapshot)
check(f"{name}: identical input snapshot SHA-256", solve_hash == control_hash,
f"{solve_hash} / {control_hash}")
else:
check(f"{name}: identical input snapshot SHA-256", None,
"Unavailable: one or both snapshots absent; equal geometry is not established by the element-count check.")
return checks
def checks_satisfied(checks):
# Optional unavailable provenance checks are not fabricated passes.
return all(okay is not False for _, okay, _ in checks)
def reduction(coarse, fine, eligible=True):
if coarse is None or fine is None:
return None, None, "missing"
if not math.isfinite(coarse) or not math.isfinite(fine):
return None, None, "nonfinite"
if coarse < 0 or fine < 0:
return None, None, "negative error magnitude"
if not eligible:
return None, None, "suppressed: compatibility checks failed"
if fine == 0:
return None, None, "both zero; no rate" if coarse == 0 else "fine zero; no finite rate"
if coarse == 0:
return 0.0, None, "coarse zero; no finite rate"
ratio = coarse / fine
rate = math.log2(coarse) - math.log2(fine)
if ratio == 0:
return ratio, rate, "ratio underflow; log-rate remains finite"
return ratio, rate, "two-level observation" if math.isfinite(ratio) else "ratio overflow; log-rate remains finite"
def exterior_diagnostics(data):
path = data["directory"] / "radial_profiles.csv"
if not path.exists():
return {}, "not available (radial_profiles.csv absent)"
with path.open(newline="") as stream:
rows = [row for row in csv.DictReader(stream) if number(row.get("r_over_R")) > 1.0]
if not rows:
return {}, "not available (no requested exterior shells)"
signature = sorted({(str(row.get("mu_points")), str(row.get("phi_points"))) for row in rows})
description = (f"{len(rows)} shells, r/R={rows[0].get('r_over_R')}..{rows[-1].get('r_over_R')}, "
f"angular grids={signature}; sampled-shell diagnostics only")
values = {}
for column, _ in EXTERIOR:
samples = [number(row.get(column)) for row in rows]
field = "potential" if column.startswith("potential_") else "gravity_radial"
complete = all(number(row.get("located_weight_fraction")) >= 1.0 - 1e-12
and number(row.get(field + "_valid_weight_fraction")) >= 1.0 - 1e-12
for row in rows)
values[column] = (max(abs(value) for value in samples)
if complete and all(math.isfinite(value) for value in samples) else math.nan)
return values, description
def digest(path):
if not path.is_file():
return "not available"
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def display(value):
if value is None:
return "not available"
if not math.isfinite(value):
return str(value)
return f"{value:.8g}"
def markdown(value):
return str(value).replace("|", "\\|").replace("\n", " ")
def write_comparison(coarse, fine, output, coarse_control=None, fine_control=None):
checks = compatibility(coarse, fine, coarse_control, fine_control)
eligible = checks_satisfied(checks)
rows = []
for metric, label, required in METRICS:
a, b = coarse["metrics"].get(metric), fine["metrics"].get(metric)
ratio, rate, status = reduction(a, b, eligible)
rows.append({"metric": metric, "description": label, "category": "volume_surface_or_solver",
"required_data": int(required), "coarse": a, "fine": b,
"coarse_over_fine": ratio, "observed_log2_rate": rate, "status": status,
"coarse_control": coarse_control["metrics"].get(metric) if coarse_control else None,
"fine_control": fine_control["metrics"].get(metric) if fine_control else None})
exterior_a, sampling_a = exterior_diagnostics(coarse)
exterior_b, sampling_b = exterior_diagnostics(fine)
for metric, label in EXTERIOR:
a, b = exterior_a.get(metric), exterior_b.get(metric)
status = "diagnostic only; no gate/rate (angular/radial samples need independent convergence checks)"
if a is None or b is None or not math.isfinite(a) or not math.isfinite(b):
status = "diagnostic unavailable or incomplete; no gate/rate"
rows.append({"metric": "sampled_exterior_max_" + metric, "description": label,
"category": "sampled_exterior_diagnostic_only", "required_data": 0,
"coarse": a, "fine": b, "coarse_over_fine": None, "observed_log2_rate": None,
"status": status, "coarse_control": None, "fine_control": None})
# No overwrite, including output paths that alias an input directory.
output.mkdir()
with (output / "comparison.csv").open("x", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
lines = ["# Two-level polytrope refinement comparison", "",
"Required comparison checks: " + ("satisfied." if eligible else "**FAILED; h-rates suppressed.**"), "",
"This is an observed two-level error comparison, not an established asymptotic order or a physical-accuracy certificate. "
"Exit 0 indicates usable comparison data, not a passing physical screen. "
"Reported rates are log2(E_coarse/E_fine), conditional on one uniform level halving the logical cell scale. "
"An 8x element ratio alone cannot prove mesh ancestry, stable source code, or identical geometry construction.", "",
"Optional unavailable snapshot checks are marked unavailable, not passed; they do not establish identical control/solve geometry.", "",
"Interior L2 errors use the saved full 3D stellar-volume diagnostics, not fitted spherical means. "
"A negative rate means this error increased. Missing/nonfinite/negative errors and zero denominators never receive a fabricated rate. "
"No existing physical screening budget is changed or reinterpreted.", "",
"## Provenance", ""]
for name, data in (("Coarse solve", coarse), ("Fine solve", fine),
("Coarse analytic control", coarse_control), ("Fine analytic control", fine_control)):
if data is None:
lines.append(f"- {name}: not supplied.")
continue
meta = data["metadata"]
lines += [f"- {name}: `{markdown(data['directory'])}`; elements={markdown(meta.get('elements'))}; "
f"saved physical_screen_passed={markdown(meta.get('physical_screen_passed'))}."]
for filename in ("metadata.txt", "physical_metrics.csv", "input.smesh"):
lines.append(f" - {filename} SHA-256: `{digest(data['directory'] / filename)}`")
lines.append(f" - compiled={markdown(meta.get('compiled', 'not available'))}; compiler={markdown(meta.get('compiler', 'not available'))}.")
lines += ["", "Input hashes identify these artifacts, not the production source/library version. "
"Confirm unchanged physics/seed/normalization/mapping code separately. Geometry-aware STROID refinement can regenerate "
"the curved mesh, rather than merely subdividing its old polynomial geometry.", "",
"## Error comparison", "",
"| Diagnostic | Coarse | Fine | E_coarse/E_fine | Observed log2 rate | Coarse control | Fine control | Status |",
"|---|---:|---:|---:|---:|---:|---:|---|"]
for row in rows:
lines.append("| " + " | ".join(markdown(value) for value in (
row["description"], display(row["coarse"]), display(row["fine"]),
display(row["coarse_over_fine"]), display(row["observed_log2_rate"]),
display(row["coarse_control"]), display(row["fine_control"]), row["status"])) + " |")
lines += ["", "Analytic-control field errors may be zero by construction; they are not FE best-approximation errors. "
"Controls are shown without subtraction from solve errors. EOS projection floors, cancellation in integral errors, "
"sampled extrema, and algebraic residual floors can produce rates unrelated to formal FE approximation order.", "",
"## Finite-exterior sampling (diagnostic only)", "",
f"- Coarse: {markdown(sampling_a)}.", f"- Fine: {markdown(sampling_b)}.", "",
"Exterior rows are maxima across the saved requested shells with r/R > 1, not pointwise global maxima or volume L2 norms. "
"Potential is scaled by GM/R and radial gravity by GM/R^2. No rate or pass gate is inferred from these samples; "
"missing or incomplete shell coverage remains unavailable.", "", "## Compatibility checks", "",
"| Check | Satisfied | Detail |", "|---|---|---|"]
lines.extend(f"| {markdown(name)} | {'unavailable' if okay is None else ('yes' if okay else 'NO')} | {markdown(detail)} |"
for name, okay, detail in checks)
(output / "comparison.md").write_text("\n".join(lines) + "\n")
return eligible
def self_check():
"""Synthetic-only checks; no project data, solver, or persistent outputs."""
import tempfile
import unittest
class ComparisonChecks(unittest.TestCase):
@staticmethod
def fixture(elements):
meta = {key: "1" for key in CONSTANT_KEYS + ORDER_KEYS}
meta.update({"model": MODEL, "normalization": "synthetic", "mode": "solve", "mpi_ranks": "1",
"solver_converged": "1", "physical_screen_passed": "0", "elements": str(elements),
"max_newton": "8", "max_linear_iterations": "80",
"absolute_tolerance": "1e-8", "relative_tolerance": "1e-8", "linear_tolerance": ".03"})
metrics = {key: 1e-4 for key, _, _ in METRICS}
metrics.update({"angular_velocity_norm": 0.0, "quadrature_order": 18.0})
return {"metadata": meta, "metrics": metrics}
def test_rates_and_edge_cases(self):
self.assertEqual(reduction(8.0, 1.0)[:2], (8.0, 3.0))
self.assertEqual(reduction(1.0, 4.0)[:2], (.25, -2.0))
for a, b in ((None, 1), (math.nan, 1), (1, math.inf), (-1, 1), (0, 0), (1, 0)):
self.assertIsNone(reduction(a, b)[1])
self.assertEqual(reduction(0, 1)[:2], (0.0, None))
self.assertEqual(reduction(8, 1, False)[:2], (None, None))
def test_compatibility(self):
a, b = self.fixture(19), self.fixture(152)
self.assertTrue(checks_satisfied(compatibility(a, b)))
for key, bad in (("mode", "replay"), ("solver_converged", "0"), ("elements", "151"),
("elements", "152.5"), ("G", "nan"), ("density_order", "2"),
("linear_tolerance", ".02"), ("max_newton", "9"), ("max_linear_iterations", "81")):
broken = {"metadata": dict(b["metadata"], **{key: bad}), "metrics": b["metrics"]}
self.assertFalse(checks_satisfied(compatibility(a, broken)), key)
del b["metrics"]["density_relative_l2_error"]
self.assertFalse(checks_satisfied(compatibility(a, b)))
def test_control(self):
a, b, control = self.fixture(19), self.fixture(152), self.fixture(19)
control["metadata"].update(mode="analytic-mesh", physical_screen_passed="1")
self.assertTrue(checks_satisfied(compatibility(a, b, control)))
self.assertTrue(any(okay is None for _, okay, _ in compatibility(a, b, control)))
control["metadata"]["elements"] = "152"
self.assertFalse(checks_satisfied(compatibility(a, b, control)))
def test_control_snapshot_hash(self):
a, b, control = self.fixture(19), self.fixture(152), self.fixture(19)
control["metadata"].update(mode="analytic-mesh", physical_screen_passed="1", max_newton="99")
with tempfile.TemporaryDirectory(prefix="polytrope-snapshot-check-") as temporary:
root = Path(temporary)
for name, data in (("solve", a), ("control", control)):
data["directory"] = root / name
data["directory"].mkdir()
(data["directory"] / "input.smesh").write_text("identical synthetic snapshot\n")
checks = compatibility(a, b, control)
self.assertTrue(checks_satisfied(checks))
self.assertTrue(any("snapshot SHA-256" in name and okay is True for name, okay, _ in checks))
(control["directory"] / "input.smesh").write_text("different geometry, same element count\n")
checks = compatibility(a, b, control)
self.assertFalse(checks_satisfied(checks))
self.assertTrue(any("snapshot SHA-256" in name and okay is False for name, okay, _ in checks))
def test_optional_metrics_and_exterior_coverage(self):
a, b = self.fixture(19), self.fixture(152)
del a["metrics"]["enthalpy_virial_error"]
self.assertTrue(checks_satisfied(compatibility(a, b)))
with tempfile.TemporaryDirectory(prefix="polytrope-exterior-check-") as temporary:
directory = Path(temporary)
a["directory"] = directory
self.assertEqual(exterior_diagnostics(a)[0], {})
exterior = {"r_over_R": 1.001, "located_weight_fraction": 1,
"potential_valid_weight_fraction": 1, "gravity_radial_valid_weight_fraction": 1,
"mu_points": 6, "phi_points": 12,
**{column: -.02 if "mean" in column else .03 for column, _ in EXTERIOR}}
for coverage in (1, .5):
exterior["potential_valid_weight_fraction"] = coverage
with (directory / "radial_profiles.csv").open("w", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(exterior))
writer.writeheader()
writer.writerow(exterior)
values, _ = exterior_diagnostics(a)
if coverage == 1:
self.assertEqual(values["potential_mean_error_scaled"], .02)
else:
self.assertTrue(math.isnan(values["potential_mean_error_scaled"]))
self.assertEqual(values["gravity_radial_rms_error_scaled"], .03)
def test_round_trip_and_no_overwrite(self):
with tempfile.TemporaryDirectory(prefix="polytrope-comparison-check-") as temporary:
root = Path(temporary)
data = []
for name, elements in (("coarse", 19), ("fine", 152)):
fixture = self.fixture(elements)
directory = root / name
directory.mkdir()
(directory / "metadata.txt").write_text("".join(f"{k}={v}\n" for k, v in fixture["metadata"].items()))
with (directory / "physical_metrics.csv").open("w", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(("metric", "value"))
writer.writerows(fixture["metrics"].items())
data.append(read_dataset(directory))
output = root / "comparison"
self.assertTrue(write_comparison(*data, output))
self.assertTrue((output / "comparison.csv").is_file())
self.assertIn("not an established asymptotic order", (output / "comparison.md").read_text())
with self.assertRaises(FileExistsError):
write_comparison(*data, output)
data[1]["metadata"]["solver_converged"] = "0"
self.assertFalse(write_comparison(*data, root / "failed"))
with (root / "failed" / "comparison.csv").open(newline="") as stream:
self.assertTrue(all(not row["observed_log2_rate"] for row in csv.DictReader(stream)))
suite = unittest.defaultTestLoader.loadTestsFromTestCase(ComparisonChecks)
return unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("coarse", type=Path, nargs="?", help="Completed coarse solve directory (not a replay)")
parser.add_argument("fine", type=Path, nargs="?", help="Completed fine solve directory (not a replay)")
parser.add_argument("--coarse-control", type=Path)
parser.add_argument("--fine-control", type=Path)
parser.add_argument("--output", type=Path, help="Fresh output directory; never overwritten")
parser.add_argument("--self-check", action="store_true", help="Run synthetic-only checks and exit")
arguments = parser.parse_args()
if arguments.self_check:
if any((arguments.coarse, arguments.fine, arguments.output, arguments.coarse_control, arguments.fine_control)):
parser.error("--self-check cannot be combined with dataset/output arguments")
return 0 if self_check() else 3
if not all((arguments.coarse, arguments.fine, arguments.output)):
parser.error("coarse, fine, and --output are required")
try:
eligible = write_comparison(
read_dataset(arguments.coarse), read_dataset(arguments.fine), arguments.output,
read_dataset(arguments.coarse_control) if arguments.coarse_control else None,
read_dataset(arguments.fine_control) if arguments.fine_control else None)
print(f"Wrote {arguments.output / 'comparison.md'}; comparison prerequisites satisfied={eligible} "
"(not a physical verification pass)")
return 0 if eligible else 3
except (OSError, ValueError, csv.Error) as error:
print(f"Comparison error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,521 @@
#pragma once
// Include after `import mean_field;`. This is deliberately an experiment-only
// observer: it uses the production estimator and mapper without changing either.
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include <mfem.hpp>
#include <mpi.h>
namespace experiment {
namespace geometry_detail {
inline double Frobenius(const mfem::DenseMatrix &matrix) {
double squared = 0.0;
for (int row = 0; row < matrix.Height(); ++row) {
for (int column = 0; column < matrix.Width(); ++column) {
squared += matrix(row, column) * matrix(row, column);
}
}
return std::sqrt(squared);
}
inline double Difference(const mfem::DenseMatrix &left, const mfem::DenseMatrix &right) {
double squared = 0.0;
for (int row = 0; row < left.Height(); ++row) {
for (int column = 0; column < left.Width(); ++column) {
const double difference = left(row, column) - right(row, column);
squared += difference * difference;
}
}
return std::sqrt(squared);
}
inline std::array<double, 4> DeterminantCoefficients(
const mfem::DenseMatrix &base, const mfem::DenseMatrix &direction
) {
std::array<double, 4> coefficients{};
const int dimension = base.Height();
mfem::DenseMatrix selected(dimension);
for (int mask = 0; mask < (1 << dimension); ++mask) {
int degree = 0;
for (int column = 0; column < dimension; ++column) {
const bool useDirection = (mask & (1 << column)) != 0;
degree += useDirection ? 1 : 0;
for (int row = 0; row < dimension; ++row) {
selected(row, column) = useDirection ? direction(row, column) : base(row, column);
}
}
coefficients[static_cast<std::size_t>(degree)] += selected.Det();
}
return coefficients;
}
inline double Polynomial(const std::array<double, 4> &coefficients, const double alpha) {
return ((coefficients[3] * alpha + coefficients[2]) * alpha + coefficients[1]) * alpha + coefficients[0];
}
inline std::ofstream OpenCsv(const std::filesystem::path &path) {
std::ofstream stream(path);
if (!stream) {
throw std::runtime_error("Cannot open geometry diagnostic output: " + path.string());
}
stream << std::setprecision(17);
return stream;
}
inline void Coordinates(std::ostream &stream, const mfem::Vector &position) {
for (int component = 0; component < 3; ++component) {
stream << ',' << (component < position.Size() ? position(component) : 0.0);
}
}
struct ElementReport final {
int element{-1};
int globalRule{-1};
mean_field::deformation::LargestSafeNewtonStepSizeEstimate estimate{};
};
// Reference-mesh probes deliberately avoid DomainMapper and inverse
// Jacobians: an exact vertex may be singular even though interior
// quadrature points remain valid.
inline void InspectReferenceCorners(
mfem::ParMesh &mesh, const std::filesystem::path &outputDirectory, const std::string &label
) {
auto csv = OpenCsv(outputDirectory / (label + "_reference_corner_probes.csv"));
csv << "element,attribute,vertex,inward_fraction,xi,eta,zeta,reference_x,reference_y,reference_z,"
"reference_radius,reference_det,reference_sigma_min,reference_sigma_mid,reference_sigma_max\n";
mfem::Vector position;
for (const int element : {0, 9, 18, 27, 36, 45, 54, 63}) {
if (element >= mesh.GetNE()) {
continue;
}
auto *transformation = mesh.GetElementTransformation(element);
const auto *vertices = mfem::Geometries.GetVertices(transformation->GetGeometryType());
const auto &center = mfem::Geometries.GetCenter(transformation->GetGeometryType());
for (int vertex = 0; vertex < vertices->GetNPoints(); ++vertex) {
const auto &corner = vertices->IntPoint(vertex);
for (const double epsilon : {0.0, 1.0e-5, 1.0e-4, 0.001, 0.01, 0.05, 0.1}) {
mfem::IntegrationPoint point;
point.Set3(
(1.0 - epsilon) * corner.x + epsilon * center.x,
(1.0 - epsilon) * corner.y + epsilon * center.y,
(1.0 - epsilon) * corner.z + epsilon * center.z
);
transformation->SetIntPoint(&point);
transformation->Transform(point, position);
const auto &jacobian = transformation->Jacobian();
csv << element << ',' << transformation->Attribute << ',' << vertex << ',' << epsilon << ','
<< point.x << ',' << point.y << ',' << point.z;
Coordinates(csv, position);
csv << ',' << position.Norml2() << ',' << jacobian.Det() << ',' << jacobian.CalcSingularvalue(2)
<< ',' << jacobian.CalcSingularvalue(1) << ',' << jacobian.CalcSingularvalue(0) << '\n';
}
}
}
}
} // namespace geometry_detail
struct GeometryReport final {
double boundaryStep{1.0};
double safeStep{1.0};
int limitingElement{-1};
int limitedElements{0};
int elementsWithinOnePartPerMillion{0};
int elementsWithinOnePercent{0};
double maximumRelativeMappingError{0.0};
double maximumRelativeDeterminantError{0.0};
double maximumReportedPointGradient{0.0};
int invalidDirectSamples{0};
std::uint64_t sampleCount{0};
};
// Sandbox-specific inspection of the negative-corner core element. The
// continuous comparison describes a logical r_L^power radial extension before
// nodal interpolation, with logical stellar-surface radius equal to one.
// The measured direction is always the supplied production FE field.
inline void InspectCoreDiagonal(
const mean_field::fem::FEM &finiteElements,
const mfem::Vector &volumeDirection,
const double cornerSurfaceAmplitude,
const std::filesystem::path &outputDirectory,
const std::string &label,
const double radialPower = 2.0
) {
using namespace geometry_detail;
auto &space = *finiteElements.displacementFes;
auto &mesh = *finiteElements.mesh;
auto &logicalMesh = *finiteElements.logicalReferenceMesh;
int ranks = 0;
MPI_Comm_size(space.GetComm(), &ranks);
if (ranks != 1 || mesh.GetNE() < 1 || logicalMesh.GetNE() != mesh.GetNE() || mesh.GetAttribute(0) != 1 ||
volumeDirection.Size() != space.GetTrueVSize() || !std::isfinite(cornerSurfaceAmplitude) ||
!std::isfinite(radialPower) || radialPower <= 0.0) {
throw std::invalid_argument("Core-diagonal probe requires a single-rank compatible core displacement.");
}
auto *physicalTransformation = mesh.GetElementTransformation(0);
auto *logicalTransformation = logicalMesh.GetElementTransformation(0);
if (physicalTransformation->GetGeometryType() != mfem::Geometry::CUBE) {
std::cout << "CoreDiagonal[" << label << "]: skipped unrecognized non-hex core element\n";
return;
}
mfem::Vector physicalPosition(3), logicalPosition(3);
for (const double parameter : {0.0, 0.5, 1.0}) {
mfem::IntegrationPoint point;
point.Set3(parameter, parameter, parameter);
logicalTransformation->Transform(point, logicalPosition);
physicalTransformation->Transform(point, physicalPosition);
bool recognized = logicalPosition(0) < 0.0 && physicalPosition(0) < 0.0;
for (int component = 1; component < 3; ++component) {
recognized = recognized && std::abs(logicalPosition(component) - logicalPosition(0)) < 1.0e-11 &&
std::abs(physicalPosition(component) - physicalPosition(0)) < 1.0e-11;
}
// Element 0 in sandbox.smesh covers logical diagonal [-1/4,-1/8].
const double expectedLogical = -0.25 + 0.125 * parameter;
recognized = recognized && std::abs(logicalPosition(0) - expectedLogical) < 1.0e-11;
if (!recognized) {
std::cout << "CoreDiagonal[" << label << "]: skipped unrecognized sandbox core diagonal\n";
return;
}
}
mfem::ParGridFunction direction(&space);
direction.SetFromTrueDofs(volumeDirection);
mfem::Array<int> dofs;
auto *dofTransformation = space.GetElementVDofs(0, dofs);
mfem::Vector elementDirection;
direction.GetSubVector(dofs, elementDirection);
if (dofTransformation != nullptr) {
dofTransformation->InvTransformPrimal(elementDirection);
}
const auto &finiteElement = *space.GetFE(0);
const mean_field::mapping::ElementDisplacementData directionData(finiteElement, elementDirection, space.GetOrdering());
mfem::Vector shape(finiteElement.GetDof()), value(3), directionAlongDiagonal(3), physicalAlongDiagonal(3);
mfem::Vector logicalAlongDiagonal(3), unitDiagonal(3), radial(3), radialDerivative(3);
unitDiagonal = 1.0;
mfem::DenseMatrix derivativeShape(finiteElement.GetDof(), 3), directionGradientHat(3);
auto csv = OpenCsv(outputDirectory / (label + "_core_diagonal.csv"));
csv << "element,s,logical_x,logical_y,logical_z,reference_x,reference_y,reference_z,logical_radius,physical_radius,"
"corner_surface_amplitude,drL_ds,dr_ds,actual_radial_displacement,desired_radial_displacement,"
"actual_du_radial_ds,desired_du_radial_ds,actual_radial_gradient,desired_radial_gradient,"
"actual_du_x_ds,actual_du_y_ds,actual_du_z_ds,reference_det,reference_sigma_min,reference_sigma_max,"
"radial_power,relative_det_coefficient_0,relative_det_coefficient_1,relative_det_coefficient_2,relative_det_coefficient_3\n";
for (const double parameter : {0.0, 0.005, 0.010885670926971493, 0.02, 0.05, 0.1, 0.2,
0.276393202250021, 0.5, 0.723606797749979, 0.9, 1.0}) {
mfem::IntegrationPoint point;
point.Set3(parameter, parameter, parameter);
logicalTransformation->SetIntPoint(&point);
physicalTransformation->SetIntPoint(&point);
logicalTransformation->Transform(point, logicalPosition);
physicalTransformation->Transform(point, physicalPosition);
const auto &referenceJacobian = physicalTransformation->Jacobian();
referenceJacobian.Mult(unitDiagonal, physicalAlongDiagonal);
logicalTransformation->Jacobian().Mult(unitDiagonal, logicalAlongDiagonal);
finiteElement.CalcShape(point, shape);
finiteElement.CalcDShape(point, derivativeShape);
directionData.GetDofMatrix().MultTranspose(shape, value);
mfem::MultAtB(directionData.GetDofMatrix(), derivativeShape, directionGradientHat);
directionGradientHat.Mult(unitDiagonal, directionAlongDiagonal);
const double physicalRadius = physicalPosition.Norml2();
const double logicalRadius = std::max({std::abs(logicalPosition(0)), std::abs(logicalPosition(1)), std::abs(logicalPosition(2))});
const double logicalRadiusDerivative = -(logicalAlongDiagonal(0) + logicalAlongDiagonal(1) + logicalAlongDiagonal(2)) / 3.0;
const double nan = std::numeric_limits<double>::quiet_NaN();
double physicalRadiusDerivative = nan, actualRadial = nan, actualRadialDerivative = nan;
if (physicalRadius > 0.0) {
radial = physicalPosition;
radial /= physicalRadius;
physicalRadiusDerivative = radial * physicalAlongDiagonal;
radialDerivative = physicalAlongDiagonal;
radialDerivative.Add(-physicalRadiusDerivative, radial);
radialDerivative /= physicalRadius;
actualRadial = radial * value;
actualRadialDerivative = radial * directionAlongDiagonal + radialDerivative * value;
}
const double desiredRadial = cornerSurfaceAmplitude * std::pow(logicalRadius, radialPower);
const double desiredRadialDerivative = radialPower * cornerSurfaceAmplitude *
std::pow(logicalRadius, radialPower - 1.0) * logicalRadiusDerivative;
const double actualGradient = std::isfinite(physicalRadiusDerivative) && physicalRadiusDerivative != 0.0
? actualRadialDerivative / physicalRadiusDerivative : nan;
const double desiredGradient = std::isfinite(physicalRadiusDerivative) && physicalRadiusDerivative != 0.0
? desiredRadialDerivative / physicalRadiusDerivative : nan;
csv << "0," << parameter;
Coordinates(csv, logicalPosition);
Coordinates(csv, physicalPosition);
csv << ',' << logicalRadius << ',' << physicalRadius << ',' << cornerSurfaceAmplitude << ','
<< logicalRadiusDerivative << ',' << physicalRadiusDerivative << ',' << actualRadial << ',' << desiredRadial
<< ',' << actualRadialDerivative << ',' << desiredRadialDerivative << ',' << actualGradient << ',' << desiredGradient;
Coordinates(csv, directionAlongDiagonal);
const double referenceDeterminant = referenceJacobian.Det();
csv << ',' << referenceDeterminant << ',' << referenceJacobian.CalcSingularvalue(2) << ','
<< referenceJacobian.CalcSingularvalue(0) << ',' << radialPower;
// Direct total-element determinant, normalized by undeformed
// reference volume. This remains evaluable at a vertex without
// constructing a potentially ill-conditioned inverse Jacobian.
const auto determinant = DeterminantCoefficients(referenceJacobian, directionGradientHat);
for (const double coefficient : determinant) {
csv << ',' << (referenceDeterminant != 0.0 ? coefficient / referenceDeterminant : nan);
}
csv << '\n';
}
}
// Per-element calls to the collective production estimator are intentional.
// Restrict this diagnostic to one rank: element counts differ on MPI ranks,
// so independently iterating them would mismatch estimator collectives.
inline GeometryReport InspectGeometry(
const mean_field::mapping::DomainMapper &mapper,
const mfem::ParFiniteElementSpace &displacementSpace,
const mfem::ParGridFunction &compactification,
const mfem::Vector &acceptedVolumeDisplacement,
const mfem::Vector &volumeDirection,
const std::span<const mean_field::deformation::NewtonStepGeometryRule> productionRules,
const std::filesystem::path &outputDirectory,
const std::string &label,
const std::size_t detailedElementCount = 12
) {
using namespace mean_field;
using namespace geometry_detail;
int ranks = 0;
MPI_Comm_size(displacementSpace.GetComm(), &ranks);
if (ranks != 1) {
throw std::invalid_argument("Per-element geometry experiment requires exactly one MPI rank.");
}
if (mapper.GetDimension() != 3) {
throw std::invalid_argument("Geometry experiment currently requires three spatial dimensions.");
}
std::filesystem::create_directories(outputDirectory);
auto *mesh = displacementSpace.GetParMesh();
if (label.find("uniform") != std::string::npos) {
InspectReferenceCorners(*mesh, outputDirectory, label);
}
std::vector<std::vector<deformation::NewtonStepGeometryRule>> elementRules(mesh->GetNE());
std::vector<std::vector<int>> ruleIndices(mesh->GetNE());
for (std::size_t index = 0; index < productionRules.size(); ++index) {
const auto &rule = productionRules[index];
elementRules.at(rule.element).push_back(rule);
ruleIndices.at(rule.element).push_back(static_cast<int>(index));
}
GeometryReport report;
std::vector<ElementReport> elements;
elements.reserve(mesh->GetNE());
for (int element = 0; element < mesh->GetNE(); ++element) {
if (elementRules[element].empty()) {
continue;
}
const auto estimate = deformation::estimate_largest_safe_newton_step_size(
mapper, displacementSpace, compactification, acceptedVolumeDisplacement, volumeDirection,
elementRules[element]
);
const int globalRule = estimate.limitingRule < 0 ? -1 : ruleIndices[element].at(estimate.limitingRule);
elements.push_back({element, globalRule, estimate});
report.sampleCount += estimate.sampledQuadraturePointCount;
if (estimate.limitedByGeometry) {
++report.limitedElements;
if (report.limitingElement < 0 || estimate.boundaryStepSize < report.boundaryStep) {
report.boundaryStep = estimate.boundaryStepSize;
report.safeStep = estimate.stepSize;
report.limitingElement = element;
}
}
}
std::stable_sort(elements.begin(), elements.end(), [](const auto &left, const auto &right) {
return left.estimate.boundaryStepSize < right.estimate.boundaryStepSize;
});
for (const auto &element : elements) {
if (element.estimate.limitedByGeometry) {
report.elementsWithinOnePartPerMillion +=
element.estimate.boundaryStepSize <= report.boundaryStep * (1.0 + 1.0e-6);
report.elementsWithinOnePercent +=
element.estimate.boundaryStepSize <= report.boundaryStep * 1.01;
}
}
auto elementCsv = OpenCsv(outputDirectory / (label + "_geometry_elements.csv"));
elementCsv << "rank,element,attribute,compactified,limited,boundary_step,safe_step,rule_index,point_index,"
"rule_points,xi,eta,zeta,reference_x,reference_y,reference_z,physical_x,physical_y,physical_z,"
"physical_radius,min_det_accepted_samples,min_det_full_step_samples,limiter_det_accepted,"
"limiter_sigma_min_accepted,limiter_sigma_max_accepted,limiter_direction_gradient_frobenius,"
"limiter_direction_radial,limiter_direction_tangential,"
"limiter_radial_gradient,limiter_tangential_gradient_trace,"
"reference_element_det,reference_element_sigma_min,reference_element_sigma_max,"
"total_physical_element_det,total_physical_element_sigma_min,total_physical_element_sigma_max,"
"det_coefficient_0,det_coefficient_1,det_coefficient_2,det_coefficient_3\n";
auto sampleCsv = OpenCsv(outputDirectory / (label + "_geometry_mapping_checks.csv"));
sampleCsv << "element,rule_index,point_index,alpha,boundary_fraction,status,polynomial_det,direct_det,"
"relative_det_error,relative_mapping_matrix_error,"
"mapped_sigma_min,mapped_sigma_max,displacement_det,displacement_sigma_min\n";
auto matrixCsv = OpenCsv(outputDirectory / (label + "_geometry_limiting_matrices.csv"));
matrixCsv << "element,matrix,row,column,value\n";
mfem::Vector acceptedLocal(displacementSpace.GetVSize());
mfem::Vector directionLocal(displacementSpace.GetVSize());
const auto *prolongation = displacementSpace.GetProlongationMatrix();
if (prolongation != nullptr) {
prolongation->Mult(acceptedVolumeDisplacement, acceptedLocal);
prolongation->Mult(volumeDirection, directionLocal);
} else {
acceptedLocal = acceptedVolumeDisplacement;
directionLocal = volumeDirection;
}
const auto *compactificationSpace = compactification.ParFESpace();
mfem::Array<int> displacementDofs, compactificationDofs;
mfem::Vector acceptedElement, directionElement, compactificationElement, trialElement;
mapping::DomainMapper::Workspace workspace(mapper.GetDimension());
mapping::MappingPointContext base, direct;
mapping::MappingPointVariation variation;
for (std::size_t sortedIndex = 0; sortedIndex < elements.size(); ++sortedIndex) {
const auto &entry = elements[sortedIndex];
const int element = entry.element;
auto *transformation = mesh->GetElementTransformation(element);
const auto *displacementFe = displacementSpace.GetFE(element);
const auto *compactificationFe = compactificationSpace->GetFE(element);
auto *displacementTransform = displacementSpace.GetElementVDofs(element, displacementDofs);
auto *compactificationTransform = compactificationSpace->GetElementDofs(element, compactificationDofs);
acceptedLocal.GetSubVector(displacementDofs, acceptedElement);
directionLocal.GetSubVector(displacementDofs, directionElement);
compactification.GetSubVector(compactificationDofs, compactificationElement);
if (displacementTransform != nullptr) {
displacementTransform->InvTransformPrimal(acceptedElement);
displacementTransform->InvTransformPrimal(directionElement);
}
if (compactificationTransform != nullptr) {
compactificationTransform->InvTransformPrimal(compactificationElement);
}
const mapping::ElementDisplacementData baseData(
*displacementFe, acceptedElement, displacementSpace.GetOrdering()
);
const mapping::ElementDisplacementData directionData(
*displacementFe, directionElement, displacementSpace.GetOrdering()
);
const mapping::ElementCompactificationData compactificationData(*compactificationFe, compactificationElement);
const mapping::ElementMappingData mappingData{baseData, compactificationData};
const auto &point = entry.globalRule >= 0
? productionRules[entry.globalRule].integrationRule->IntPoint(entry.estimate.limitingQuadraturePoint)
: mfem::Geometries.GetCenter(transformation->GetGeometryType());
if (mapper.EvaluatePoint(mappingData, *transformation, point, workspace, base) != mapping::MappingStatus::valid ||
mapper.EvaluatePointVariation(mappingData, directionData, *transformation, point, base, workspace, variation) !=
mapping::MappingStatus::valid) {
throw std::runtime_error("Geometry diagnostic could not evaluate element " + std::to_string(element));
}
const auto coefficients = DeterminantCoefficients(base.mapping_jacobian, variation.mapping_jacobian_variation);
const mfem::DenseMatrix referenceJacobian(transformation->Jacobian());
mfem::DenseMatrix totalPhysicalJacobian(3);
mfem::Mult(base.mapping_jacobian, referenceJacobian, totalPhysicalJacobian);
const double radius = base.physical_position.Norml2();
double radialDirection = 0.0;
if (radius > 0.0) {
radialDirection = (base.physical_position * variation.physical_position_variation) / radius;
}
const double directionNorm = variation.physical_position_variation.Norml2();
const double tangentialDirection = std::sqrt(std::max(0.0, directionNorm * directionNorm - radialDirection * radialDirection));
mfem::DenseMatrix physicalGradient(3);
mfem::Mult(variation.mapping_jacobian_variation, base.inverse_mapping_jacobian, physicalGradient);
double radialGradient = 0.0;
if (radius > 0.0) {
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
radialGradient += base.physical_position(row) * physicalGradient(row, column) *
base.physical_position(column) / (radius * radius);
}
}
}
const double tangentialTrace = physicalGradient(0, 0) + physicalGradient(1, 1) + physicalGradient(2, 2) - radialGradient;
const double gradientNorm = Frobenius(variation.displacement_jacobian_variation);
report.maximumReportedPointGradient = std::max(report.maximumReportedPointGradient, gradientNorm);
elementCsv << "0," << element << ',' << transformation->Attribute << ',' << base.compactified << ','
<< entry.estimate.limitedByGeometry << ',' << entry.estimate.boundaryStepSize << ','
<< entry.estimate.stepSize << ',' << entry.globalRule << ',' << entry.estimate.limitingQuadraturePoint << ','
<< (entry.globalRule >= 0 ? productionRules[entry.globalRule].integrationRule->GetNPoints() : 0) << ','
<< point.x << ',' << point.y << ',' << point.z;
Coordinates(elementCsv, base.reference_position);
Coordinates(elementCsv, base.physical_position);
elementCsv << ',' << radius << ',' << entry.estimate.minimumDeterminantAtAcceptedState << ','
<< entry.estimate.minimumDeterminantAtMaximumStepSize << ',' << base.mapping_determinant << ','
<< base.mapping_jacobian.CalcSingularvalue(2) << ',' << base.mapping_jacobian.CalcSingularvalue(0) << ','
<< gradientNorm << ',' << radialDirection << ',' << tangentialDirection << ',' << radialGradient << ','
<< tangentialTrace << ',' << referenceJacobian.Det() << ',' << referenceJacobian.CalcSingularvalue(2)
<< ',' << referenceJacobian.CalcSingularvalue(0) << ',' << totalPhysicalJacobian.Det() << ','
<< totalPhysicalJacobian.CalcSingularvalue(2) << ',' << totalPhysicalJacobian.CalcSingularvalue(0);
for (const double coefficient : coefficients) {
elementCsv << ',' << coefficient;
}
elementCsv << '\n';
if (sortedIndex >= detailedElementCount) {
continue;
}
const std::array<std::pair<const char *, const mfem::DenseMatrix *>, 6> matrices{{
{"base_mapping", &base.mapping_jacobian},
{"direction_mapping", &variation.mapping_jacobian_variation},
{"direction_displacement", &variation.displacement_jacobian_variation},
{"physical_direction_gradient", &physicalGradient},
{"reference_element", &referenceJacobian},
{"total_physical_element", &totalPhysicalJacobian}
}};
for (const auto &[name, matrix] : matrices) {
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) {
matrixCsv << element << ',' << name << ',' << row << ',' << column << ',' << (*matrix)(row, column) << '\n';
}
}
}
for (const double fraction : {0.0, 0.25, 0.5, 0.9, 0.99, 0.999}) {
const double alpha = fraction * entry.estimate.boundaryStepSize;
trialElement = acceptedElement;
trialElement.Add(alpha, directionElement);
const mapping::ElementDisplacementData trialData(*displacementFe, trialElement, displacementSpace.GetOrdering());
const mapping::ElementMappingData trialMappingData{trialData, compactificationData};
const auto status = mapper.EvaluatePoint(trialMappingData, *transformation, point, workspace, direct);
mfem::DenseMatrix affine(base.mapping_jacobian);
affine.Add(alpha, variation.mapping_jacobian_variation);
const double predictedDeterminant = Polynomial(coefficients, alpha);
const double nan = std::numeric_limits<double>::quiet_NaN();
double relativeMatrixError = nan, relativeDeterminantError = nan;
double minimumSingular = nan, maximumSingular = nan, displacementDeterminant = nan, displacementMinimumSingular = nan;
if (status == mapping::MappingStatus::valid) {
relativeMatrixError = Difference(direct.mapping_jacobian, affine) / std::max(Frobenius(affine), 1.0e-300);
relativeDeterminantError = std::abs(direct.mapping_determinant - predictedDeterminant) /
std::max(std::abs(base.mapping_determinant), 1.0e-300);
minimumSingular = direct.mapping_jacobian.CalcSingularvalue(2);
maximumSingular = direct.mapping_jacobian.CalcSingularvalue(0);
// DomainMapper's displacement_jacobian already includes I.
const mfem::DenseMatrix &displacementMapping = direct.displacement_jacobian;
displacementDeterminant = displacementMapping.Det();
displacementMinimumSingular = displacementMapping.CalcSingularvalue(2);
report.maximumRelativeMappingError = std::max(report.maximumRelativeMappingError, relativeMatrixError);
report.maximumRelativeDeterminantError = std::max(report.maximumRelativeDeterminantError, relativeDeterminantError);
} else {
++report.invalidDirectSamples;
}
sampleCsv << element << ',' << entry.globalRule << ',' << entry.estimate.limitingQuadraturePoint << ','
<< alpha << ',' << fraction << ',' << static_cast<int>(status) << ',' << predictedDeterminant << ','
<< (status == mapping::MappingStatus::valid ? direct.mapping_determinant : nan) << ','
<< relativeDeterminantError << ',' << relativeMatrixError << ',' << minimumSingular << ','
<< maximumSingular << ',' << displacementDeterminant << ',' << displacementMinimumSingular << '\n';
}
}
std::cout << "Geometry[" << label << "]: boundary=" << report.boundaryStep << " safe=" << report.safeStep
<< " limiter=" << report.limitingElement << " limited_elements=" << report.limitedElements
<< " ties_1ppm=" << report.elementsWithinOnePartPerMillion << " ties_1pct=" << report.elementsWithinOnePercent
<< " samples=" << report.sampleCount << " max_mapping_error=" << report.maximumRelativeMappingError
<< " max_det_error=" << report.maximumRelativeDeterminantError
<< " invalid_direct_samples=" << report.invalidDirectSamples << std::endl;
return report;
}
} // namespace experiment

View File

@@ -0,0 +1,472 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numbers>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <mfem.hpp>
import mean_field;
#include "geometry_quality_diagnostics.hpp"
namespace {
using namespace mean_field;
using Clock = std::chrono::steady_clock;
struct Options {
std::string mesh = "sandbox.smesh";
std::filesystem::path output = "geometry_quality_results";
std::vector<double> tolerances{0.03, 0.003};
int maximumLinearIterations = 200;
int advance = 0;
bool finiteDifferences = true;
bool blockActions = true;
bool geometryOnly = false;
bool saveVectors = false;
bool warm = false;
std::filesystem::path replayVectors;
bool diagonalOnly=false;
};
Options Parse(int argc, char **argv) {
Options o;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
auto value = [&]() -> std::string {
if (++i >= argc) throw std::invalid_argument("Missing value after " + arg);
return argv[i];
};
if (arg == "--mesh") o.mesh = value();
else if (arg == "--output") o.output = value();
else if (arg == "--tolerances") {
o.tolerances.clear();
std::istringstream stream(value());
for (std::string token; std::getline(stream, token, ',');) {
const double tolerance = std::stod(token);
if (!(tolerance > 0.0 && tolerance < 1.0)) throw std::invalid_argument("Invalid tolerance");
o.tolerances.push_back(tolerance);
}
if (o.tolerances.empty()) throw std::invalid_argument("Empty tolerance list");
} else if (arg == "--max-linear-iterations") o.maximumLinearIterations = std::stoi(value());
else if (arg == "--advance") o.advance = std::stoi(value());
else if (arg == "--no-fd") o.finiteDifferences = false;
else if (arg == "--no-block-actions") o.blockActions = false;
else if (arg == "--geometry-only") o.geometryOnly = true;
else if (arg == "--save-vectors") o.saveVectors = true;
else if (arg == "--warm") o.warm = true;
else if (arg == "--replay-vectors") o.replayVectors=value();
else if (arg == "--diagonal-only") o.diagonalOnly=true;
else if (arg == "--help") {
std::cout << "geometry_quality_experiment [--mesh FILE] [--output NEW_DIRECTORY]\n"
" [--tolerances 0.03,0.003] [--max-linear-iterations 200] [--advance N]\n"
" [--warm] [--geometry-only] [--no-fd] [--no-block-actions] [--save-vectors]\n"
" [--replay-vectors CASE_vectors.csv] (verifies saved accepted state, no linear solve)\n"
" [--diagonal-only] (seed replay only; skips per-element scans and residual checks)\n"
"Single MPI rank only. Defaults inspect a frozen seed; --advance uses production Newton.\n";
std::exit(0);
} else throw std::invalid_argument("Unknown option: " + arg);
}
if (o.advance < 0 || o.maximumLinearIterations < 1) throw std::invalid_argument("Invalid iteration count");
if (!o.replayVectors.empty()) o.tolerances.resize(1);
if (o.diagonalOnly) {
if (o.replayVectors.empty() || o.advance!=0 || o.geometryOnly) throw std::invalid_argument("--diagonal-only requires seed correction replay");
o.finiteDifferences=false; o.blockActions=false;
}
return o;
}
std::ofstream File(const std::filesystem::path &path) {
std::ofstream stream(path);
stream.exceptions(std::ios::failbit | std::ios::badbit);
stream << std::setprecision(17);
return stream;
}
double Norm(const mfem::Vector &v, int offset, int size) {
long double sum = 0;
for (int i = offset; i < offset + size; ++i) sum += static_cast<long double>(v(i)) * v(i);
return std::sqrt(sum);
}
double MaxAbs(const mfem::Vector &v, int offset, int size) {
double result = 0;
for (int i = offset; i < offset + size; ++i) result = std::max(result, std::abs(v(i)));
return result;
}
template <typename State>
void Inspect(State &s, const fem::FEM &fem, const Options &options) {
auto &problem = s.Problem();
const auto &deformation = problem.GetPhysicalOperator().GetDomainDeformation();
// Recompile only the public surface-coordinate descriptor to inspect its
// radii/directions; the actual extension always remains the context's.
mfem::Vector center(3); center=0.0;
const deformation::SurfaceDeformationCompilationContext surfaceContext{
*fem.surfaceDeformationFes,
field::make_stellar_surface_scalar_dof_map<utils::domain::CoreEnvelopeVacuumDomainSchema>(*fem.surfaceDeformationFes)};
const auto surface=deformation::compileSurfaceDeformationPrescription(deformation::NodalRadialSurface{center},surfaceContext);
const auto values = problem.GetManifest().valueBlocks();
const auto rows = problem.GetManifest().residualBlocks();
const auto surfaceIterator = std::find_if(values.begin(), values.end(), [](const auto &b) {
return b.stableId == "surface_deformation";
});
if (surfaceIterator == values.end()) throw std::logic_error("Experiment requires a surface-deformation block");
const auto surfaceBlock = *surfaceIterator;
const mfem::Vector acceptedVolume(problem.GetPhysicalOperator().GetGeneratedVolumeDisplacement());
const mfem::Vector physicalResidual(s.normalizedOperator->GetPhysicalResidual());
const mfem::Vector warmCorrection(s.normalizedCorrection);
auto residuals = File(options.output / "blocks.csv");
residuals << "case,kind,block,size,physical_l2,normalized_l2,physical_linf,normalized_linf,linear_residual_over_block_F\n";
auto record = [&](const std::string &label, const std::string &kind, auto blocks,
const mfem::Vector &physical, const mfem::Vector &normalized, bool relative) {
for (const auto &b : blocks) {
const double denominator = relative ? Norm(s.acceptedNormalizedResidual, b.offset, b.size) : 0.0;
residuals << label << ',' << kind << ',' << b.stableId << ',' << b.size << ','
<< Norm(physical, b.offset, b.size) << ',' << Norm(normalized, b.offset, b.size) << ','
<< MaxAbs(physical, b.offset, b.size) << ',' << MaxAbs(normalized, b.offset, b.size) << ','
<< (relative && denominator > 0 ? Norm(normalized, b.offset, b.size) / denominator
: std::numeric_limits<double>::quiet_NaN()) << '\n';
}
residuals.flush();
};
record("accepted", "residual", rows, physicalResidual, s.acceptedNormalizedResidual, false);
record("accepted", "state", values, s.AcceptedPhysicalState(), s.acceptedNormalizedState, false);
auto metadata = File(options.output / "metadata.txt");
metadata << "mesh=" << options.mesh << "\nmesh_bytes=" << std::filesystem::file_size(options.mesh)
<< "\ncompiled=" << __DATE__ << ' ' << __TIME__ << "\ncompiler=" << __VERSION__
<< "\npolynomial_increment=" << MEAN_FIELD_UNIFORM_POLYNOMIAL_ORDER_INCREMENT
<< "\nmpi_ranks=1\nmodel=n1_K2G_R2_over_pi_M1_R1_J0_fixed_central_density"
<< "\nnormalization=production_frozen_physical_Riesz_diagonal"
<< "\npreconditioner=production_default\nFGMRES_restart=40\nadvanced_steps=" << options.advance
<< "\nmax_linear_iterations=" << options.maximumLinearIterations
<< "\nwarm=" << options.warm << "\nstate_size=" << problem.StateSize()
<< "\nelement_count=" << fem.mesh->GetNE() << "\naccepted_residual=" << s.acceptedNormalizedResidual.Norml2()
<< "\ncoefficient_norms_are_unweighted_single_rank=true"
<< "\nsurface_mean_and_rms_are_nodal_not_area_weighted=true\ntolerances=";
for (double t : options.tolerances) metadata << t << ',';
metadata << '\n';
metadata << "replay_vectors=" << options.replayVectors.string() << '\n';
metadata << "diagonal_only=" << options.diagonalOnly << '\n';
metadata.flush();
auto vertexGeometry = [&](const std::string &label,const mfem::Vector &direction) {
// Deliberately independent sampling of the known sandbox core corners.
// Uses the unmodified production estimator with a vertex-only rule.
std::vector<deformation::NewtonStepGeometryRule> rules;
for (int element : {0,9,18,27,36,45,54,63}) {
if (element<fem.mesh->GetNE() && fem.mesh->GetAttribute(element)==1 &&
fem.mesh->GetElementBaseGeometry(element)==mfem::Geometry::CUBE)
rules.push_back({element,mfem::Geometries.GetVertices(mfem::Geometry::CUBE)});
}
if (!rules.empty()) experiment::InspectGeometry(problem.GetDiscretization().domainMapper(),*fem.displacementFes,
*fem.compactificationCoordinate,acceptedVolume,direction,rules,options.output,label+"_vertices");
};
auto geometry = [&](const std::string &label, const mfem::Vector &physicalDirection) {
mfem::Vector volumeDirection(acceptedVolume.Size());
problem.BuildVolumeDisplacementDirection(physicalDirection, volumeDirection);
std::cout << "Geometry: " << label << std::endl;
if (options.advance==0 && (label=="newton_0" || label=="uniform_contraction")) {
int corner=0;
double minimumSum=std::numeric_limits<double>::infinity();
for (int i=0;i<surfaceBlock.size;++i) {
double sum=0;
for (int d=0;d<3;++d) sum+=surface.radialDirection(i,d);
if (sum<minimumSum) { minimumSum=sum; corner=i; }
}
experiment::InspectCoreDiagonal(fem,volumeDirection,physicalDirection(surfaceBlock.offset+corner),options.output,label);
}
if (options.advance==0 && !options.replayVectors.empty()) vertexGeometry(label,volumeDirection);
if (options.diagonalOnly) return experiment::GeometryReport{};
return experiment::InspectGeometry(problem.GetDiscretization().domainMapper(), *fem.displacementFes,
*fem.compactificationCoordinate, acceptedVolume, volumeDirection, s.geometryPreflightRules,
options.output, label);
};
mfem::Vector contraction(problem.StateSize());
contraction = 0.0;
for (int i = 0; i < surfaceBlock.size; ++i) contraction(surfaceBlock.offset + i) = -surface.referenceRadius(i);
geometry("uniform_contraction", contraction);
// Controls, not candidate production prescriptions: bypass the surface
// extension and inspect only core (attribute 1) samples. P3 interpolation
// cannot represent the P4 physical mesh coordinates exactly.
std::vector<deformation::NewtonStepGeometryRule> coreRules;
for (const auto &rule : s.geometryPreflightRules) {
if (fem.mesh->GetAttribute(rule.element)==1) coreRules.push_back(rule);
}
if (!options.diagonalOnly) for (bool radial : {false,true}) {
mfem::VectorFunctionCoefficient coefficient(3,[radial](const mfem::Vector &x,mfem::Vector &u) {
u=x;
u *= radial ? -x.Norml2()/utils::RADIUS : -1.0;
});
mfem::ParGridFunction projected(fem.displacementFes.get());
projected.ProjectCoefficient(coefficient);
mfem::Vector direction; projected.GetTrueDofs(direction);
experiment::InspectGeometry(problem.GetDiscretization().domainMapper(), *fem.displacementFes,
*fem.compactificationCoordinate, acceptedVolume, direction, coreRules, options.output,
radial ? "core_projected_physical_radial_contraction" : "core_projected_affine_contraction");
}
if (options.geometryOnly) return;
auto solves = File(options.output / "solves.csv");
solves << "case,tolerance,status,iterations,relative_true_residual,wall_seconds,safe_step,boundary_step,limiting_element,correction_difference_from_baseline,surface_difference_from_baseline,action_difference_over_F\n";
auto surfaceFile = File(options.output / "surface.csv");
surfaceFile << "case,parameter,x,y,z,radius,accepted_fraction,correction_fraction\n";
auto surfaceSummary = File(options.output / "surface_summary.csv");
surfaceSummary << "case,mean_fraction,rms_fraction,rms_nonmean_fraction,min_fraction,max_fraction\n";
auto columns = File(options.output / "block_actions.csv");
columns << "case,column,row,normalized_action_l2,normalized_dot_with_F\n";
auto fd = File(options.output / "finite_differences.csv");
fd << "case,epsilon,row,relative_error,absolute_error,action_norm\n";
auto extensionChecks = File(options.output / "extension_checks.csv");
extensionChecks << "case,epsilon,relative_volume_direction_error,trial_residual_norm\n";
mfem::Vector baseline, baselineAction;
for (std::size_t caseIndex = 0; caseIndex < options.tolerances.size(); ++caseIndex) {
const std::string label = "newton_" + std::to_string(caseIndex);
const double tolerance = options.tolerances[caseIndex];
s.normalizedCorrection = options.warm ? warmCorrection : mfem::Vector(problem.StateSize());
if (!options.warm) s.normalizedCorrection = 0.0;
s.linearRightHandSide = s.acceptedNormalizedResidual;
s.linearRightHandSide *= -1;
std::cout << "Solve: " << label << " tolerance=" << tolerance << std::endl;
const auto start = Clock::now();
solver::LinearSolveReport solve;
if (options.replayVectors.empty()) {
solve=s.linearBackend->Solve(s.linearRightHandSide, s.normalizedCorrection,
{.relativeTolerance=tolerance, .absoluteTolerance=0.0, .maximumIterations=options.maximumLinearIterations});
} else {
std::ifstream saved(options.replayVectors);
if (!saved) throw std::runtime_error("Cannot open replay vectors");
std::string line; std::getline(saved,line);
if (line!="index,physical_state,normalized_state,physical_correction,normalized_correction")
throw std::runtime_error("Unrecognized replay vector header");
int index=0;
while (std::getline(saved,line)) {
std::replace(line.begin(),line.end(),',',' ');
std::istringstream row(line);
int savedIndex; double physicalState, normalizedState, physicalCorrection, normalizedCorrection;
if (!(row>>savedIndex>>physicalState>>normalizedState>>physicalCorrection>>normalizedCorrection) ||
savedIndex!=index || index>=problem.StateSize()) throw std::runtime_error("Invalid replay vector row");
if (std::abs(physicalState-s.AcceptedPhysicalState()(index))>1e-12*(1+std::abs(physicalState)) ||
std::abs(normalizedState-s.acceptedNormalizedState(index))>1e-12*(1+std::abs(normalizedState)))
throw std::runtime_error("Replay state differs from this frozen context");
if (!std::isfinite(normalizedCorrection)) throw std::runtime_error("Nonfinite replay direction");
s.normalizedCorrection(index++)=normalizedCorrection;
}
if (index!=problem.StateSize()) throw std::runtime_error("Incomplete replay vector file");
mfem::Vector check(problem.EquationSize());
s.normalizedOperator->Mult(s.normalizedCorrection,check);
check+=s.acceptedNormalizedResidual;
solve.relativeTrueResidualNorm=check.Norml2()/s.acceptedNormalizedResidual.Norml2();
solve.status=solve.relativeTrueResidualNorm<=tolerance ? solver::LinearSolveStatus::converged : solver::LinearSolveStatus::maximum_iterations;
solve.iterations=0;
std::cout << "Replayed saved correction; true residual independently checked" << std::endl;
}
const double elapsed = std::chrono::duration<double>(Clock::now()-start).count();
std::cout << "Solved: iterations=" << solve.iterations << " true_relative=" << solve.relativeTrueResidualNorm
<< " wall=" << elapsed << std::endl;
s.normalizedOperator->DenormalizeState(s.normalizedCorrection, s.physicalCorrection);
const auto safe = s.EstimateLargestSafeStepSize(1.0, 0.9);
geometry(label, s.physicalCorrection);
if (caseIndex==0 && options.advance==0 && !options.replayVectors.empty()) {
mfem::Vector zeroSurface(surfaceBlock.size), surfaceDirection(surfaceBlock.size);
zeroSurface=0.0;
for (int i=0;i<surfaceBlock.size;++i) surfaceDirection(i)=s.physicalCorrection(surfaceBlock.offset+i);
const auto extensionContext=deformation::makeRadialDeformationExtensionCompilationContext(
*fem.surfaceDeformationFes,*fem.displacementFes,*fem.logicalReferenceMesh);
for (double power : {3.0,4.0}) {
// Existing production prescription, but a geometry-only
// intervention: this is NOT a Newton direction for the new
// parameterization until that system is rebuilt and solved.
auto alternativeSurface=deformation::compileSurfaceDeformationPrescription(
deformation::NodalRadialSurface{center},surfaceContext);
auto alternativeInterior=deformation::compileInteriorDeformationExtension(
deformation::PowerLawRadialInteriorExtension{power},extensionContext);
auto alternativeVacuum=deformation::compileVacuumDeformationExtension(
deformation::FixedInfinityRadialVacuumExtension{},extensionContext);
auto alternative=deformation::composePreparedDomainDeformation(
std::move(alternativeSurface),std::move(alternativeInterior),std::move(alternativeVacuum),
*fem.surfaceDeformationFes,*fem.displacementFes,*fem.logicalReferenceMesh);
mfem::Vector direction(acceptedVolume.Size());
alternative.applyJacobian(zeroSurface,surfaceDirection,direction);
const std::string control="newton_0_radial_power_"+std::to_string(static_cast<int>(power));
int corner=0; double smallest=std::numeric_limits<double>::infinity();
for (int i=0;i<surfaceBlock.size;++i) {
double sum=0; for (int d=0;d<3;++d) sum+=surface.radialDirection(i,d);
if (sum<smallest) { smallest=sum; corner=i; }
}
experiment::InspectCoreDiagonal(fem,direction,surfaceDirection(corner),options.output,control,power);
vertexGeometry(control,direction);
if (!options.diagonalOnly) experiment::InspectGeometry(problem.GetDiscretization().domainMapper(),*fem.displacementFes,
*fem.compactificationCoordinate,acceptedVolume,direction,s.geometryPreflightRules,options.output,control);
}
}
mfem::Vector action(problem.EquationSize()), linearResidual(problem.EquationSize()), physicalLinear(problem.EquationSize());
s.normalizedOperator->Mult(s.normalizedCorrection, action);
linearResidual = action;
linearResidual += s.acceptedNormalizedResidual;
s.normalizedOperator->DenormalizeResidual(linearResidual, physicalLinear);
record(label, "linear_residual", rows, physicalLinear, linearResidual, true);
record(label, "correction", values, s.physicalCorrection, s.normalizedCorrection, false);
if (caseIndex == 0) { baseline = s.normalizedCorrection; baselineAction=action; }
mfem::Vector difference(s.normalizedCorrection);
difference -= baseline;
mfem::Vector actionDifference(action); actionDifference-=baselineAction;
solves << label << ',' << tolerance << ',' << static_cast<int>(solve.status) << ',' << solve.iterations << ','
<< solve.relativeTrueResidualNorm << ',' << elapsed << ',' << safe.stepSize << ',' << safe.boundaryStepSize
<< ',' << safe.limitingElement << ',' << difference.Norml2()/std::max(baseline.Norml2(),1e-300) << ','
<< Norm(difference,surfaceBlock.offset,surfaceBlock.size)/std::max(Norm(baseline,surfaceBlock.offset,surfaceBlock.size),1e-300) << ','
<< actionDifference.Norml2()/std::max(s.acceptedNormalizedResidual.Norml2(),1e-300) << '\n';
solves.flush();
double sum=0, squared=0, minimum=std::numeric_limits<double>::infinity(), maximum=-minimum;
for (int i=0; i<surfaceBlock.size; ++i) {
const double radius=surface.referenceRadius(i);
const double fraction=s.physicalCorrection(surfaceBlock.offset+i)/radius;
sum += fraction; squared += fraction*fraction; minimum=std::min(minimum,fraction); maximum=std::max(maximum,fraction);
surfaceFile << label << ',' << i;
for (int d=0; d<3; ++d) surfaceFile << ',' << surface.referenceCenter()(d)+radius*surface.radialDirection(i,d);
surfaceFile << ',' << radius << ',' << s.AcceptedPhysicalState()(surfaceBlock.offset+i)/radius << ',' << fraction << '\n';
}
const double mean=sum/surfaceBlock.size, meanSquared=squared/surfaceBlock.size;
surfaceSummary << label << ',' << mean << ',' << std::sqrt(meanSquared) << ','
<< std::sqrt(std::max(0.0,meanSquared-mean*mean)) << ',' << minimum << ',' << maximum << '\n';
surfaceFile.flush(); surfaceSummary.flush();
if (caseIndex == 0) {
// Split only the surface component; other physical fields do not
// enter the production volume-extension Jacobian.
mfem::Vector meanDirection(problem.StateSize()), nonmeanDirection(s.physicalCorrection);
meanDirection = 0.0;
for (int i=0;i<surfaceBlock.size;++i) {
meanDirection(surfaceBlock.offset+i)=mean*surface.referenceRadius(i);
nonmeanDirection(surfaceBlock.offset+i)-=meanDirection(surfaceBlock.offset+i);
}
geometry(label+"_mean",meanDirection);
geometry(label+"_nonmean",nonmeanDirection);
}
if (options.saveVectors) {
auto vectors=File(options.output/(label+"_vectors.csv"));
vectors << "index,physical_state,normalized_state,physical_correction,normalized_correction\n";
for (int i=0;i<problem.StateSize();++i) vectors << i << ',' << s.AcceptedPhysicalState()(i) << ','
<< s.acceptedNormalizedState(i) << ',' << s.physicalCorrection(i) << ',' << s.normalizedCorrection(i) << '\n';
}
if (options.blockActions && caseIndex == 0) {
std::cout << "Block-column actions" << std::endl;
for (const auto &column : values) {
mfem::Vector direction(problem.StateSize()), blockAction(problem.EquationSize());
direction = 0.0;
for (int i=column.offset;i<column.offset+column.size;++i) direction(i)=s.normalizedCorrection(i);
s.normalizedOperator->Mult(direction,blockAction);
for (const auto &row : rows) {
double dot=0;
for (int i=row.offset;i<row.offset+row.size;++i) dot+=blockAction(i)*s.acceptedNormalizedResidual(i);
columns << label << ',' << column.stableId << ',' << row.stableId << ','
<< Norm(blockAction,row.offset,row.size) << ',' << dot << '\n';
}
columns.flush();
}
}
if (options.finiteDifferences && caseIndex == 0) {
// Forward differences remain inside the verified positive-alpha interval.
// Frozen normalization and fresh dependency revisions match production trial preparation.
mfem::Vector expectedVolumeDirection(acceptedVolume.Size());
problem.BuildVolumeDisplacementDirection(s.physicalCorrection,expectedVolumeDirection);
for (double multiplier : {1e-2,1e-3,1e-4}) {
const double epsilon = std::min(1.0,safe.stepSize)*multiplier;
if (!(epsilon>0)) throw std::runtime_error("No admissible finite-difference step");
std::cout << "Finite difference epsilon=" << epsilon << std::endl;
s.trialNormalizedState=s.acceptedNormalizedState;
s.trialNormalizedState.Add(epsilon,s.normalizedCorrection);
const auto preparation=s.PrepareTrial();
if (!preparation) throw std::runtime_error("Finite-difference trial preparation rejected");
mfem::Vector volumeError(problem.GetPhysicalOperator().GetGeneratedVolumeDisplacement());
volumeError-=acceptedVolume;
volumeError/=epsilon;
volumeError-=expectedVolumeDirection;
extensionChecks << label << ',' << epsilon << ','
<< volumeError.Norml2()/std::max(expectedVolumeDirection.Norml2(),1e-300) << ','
<< s.trialNormalizedResidual.Norml2() << '\n';
extensionChecks.flush();
mfem::Vector approximation(s.trialNormalizedResidual);
approximation-=s.acceptedNormalizedResidual;
approximation/=epsilon;
approximation-=action;
for (const auto &row:rows) {
const double error=Norm(approximation,row.offset,row.size), magnitude=Norm(action,row.offset,row.size);
fd << label << ',' << epsilon << ',' << row.stableId << ','
<< (magnitude>0 ? error/magnitude : std::numeric_limits<double>::quiet_NaN()) << ',' << error << ',' << magnitude << '\n';
}
fd.flush();
}
s.RestoreAccepted();
}
}
}
} // namespace
int main(int argc, char **argv) {
mfem::Mpi::Init(argc,argv);
try {
const auto options=Parse(argc,argv);
int ranks=0; MPI_Comm_size(MPI_COMM_WORLD,&ranks);
if (ranks!=1) throw std::invalid_argument("This diagnostic executable currently requires exactly one MPI rank");
if (std::filesystem::exists(options.output)) throw std::invalid_argument("Output directory already exists; use a new --output path");
std::filesystem::create_directories(options.output);
mfem::Device device("cpu");
utils::Args args; args.mesh_file=options.mesh; args.p.rtol=1e-12; args.p.atol=1e-12;
const auto start=Clock::now();
auto fem=fem::setup_fem(args.mesh_file,args,0);
if (!fem.okay()) throw std::runtime_error("Finite-element setup failed");
constexpr double radius=utils::RADIUS, mass=utils::MASS, G=utils::G;
auto model=model::StellarModel(eos::Polytrope({.n=1.0,.K=2*G*radius*radius/std::numbers::pi}),
surface::Isobaric({.Psurf=dimensions::PressureValue{0}}),
integral::FixedTotalMass({.Mtotal=dimensions::MassValue{mass}}),
integral::FixedAngularMomentum({.Jtotal=dimensions::AngularMomentumValue{0},.axis={0,0,1},.center={0,0,0}}),
constraint::FixedCentralDensity({.RhoC=dimensions::DensityValue{std::numbers::pi*mass/(4*radius*radius*radius)}}));
auto discretization=equilibrium::makeStellarDiscretization(std::move(fem),
normalization::PhysicalRieszDiagonal{dimensions::LengthValue{radius},G});
std::cout << "Constructing production context" << std::endl;
auto context=solver::makeContext(std::move(model),std::move(discretization),
preconditioning::makePreconditioner(),solver::linear::FGMRES({.restartLength=40,.printLevel=-1}));
std::cout << "Setup seconds=" << std::chrono::duration<double>(Clock::now()-start).count() << std::endl;
if (options.advance>0) {
auto trajectory=File(options.output/"trajectory.csv");
trajectory << "iteration,residual,step,limiting_element,boundary\n";
auto observer=solver::nonlinear::makeObserver([](const solver::nonlinear::BeforeIteration &) {},
[&](const solver::nonlinear::AfterIteration &event) {
trajectory << event.iteration << ',' << event.residualNorm << ',' << event.acceptedStepLength << ','
<< (event.geometryPreflight ? event.geometryPreflight->limitingElement : -1) << ','
<< (event.geometryPreflight ? event.geometryPreflight->boundaryStepSize : 0) << '\n';
trajectory.flush();
std::cout << "Advance iteration=" << event.iteration << " residual=" << event.residualNorm << " step=" << event.acceptedStepLength << std::endl;
});
auto method=solver::nonlinear::Newton(solver::nonlinear::NewtonOptions{
.relativeTolerance=1e-8,.absoluteTolerance=0,.maximumIterations=options.advance,
.linearSolve={.relativeTolerance=0.03,.absoluteTolerance=0,.maximumIterations=200},.backtracking={}});
auto solve=solver::make(context,method,observer);
auto report=solve.evaluate();
std::cout << "Production accepted steps=" << report.completedNonlinearIterations() << std::endl;
}
solver::detail::StellarEquilibriumContextDiagnostics::WithState(context,[&](auto &state, auto &fem) { Inspect(state,fem,options); });
if (!context.isReady() || context.hasActiveSolver()) throw std::logic_error("Diagnostic access did not restore the context");
std::cout << "Experiment complete: " << options.output << " total_seconds="
<< std::chrono::duration<double>(Clock::now()-start).count() << std::endl;
return 0;
} catch (const std::exception &error) {
std::cerr << "geometry_quality_experiment: " << error.what() << '\n';
return 1;
}
}

View File

@@ -0,0 +1,155 @@
#pragma once
// Experiment-only closed-form benchmark. This intentionally does not use the
// production Lane-Emden integration, radial interpolation, EOS, or seed helpers.
#include <cmath>
#include <initializer_list>
#include <numbers>
#include <stdexcept>
namespace experiment::polytrope_validation {
struct AnalyticValues final {
double density{};
double enthalpy{};
double pressure{};
double potential{};
double enclosedMass{};
// Outward-positive dPhi/dr, not the inward gravitational acceleration.
double radialPotentialGradient{};
};
struct N1Reference final {
double gravitationalConstant{1.0};
double mass{1.0};
double radius{1.0};
// Validate once before using this reference in a quadrature loop.
void Validate() const {
if (!std::isfinite(gravitationalConstant) || gravitationalConstant <= 0.0
|| !std::isfinite(mass) || mass <= 0.0
|| !std::isfinite(radius) || radius <= 0.0) {
throw std::invalid_argument("The n=1 reference requires finite, positive G, M, and R.");
}
for (const double scale : {PolytropicConstant(), CentralDensity(), CentralEnthalpy(),
PressureIntegral(), -BindingEnergy(), MomentOfInertia(),
CentralEnthalpy() / radius, 0.5 * CentralEnthalpy() * CentralDensity()}) {
if (!std::isfinite(scale) || scale <= 0.0) {
throw std::invalid_argument("The n=1 reference scales are not representable as positive finite doubles.");
}
}
}
[[nodiscard]] double PolytropicConstant() const {
return 2.0 * gravitationalConstant * radius * radius / std::numbers::pi;
}
[[nodiscard]] double CentralDensity() const {
return (std::numbers::pi / 4.0) * (mass / radius) / radius / radius;
}
[[nodiscard]] double CentralEnthalpy() const {
return gravitationalConstant * mass / radius;
}
[[nodiscard]] double PressureIntegral() const {
return 0.25 * CentralEnthalpy() * mass;
}
// W = (1/2) integral rho Phi dV, with Phi tending to zero at infinity.
[[nodiscard]] double BindingEnergy() const {
return -0.75 * CentralEnthalpy() * mass;
}
// Axial moment of inertia, not integral rho r^2 dV.
[[nodiscard]] double MomentOfInertia() const {
constexpr double coefficient = (2.0 / 3.0)
* (1.0 - 6.0 / (std::numbers::pi * std::numbers::pi));
return coefficient * mass * radius * radius;
}
[[nodiscard]] double DimensionlessTheta(const double physicalRadius) const {
CheckRadius(physicalRadius);
if (physicalRadius >= radius) return 0.0;
const double fraction = physicalRadius / radius;
const double argument = std::numbers::pi * fraction;
if (argument < 0.25) {
const double squared = argument * argument;
return 1.0 + squared * (-1.0 / 6.0 + squared * (1.0 / 120.0
+ squared * (-1.0 / 5040.0 + squared * (1.0 / 362880.0
+ squared * (-1.0 / 39916800.0 + squared / 6227020800.0)))));
}
if (fraction > 0.5) {
// sin(pi-delta) avoids the nonzero floating-point sin(pi)
// floor. Form the small surface distance before dividing.
const double surfaceDistance = (radius - physicalRadius) / radius;
return std::sin(std::numbers::pi * surfaceDistance) / argument;
}
return std::sin(argument) / argument;
}
// This normalization also accepts numerical potentials: do not clamp
// its result to the stellar theta range or fit an additive constant.
[[nodiscard]] double NormalizedPotential(const double potential) const {
return -potential / CentralEnthalpy() - 1.0;
}
[[nodiscard]] AnalyticValues AtRadius(const double physicalRadius) const {
CheckRadius(physicalRadius);
const double centralEnthalpy = CentralEnthalpy();
if (physicalRadius >= radius) {
const double surfaceFraction = radius / physicalRadius;
return {
.density = 0.0,
.enthalpy = 0.0,
.pressure = 0.0,
.potential = -centralEnthalpy * surfaceFraction,
.enclosedMass = mass,
.radialPotentialGradient = (centralEnthalpy / radius) * surfaceFraction * surfaceFraction
};
}
const double fraction = physicalRadius / radius;
const double argument = std::numbers::pi * fraction;
const double theta = DimensionlessTheta(physicalRadius);
double massFraction = 0.0;
double gradientFraction = 0.0;
if (argument < 0.25) {
// sin(x)-x*cos(x) = x^3 [1/3-x^2/30+x^4/840-...].
// Evaluate g separately from m/r^2 to remain regular even
// when the representable enclosed mass underflows at r~0.
const double squared = argument * argument;
const double factor = 1.0 / 3.0 + squared * (-1.0 / 30.0
+ squared * (1.0 / 840.0 + squared * (-1.0 / 45360.0
+ squared * (1.0 / 3991680.0 - squared / 518918400.0))));
massFraction = std::numbers::pi * std::numbers::pi
* fraction * fraction * fraction * factor;
gradientFraction = std::numbers::pi * std::numbers::pi * fraction * factor;
} else {
double numerator = 0.0;
if (fraction > 0.5) {
const double delta = std::numbers::pi * ((radius - physicalRadius) / radius);
numerator = std::sin(delta) + argument * std::cos(delta);
} else {
numerator = std::sin(argument) - argument * std::cos(argument);
}
massFraction = numerator / std::numbers::pi;
gradientFraction = std::numbers::pi * numerator / (argument * argument);
}
const double centralDensity = CentralDensity();
return {
.density = centralDensity * theta,
.enthalpy = centralEnthalpy * theta,
.pressure = 0.5 * centralEnthalpy * centralDensity * theta * theta,
.potential = -centralEnthalpy * (1.0 + theta),
.enclosedMass = mass * massFraction,
.radialPotentialGradient = (centralEnthalpy / radius) * gradientFraction
};
}
private:
static void CheckRadius(const double physicalRadius) {
if (!std::isfinite(physicalRadius) || physicalRadius < 0.0) {
throw std::invalid_argument("The analytic reference radius must be finite and nonnegative.");
}
}
};
} // namespace experiment::polytrope_validation

View File

@@ -0,0 +1,221 @@
#pragma once
#include "polytrope_analytic_reference.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <string>
#include <utility>
#include <vector>
namespace experiment::polytrope_validation {
struct AnalyticSelfCheck final {
std::string name;
double observed{};
double expected{};
double scale{1.0};
double tolerance{};
bool passed{};
[[nodiscard]] double AbsoluteError() const { return std::abs(observed - expected); }
[[nodiscard]] double ScaledError() const { return AbsoluteError() / scale; }
};
struct AnalyticSelfCheckReport final {
std::vector<AnalyticSelfCheck> checks;
[[nodiscard]] bool Passed() const {
return std::all_of(checks.begin(), checks.end(), [](const AnalyticSelfCheck &check) {
return check.passed;
});
}
};
namespace analytic_detail {
struct RadialIntegrals final {
long double mass{};
long double pressure{};
long double potentialEnergy{};
long double gradientEnergy{};
long double fieldEnergy{};
long double momentOfInertia{};
};
// Independent physical radial integration: no mesh, projection, seed,
// or production quadrature implementation is involved in these checks.
inline RadialIntegrals IntegrateReference(const N1Reference &reference) {
constexpr int intervals = 8192;
constexpr long double pi = std::numbers::pi_v<long double>;
const long double spacing = static_cast<long double>(reference.radius) / intervals;
RadialIntegrals result;
for (int index = 0; index <= intervals; ++index) {
const double radius = reference.radius * (static_cast<double>(index) / intervals);
const auto values = reference.AtRadius(radius);
const long double r = radius;
const long double density = values.density;
const long double gradient = values.radialPotentialGradient;
const long double weight = (index == 0 || index == intervals) ? 1.0L
: ((index % 2 == 0) ? 2.0L : 4.0L);
const long double volumeWeight = weight * 4.0L * pi * r * r;
result.mass += volumeWeight * density;
result.pressure += volumeWeight * values.pressure;
result.potentialEnergy += 0.5L * volumeWeight * density * values.potential;
result.gradientEnergy -= volumeWeight * density * r * gradient;
result.fieldEnergy -= volumeWeight * gradient * gradient
/ (8.0L * pi * reference.gravitationalConstant);
result.momentOfInertia += (2.0L / 3.0L) * volumeWeight * density * r * r;
}
const long double factor = spacing / 3.0L;
result.mass *= factor;
result.pressure *= factor;
result.potentialEnergy *= factor;
result.gradientEnergy *= factor;
result.fieldEnergy *= factor;
result.momentOfInertia *= factor;
// The gravitational field outside the star is not zero. Its
// analytic contribution is essential to the field-energy identity.
result.fieldEnergy -= static_cast<long double>(reference.gravitationalConstant)
* reference.mass * reference.mass / (2.0L * reference.radius);
return result;
}
} // namespace analytic_detail
inline AnalyticSelfCheckReport RunAnalyticSelfChecks() {
AnalyticSelfCheckReport report;
auto check = [&](std::string name, const double observed, const double expected,
const double scale, const double tolerance) {
const bool passed = std::isfinite(observed) && std::isfinite(expected)
&& std::isfinite(scale) && scale > 0.0
&& std::abs(observed - expected) <= tolerance * scale;
report.checks.push_back({std::move(name), observed, expected, scale, tolerance, passed});
};
const std::array<std::pair<std::string, N1Reference>, 2> references{{
{"unit", {}},
{"nonunit", {.gravitationalConstant = 2.3, .mass = 3.7, .radius = 1.9}}
}};
for (const auto &[label, reference] : references) {
reference.Validate();
const double densityScale = reference.CentralDensity();
const double enthalpyScale = reference.CentralEnthalpy();
const double gradientScale = enthalpyScale / reference.radius;
const double energyScale = enthalpyScale * reference.mass;
const double inertiaScale = reference.mass * reference.radius * reference.radius;
const auto origin = reference.AtRadius(0.0);
check(label + ".origin.density", origin.density, densityScale, densityScale, 0.0);
check(label + ".origin.enthalpy", origin.enthalpy, enthalpyScale, enthalpyScale, 0.0);
check(label + ".origin.potential", origin.potential, -2.0 * enthalpyScale, enthalpyScale, 0.0);
check(label + ".origin.enclosed_mass", origin.enclosedMass, 0.0, reference.mass, 0.0);
check(label + ".origin.gradient", origin.radialPotentialGradient, 0.0, gradientScale, 0.0);
constexpr double smallFraction = 1.0e-8;
const auto nearOrigin = reference.AtRadius(reference.radius * smallFraction);
constexpr double centralSlope = std::numbers::pi * std::numbers::pi / 3.0;
check(label + ".origin.mass_cubic_coefficient",
nearOrigin.enclosedMass / (reference.mass * smallFraction * smallFraction * smallFraction),
centralSlope, centralSlope, 5.0e-15);
check(label + ".origin.gradient_linear_coefficient",
nearOrigin.radialPotentialGradient / (gradientScale * smallFraction),
centralSlope, centralSlope, 5.0e-15);
constexpr double tinyFraction = 1.0e-200;
const auto tinyRadius = reference.AtRadius(reference.radius * tinyFraction);
check(label + ".origin.gradient_without_mass_underflow_division",
tinyRadius.radialPotentialGradient / (gradientScale * tinyFraction),
centralSlope, centralSlope, 5.0e-15);
const auto surface = reference.AtRadius(reference.radius);
check(label + ".surface.density", surface.density, 0.0, densityScale, 0.0);
check(label + ".surface.enthalpy", surface.enthalpy, 0.0, enthalpyScale, 0.0);
check(label + ".surface.pressure", surface.pressure, 0.0, enthalpyScale * densityScale, 0.0);
check(label + ".surface.potential", surface.potential, -enthalpyScale, enthalpyScale, 0.0);
check(label + ".surface.enclosed_mass", surface.enclosedMass, reference.mass, reference.mass, 0.0);
check(label + ".surface.gradient", surface.radialPotentialGradient, gradientScale, gradientScale, 0.0);
const double innerRadius = std::nextafter(reference.radius, 0.0);
const double outerRadius = std::nextafter(reference.radius, std::numeric_limits<double>::infinity());
const auto justInside = reference.AtRadius(innerRadius);
const auto justOutside = reference.AtRadius(outerRadius);
check(label + ".surface.potential_join", justInside.potential, justOutside.potential, enthalpyScale, 2.0e-15);
check(label + ".surface.gradient_join", justInside.radialPotentialGradient,
justOutside.radialPotentialGradient, gradientScale, 3.0e-15);
check(label + ".surface.theta_linear_coefficient",
reference.DimensionlessTheta(innerRadius) / ((reference.radius - innerRadius) / reference.radius),
1.0, 1.0, 2.0e-15);
check(label + ".surface.positive_density_inside", justInside.density > 0.0 ? 1.0 : 0.0, 1.0, 1.0, 0.0);
const auto exterior = reference.AtRadius(2.0 * reference.radius);
check(label + ".exterior.vacuum_density", exterior.density, 0.0, densityScale, 0.0);
check(label + ".exterior.point_mass_potential", exterior.potential, -0.5 * enthalpyScale, enthalpyScale, 0.0);
check(label + ".exterior.point_mass_gradient", exterior.radialPotentialGradient, 0.25 * gradientScale, gradientScale, 0.0);
check(label + ".normalization.does_not_clip_negative_theta",
reference.NormalizedPotential(exterior.potential), -0.5, 1.0, 0.0);
check(label + ".normalization.does_not_clip_positive_potential",
reference.NormalizedPotential(enthalpyScale), -2.0, 1.0, 0.0);
int radialIndex = 0;
for (const double fraction : {0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0}) {
const auto values = reference.AtRadius(reference.radius * fraction);
const std::string prefix = label + ".radial_" + std::to_string(radialIndex++);
check(prefix + ".enthalpy_eos", values.enthalpy,
2.0 * reference.PolytropicConstant() * values.density, enthalpyScale, 2.0e-15);
check(prefix + ".pressure_eos", values.pressure,
reference.PolytropicConstant() * values.density * values.density,
enthalpyScale * densityScale, 2.0e-15);
check(prefix + ".hydrostatic_constant", values.enthalpy + values.potential,
-enthalpyScale, enthalpyScale, 2.0e-15);
check(prefix + ".normalized_potential", reference.NormalizedPotential(values.potential),
reference.DimensionlessTheta(reference.radius * fraction), 1.0, 2.0e-15);
}
radialIndex = 0;
for (const double fraction : {0.1, 0.25, 0.5, 0.75, 0.9}) {
const double radius = reference.radius * fraction;
const double spacing = 1.0e-4 * reference.radius;
const auto minusTwo = reference.AtRadius(radius - 2.0 * spacing);
const auto minusOne = reference.AtRadius(radius - spacing);
const auto plusOne = reference.AtRadius(radius + spacing);
const auto plusTwo = reference.AtRadius(radius + 2.0 * spacing);
const double enthalpyDerivative = (minusTwo.enthalpy - 8.0 * minusOne.enthalpy
+ 8.0 * plusOne.enthalpy - plusTwo.enthalpy) / (12.0 * spacing);
const double massDerivative = (minusTwo.enclosedMass - 8.0 * minusOne.enclosedMass
+ 8.0 * plusOne.enclosedMass - plusTwo.enclosedMass) / (12.0 * spacing);
const auto values = reference.AtRadius(radius);
const std::string prefix = label + ".derivative_" + std::to_string(radialIndex++);
check(prefix + ".hydrostatic_balance", enthalpyDerivative + values.radialPotentialGradient,
0.0, gradientScale, 5.0e-11);
check(prefix + ".enclosed_mass", massDerivative,
4.0 * std::numbers::pi * radius * radius * values.density,
reference.mass / reference.radius, 5.0e-11);
}
const auto integrals = analytic_detail::IntegrateReference(reference);
check(label + ".integral.mass", static_cast<double>(integrals.mass), reference.mass, reference.mass, 2.0e-12);
check(label + ".integral.pressure", static_cast<double>(integrals.pressure),
reference.PressureIntegral(), energyScale, 2.0e-12);
check(label + ".integral.binding_from_potential", static_cast<double>(integrals.potentialEnergy),
reference.BindingEnergy(), energyScale, 2.0e-12);
check(label + ".integral.binding_from_gradient", static_cast<double>(integrals.gradientEnergy),
reference.BindingEnergy(), energyScale, 2.0e-12);
check(label + ".integral.binding_from_field_with_exterior", static_cast<double>(integrals.fieldEnergy),
reference.BindingEnergy(), energyScale, 2.0e-12);
check(label + ".integral.binding_potential_vs_gradient", static_cast<double>(integrals.potentialEnergy),
static_cast<double>(integrals.gradientEnergy), energyScale, 2.0e-12);
check(label + ".integral.scalar_virial_nonrotating",
static_cast<double>(integrals.gradientEnergy + 3.0L * integrals.pressure),
0.0, energyScale, 2.0e-12);
check(label + ".integral.axial_moment_of_inertia", static_cast<double>(integrals.momentOfInertia),
reference.MomentOfInertia(), inertiaScale, 2.0e-12);
}
bool rejectedNegativeRadius = false;
try { (void)N1Reference{}.AtRadius(-1.0); }
catch (const std::invalid_argument &) { rejectedNegativeRadius = true; }
check("contract.negative_radius_rejected", rejectedNegativeRadius ? 1.0 : 0.0, 1.0, 1.0, 0.0);
bool rejectedInvalidScale = false;
try { N1Reference{.gravitationalConstant = -1.0}.Validate(); }
catch (const std::invalid_argument &) { rejectedInvalidScale = true; }
check("contract.invalid_reference_rejected", rejectedInvalidScale ? 1.0 : 0.0, 1.0, 1.0, 0.0);
return report;
}
} // namespace experiment::polytrope_validation

View File

@@ -0,0 +1,395 @@
#pragma once
// Include after `import mean_field;`. This observer reconstructs the accepted
// coefficients without mutating the prepared production operator or mesh.
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <limits>
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <mfem.hpp>
#include <mpi.h>
namespace experiment::polytrope_validation {
struct PhysicalPoint final {
mean_field::mapping::MappingPointContext mapping;
int element{-1};
int attribute{-1};
bool stellarMaterial{false};
double rho{std::numeric_limits<double>::quiet_NaN()};
double h{std::numeric_limits<double>::quiet_NaN()};
double phi{std::numeric_limits<double>::quiet_NaN()};
// Positive centrifugal potential Psi = |Omega cross (x-center)|^2 / 2.
// The pointwise Bernoulli balance is h + phi - Psi - C = 0.
double rotationPotential{std::numeric_limits<double>::quiet_NaN()};
mfem::Vector gravityGradientPhysical;
mfem::Vector enthalpyGradientPhysical;
mfem::Vector potentialGradientPhysical;
PhysicalPoint()
: gravityGradientPhysical(3), enthalpyGradientPhysical(3), potentialGradientPhysical(3) {
const double nan = std::numeric_limits<double>::quiet_NaN();
gravityGradientPhysical = nan;
enthalpyGradientPhysical = nan;
potentialGradientPhysical = nan;
}
};
struct FieldReconstructionReport final {
std::string field;
int reducedSize{0};
int fullTrueSize{0};
double maximumRoundTripError{0.0};
};
// Nonmovable because the mapping evaluator references the owned displacement
// grid function. The FEM, spaces, compactification field, and mapper remain
// borrowed: keep this object inside the diagnostic context callback.
class PhysicalState final {
public:
using DomainSchema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema;
template <typename State>
explicit PhysicalState(State& state, const mean_field::fem::FEM& fem)
: finiteElements(fem),
density(RequireSpace(fem.densityFes)),
enthalpy(RequireSpace(fem.enthalpyFes)),
potential(RequireSpace(fem.gravityPotentialFes)),
gravityGradientReference(RequireSpace(fem.gravityFluxFes)),
displacement(RequireSpace(fem.displacementFes)),
domainMapper(state.Problem().GetDiscretization().domainMapper()),
mapping(domainMapper, displacement, RequireCompactification(fem)),
rotation(state.Problem().GetPreparedOperator().GetRotation()) {
int ranks = 0;
MPI_Comm_size(fem.mesh->GetComm(), &ranks);
if (ranks != 1) {
throw std::invalid_argument("Physical verification currently requires exactly one MPI rank.");
}
const auto& problem = state.Problem();
const auto& accepted = state.AcceptedPhysicalState();
const auto& prepared = state.normalizedOperator->GetPhysicalState();
if (accepted.Size() != prepared.Size()) {
throw std::logic_error("Accepted and prepared physical state sizes differ.");
}
for (int index = 0; index < accepted.Size(); ++index) {
if (!std::isfinite(accepted(index)) || accepted(index) != prepared(index)) {
throw std::logic_error("Physical verification requires the accepted state to be prepared.");
}
}
const auto values = problem.GetManifest().valueBlocks();
ScatterField<mean_field::field::Density>("density", values, accepted, density);
ScatterField<mean_field::field::Enthalpy>("specific_enthalpy", values, accepted, enthalpy);
ScatterField<mean_field::field::Gravity>("gravity_potential", values, accepted, potential);
ScatterField<mean_field::field::Gravity>("gravity_gradient", values, accepted, gravityGradientReference);
displacement.SetFromTrueDofs(problem.GetPhysicalOperator().GetGeneratedVolumeDisplacement());
mapping.InvalidateCache();
bernoulliConstant = Scalar(values, accepted, "fixed_total_mass.multiplier");
physicalResidualBordered = state.normalizedOperator->GetPhysicalResidual();
physicalResidualUnbordered = physicalResidualBordered;
normalizedBorderedResidual = state.acceptedNormalizedResidual;
normalizedUnborderedResidual.SetSize(physicalResidualUnbordered.Size());
if constexpr (requires { problem.GetPreparedOperator().GetCentralDensityConstraint(); }) {
centralBorder = Scalar(values, accepted, "fixed_central_density.border");
const auto& central = problem.GetPreparedOperator().GetCentralDensityConstraint();
centralDensityReport = central.GetConstraintReport();
const auto hydrostatic = RequireBlock(problem.GetManifest().residualBlocks(), "hydrostatic_balance");
if (central.GetCenterDof().field_size() != hydrostatic.size) {
throw std::logic_error("Central-density border and hydrostatic row layouts disagree.");
}
for (const int reducedDof : central.GetCenterDof().reduced_dofs()) {
if (reducedDof < 0 || reducedDof >= hydrostatic.size) {
throw std::logic_error("Central-density border index is outside the hydrostatic block.");
}
const int row = hydrostatic.offset + reducedDof;
centralHydrostaticRows.push_back(row);
physicalResidualUnbordered(row) -= centralBorder;
}
}
state.normalizedOperator->NormalizeResidual(physicalResidualUnbordered, normalizedUnborderedResidual);
normalizedBorderedResidualNorm = normalizedBorderedResidual.Norml2();
normalizedUnborderedResidualNorm = normalizedUnborderedResidual.Norml2();
mfem::Vector normalizedBorder(normalizedBorderedResidual);
normalizedBorder -= normalizedUnborderedResidual;
normalizedCentralBorderActionNorm = normalizedBorder.Norml2();
}
// Experiment-only postprocessing replay, not a solver restart. The
// caller must construct fem from savedDirectory/input.smesh. Historical
// residuals are not recomputed: only the saved finite-element fields
// are loaded, using exactly the same Evaluate implementation below.
explicit PhysicalState(
const mean_field::fem::FEM& fem, const std::filesystem::path& savedDirectory
)
: finiteElements(fem),
density(RequireSpace(fem.densityFes)),
enthalpy(RequireSpace(fem.enthalpyFes)),
potential(RequireSpace(fem.gravityPotentialFes)),
gravityGradientReference(RequireSpace(fem.gravityFluxFes)),
displacement(RequireSpace(fem.displacementFes)),
domainMapper(RequireDomainMapper(fem)),
mapping(domainMapper, displacement, RequireCompactification(fem)),
rotation(RequireNonrotatingReplay(fem, savedDirectory)) {
LoadField(savedDirectory / "density.gf", density);
LoadField(savedDirectory / "enthalpy.gf", enthalpy);
LoadField(savedDirectory / "potential.gf", potential);
LoadField(savedDirectory / "gravity_gradient_reference.gf", gravityGradientReference);
LoadField(savedDirectory / "displacement.gf", displacement);
mapping.InvalidateCache();
centralBorder = std::numeric_limits<double>::quiet_NaN();
normalizedCentralBorderActionNorm = std::numeric_limits<double>::quiet_NaN();
}
PhysicalState(const PhysicalState&) = delete;
PhysicalState& operator=(const PhysicalState&) = delete;
PhysicalState(PhysicalState&&) = delete;
PhysicalState& operator=(PhysicalState&&) = delete;
[[nodiscard]] bool isStellar(int element) const {
CheckElement(element);
return DomainSchema::template attribute_belongs_to<mean_field::utils::domain::Stellar>(
finiteElements.mesh->GetAttribute(element)
);
}
[[nodiscard]] bool isVacuum(int element) const {
CheckElement(element);
return DomainSchema::template attribute_belongs_to<mean_field::utils::domain::Vacuum>(
finiteElements.mesh->GetAttribute(element)
);
}
// Grid-function vector values include the MFEM/reference-mesh Piola
// transform already. Applying J_map/det(J_map) here completes, rather
// than repeats, the transformation to the deformed physical geometry.
[[nodiscard]] mean_field::mapping::MappingStatus Evaluate(
int element, const mfem::IntegrationPoint& point, PhysicalPoint& output
) {
using mean_field::mapping::MappingStatus;
CheckElement(element);
output.element = element;
output.attribute = finiteElements.mesh->GetAttribute(element);
output.stellarMaterial = isStellar(element);
const double nan = std::numeric_limits<double>::quiet_NaN();
output.rho = output.h = output.phi = output.rotationPotential = nan;
output.gravityGradientPhysical = nan;
output.enthalpyGradientPhysical = nan;
output.potentialGradientPhysical = nan;
auto* transformation = finiteElements.mesh->GetElementTransformation(element);
const auto status = mapping.EvaluatePoint(*transformation, point, output.mapping);
if (status != MappingStatus::valid) return status;
// GetValue/GetGradient evaluate the FE functions on the reference
// physical mesh. Scalar values pull back unchanged under DomainMapper.
output.phi = potential.GetValue(element, point);
transformation->SetIntPoint(&point);
potential.GetGradient(*transformation, m_referenceGradient);
output.mapping.inverse_mapping_jacobian.MultTranspose(m_referenceGradient, output.potentialGradientPhysical);
gravityGradientReference.GetVectorValue(element, point, m_referenceGravity);
output.mapping.mapping_jacobian.Mult(m_referenceGravity, output.gravityGradientPhysical);
output.gravityGradientPhysical /= output.mapping.mapping_determinant;
output.rotationPotential = rotation.potential(output.mapping.physical_position);
if (output.stellarMaterial) {
output.rho = density.GetValue(element, point);
output.h = enthalpy.GetValue(element, point);
transformation->SetIntPoint(&point);
enthalpy.GetGradient(*transformation, m_referenceGradient);
output.mapping.inverse_mapping_jacobian.MultTranspose(m_referenceGradient, output.enthalpyGradientPhysical);
}
// rho/h outside their material support intentionally remain NaN;
// zeroed unsupported FE coefficients are not physical vacuum data.
if (!std::isfinite(output.phi) || !std::isfinite(output.rotationPotential) ||
!AllFinite(output.gravityGradientPhysical) || !AllFinite(output.potentialGradientPhysical) ||
(output.stellarMaterial && (!std::isfinite(output.rho) || !std::isfinite(output.h) ||
!AllFinite(output.enthalpyGradientPhysical)))) {
return MappingStatus::non_finite_result;
}
return MappingStatus::valid;
}
const mean_field::fem::FEM& finiteElements;
mfem::ParGridFunction density;
mfem::ParGridFunction enthalpy;
mfem::ParGridFunction potential;
mfem::ParGridFunction gravityGradientReference;
mfem::ParGridFunction displacement;
const mean_field::mapping::DomainMapper& domainMapper;
mean_field::mapping::GridFunctionMappingEvaluator mapping;
mean_field::physics::RigidRotation rotation;
double bernoulliConstant{std::numeric_limits<double>::quiet_NaN()};
double centralBorder{0.0};
std::optional<mean_field::operators::CentralDensityConstraintReport> centralDensityReport;
std::vector<int> centralHydrostaticRows;
std::vector<FieldReconstructionReport> reconstructionReports;
// "Unbordered" removes only the artificial lambda_c*e_c contribution
// from hydrostatic balance. All scalar constraint rows and the physical
// Bernoulli constant remain present in the original root layout.
mfem::Vector physicalResidualBordered;
mfem::Vector physicalResidualUnbordered;
mfem::Vector normalizedBorderedResidual;
mfem::Vector normalizedUnborderedResidual;
double normalizedBorderedResidualNorm{std::numeric_limits<double>::quiet_NaN()};
double normalizedUnborderedResidualNorm{std::numeric_limits<double>::quiet_NaN()};
double normalizedCentralBorderActionNorm{0.0};
private:
struct Block final { int offset; int size; };
mfem::Vector m_referenceGradient = mfem::Vector(3);
mfem::Vector m_referenceGravity = mfem::Vector(3);
static mfem::ParFiniteElementSpace* RequireSpace(
const std::unique_ptr<mfem::ParFiniteElementSpace>& space
) {
if (space == nullptr) throw std::invalid_argument("Physical verification received an incomplete FE space.");
return space.get();
}
static const mfem::ParGridFunction& RequireCompactification(const mean_field::fem::FEM& fem) {
if (fem.mesh == nullptr || fem.compactificationCoordinate == nullptr) {
throw std::invalid_argument("Physical verification received incomplete mapping data.");
}
return *fem.compactificationCoordinate;
}
static const mean_field::mapping::DomainMapper& RequireDomainMapper(const mean_field::fem::FEM& fem) {
if (fem.domainMapperStateless == nullptr) {
throw std::invalid_argument("Physical replay received an incomplete domain mapper.");
}
return *fem.domainMapperStateless;
}
static mean_field::physics::RigidRotation RequireNonrotatingReplay(
const mean_field::fem::FEM& fem, const std::filesystem::path& directory
) {
int ranks = 0;
MPI_Comm_size(fem.mesh->GetComm(), &ranks);
if (ranks != 1) throw std::invalid_argument("Physical replay requires exactly one MPI rank.");
std::ifstream metadata(directory / "metadata.txt");
if (!metadata) throw std::invalid_argument("Physical replay requires saved metadata.txt.");
std::map<std::string, std::string> values;
std::string line;
while (std::getline(metadata, line)) {
const auto separator = line.find('=');
if (separator != std::string::npos) values[line.substr(0, separator)] = line.substr(separator + 1);
}
constexpr std::string_view supportedModel =
"nonrotating_n1_fixed_mass_fixed_central_density_zero_surface_pressure";
if (metadata.bad() || values["mode"] != "solve" || values["mpi_ranks"] != "1" ||
values["model"] != supportedModel) {
throw std::invalid_argument("Physical replay only supports saved single-rank nonrotating n=1 solves.");
}
if (!values.contains("elements") || std::stoi(values.at("elements")) != fem.mesh->GetNE()) {
throw std::invalid_argument("Replay FEM element count disagrees with saved metadata.");
}
// The supported model has exactly J=0 and computes Omega=0. If a
// completed measurement file exists, reject contradictory data;
// an interrupted postprocess can still replay its already-saved GFs.
std::ifstream metrics(directory / "physical_metrics.csv");
if (metrics) {
while (std::getline(metrics, line)) {
constexpr std::string_view prefix = "angular_velocity_norm,";
if (!line.starts_with(prefix)) continue;
const double angularVelocity = std::stod(line.substr(prefix.size()));
if (!std::isfinite(angularVelocity) || angularVelocity != 0.0) {
throw std::invalid_argument("Physical replay cannot infer a nonzero saved rotation vector.");
}
}
if (metrics.bad()) throw std::runtime_error("Could not read saved rotation verification data.");
}
mfem::Vector zero(3);
zero = 0.0;
return mean_field::physics::RigidRotation{zero, zero};
}
static void LoadField(const std::filesystem::path& path, mfem::ParGridFunction& target) {
std::ifstream stream(path);
if (!stream) throw std::invalid_argument("Missing saved physical field: " + path.string());
// ParGridFunction's stream constructor reverses the local-DOF
// orientation handling in ParGridFunction::Save (important for RT).
mfem::ParGridFunction loaded(target.ParFESpace()->GetParMesh(), stream);
if (stream.fail() || !AllFinite(loaded)) {
throw std::invalid_argument("Incomplete or non-finite saved physical field: " + path.string());
}
const auto& savedSpace = *loaded.ParFESpace();
const auto& targetSpace = *target.ParFESpace();
if (std::string_view(savedSpace.FEColl()->Name()) != targetSpace.FEColl()->Name() ||
savedSpace.GetVDim() != targetSpace.GetVDim() || savedSpace.GetOrdering() != targetSpace.GetOrdering() ||
savedSpace.GetVSize() != targetSpace.GetVSize() || savedSpace.GetTrueVSize() != targetSpace.GetTrueVSize() ||
loaded.Size() != target.Size()) {
throw std::invalid_argument("Saved field FE collection/layout is incompatible with this build: " + path.string());
}
mfem::Array<int> savedDofs, targetDofs;
for (int element = 0; element < targetSpace.GetNE(); ++element) {
if (savedSpace.GetElementOrder(element) != targetSpace.GetElementOrder(element)) {
throw std::invalid_argument("Saved field element order is incompatible with this build: " + path.string());
}
savedSpace.GetElementVDofs(element, savedDofs);
targetSpace.GetElementVDofs(element, targetDofs);
if (savedDofs.Size() != targetDofs.Size()) {
throw std::invalid_argument("Saved field element DOF count is incompatible with this build: " + path.string());
}
for (int index = 0; index < targetDofs.Size(); ++index) {
if (savedDofs[index] != targetDofs[index]) {
throw std::invalid_argument("Saved field element DOF ordering is incompatible with this build: " + path.string());
}
}
}
target = loaded;
}
void CheckElement(int element) const {
if (element < 0 || element >= finiteElements.mesh->GetNE()) {
throw std::out_of_range("Physical verification element index is outside the local mesh.");
}
}
template <typename Blocks>
static Block RequireBlock(const Blocks& blocks, std::string_view name) {
for (const auto& block : blocks) {
if (block.stableId == name) return {block.offset, block.size};
}
throw std::invalid_argument("Physical verification requires root block " + std::string(name));
}
template <typename Blocks>
static double Scalar(const Blocks& blocks, const mfem::Vector& values, std::string_view name) {
const auto block = RequireBlock(blocks, name);
if (block.size != 1 || block.offset < 0 || block.offset >= values.Size()) {
throw std::logic_error("Physical verification scalar block has an incompatible layout.");
}
return values(block.offset);
}
template <typename Field, typename Blocks>
void ScatterField(std::string_view name, const Blocks& blocks, const mfem::Vector& values, mfem::ParGridFunction& field) {
const auto block = RequireBlock(blocks, name);
const auto adapter = mean_field::field::make_field_dof_grid_function_adapter<Field, DomainSchema>(*field.ParFESpace());
if (block.size != adapter.dof_map().reduced_size() || block.offset < 0 ||
block.offset + block.size > values.Size()) {
throw std::logic_error("Physical verification field map disagrees with root block " + std::string(name));
}
mfem::Vector reduced(block.size);
for (int index = 0; index < block.size; ++index) reduced(index) = values(block.offset + index);
adapter.scatter(reduced, field);
const auto roundTrip = adapter.gather(field);
double error = 0.0;
for (int index = 0; index < block.size; ++index) error = std::max(error, std::abs(roundTrip(index) - reduced(index)));
reconstructionReports.push_back({std::string(name), block.size, adapter.dof_map().full_size(), error});
}
static bool AllFinite(const mfem::Vector& vector) {
for (int index = 0; index < vector.Size(); ++index) if (!std::isfinite(vector(index))) return false;
return true;
}
};
} // namespace experiment::polytrope_validation

View File

@@ -0,0 +1,435 @@
#pragma once
// Experiment-only direct physical-space sampling. Include after mean_field.
#include "polytrope_analytic_reference.hpp"
#include "polytrope_physical_state.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <limits>
#include <numbers>
#include <numeric>
#include <stdexcept>
#include <string>
#include <vector>
namespace experiment::polytrope_validation {
struct RadialProfileOptions final {
int interiorShellCount{32};
int exteriorShellCount{8};
int muPointCount{6};
int azimuthPointCount{12};
double exteriorRadiusMultiple{2.0};
double relativeLocationTolerance{2.0e-11};
int maximumNewtonIterations{35};
};
struct RadialProfileReport final {
std::size_t requestedPoints{0};
std::size_t locatedPoints{0};
std::size_t materialPoints{0};
std::size_t locatorElementAttempts{0};
double maximumLocationError{0.0};
double maximumScaledFieldError{0.0};
double maximumAngularRmsScaled{0.0};
double angularMomentError{0.0};
};
namespace radial_detail {
constexpr double NaN = std::numeric_limits<double>::quiet_NaN();
struct Direction final {
std::array<double, 3> value{};
double weight{0.0};
std::string family;
};
inline std::vector<Direction> AngularDirections(const RadialProfileOptions &options) {
std::vector<Direction> directions;
for (int root = 0; root < options.muPointCount; ++root) {
double mu = std::cos(std::numbers::pi * (root + 0.75) / (options.muPointCount + 0.5));
double derivative = 0.0;
for (int iteration = 0; iteration < 32; ++iteration) {
double previous = 1.0;
double current = mu;
for (int degree = 2; degree <= options.muPointCount; ++degree) {
const double next = ((2 * degree - 1) * mu * current - (degree - 1) * previous) / degree;
previous = current;
current = next;
}
derivative = options.muPointCount * (mu * current - previous) / (mu * mu - 1.0);
const double correction = current / derivative;
mu -= correction;
if (std::abs(correction) < 4.0 * std::numeric_limits<double>::epsilon()) break;
}
// The Newton update changes mu after evaluating its derivative.
// Form the quadrature weight at the final root, not that iterate.
double previous = 1.0;
double current = mu;
for (int degree = 2; degree <= options.muPointCount; ++degree) {
const double next = ((2 * degree - 1) * mu * current - (degree - 1) * previous) / degree;
previous = current;
current = next;
}
derivative = options.muPointCount * (mu * current - previous) / (mu * mu - 1.0);
// Half of the [-1,1] Gauss weight: angular weights sum to one.
const double weight = 1.0 / ((1.0 - mu * mu) * derivative * derivative * options.azimuthPointCount);
const double cylindricalRadius = std::sqrt(std::max(0.0, 1.0 - mu * mu));
for (int azimuth = 0; azimuth < options.azimuthPointCount; ++azimuth) {
const double phi = 2.0 * std::numbers::pi * (azimuth + 0.5) / options.azimuthPointCount;
directions.push_back({{cylindricalRadius * std::cos(phi), cylindricalRadius * std::sin(phi), mu}, weight, "angular"});
}
}
return directions;
}
inline std::vector<Direction> Rays() {
std::vector<Direction> directions;
for (int x = -1; x <= 1; ++x) {
for (int y = -1; y <= 1; ++y) {
for (int z = -1; z <= 1; ++z) {
const int nonzero = (x != 0) + (y != 0) + (z != 0);
if (nonzero == 0) continue;
const double norm = std::sqrt(static_cast<double>(nonzero));
directions.push_back({{x / norm, y / norm, z / norm}, 0.0,
nonzero == 1 ? "axis" : nonzero == 2 ? "face_diagonal" : "body_diagonal"});
}
}
}
return directions;
}
struct LocatedPoint final {
bool found{false};
double error{NaN};
PhysicalPoint physical;
};
// Bounds prioritize searches; they NEVER exclude an element. Sampled
// bounds are not a certificate for curved/Kelvin element images.
template <typename SampleState> class PhysicalLocator final {
public:
PhysicalLocator(SampleState &state, const double radius, const RadialProfileOptions &options)
: m_state(state), m_radius(radius), m_options(options) {
auto &mesh = *state.finiteElements.mesh;
m_elements.resize(static_cast<std::size_t>(mesh.GetNE()));
constexpr std::array<double, 5> coordinates{0.0, 0.125, 0.5, 0.875, 1.0};
for (int element = 0; element < mesh.GetNE(); ++element) {
auto *transformation = mesh.GetElementTransformation(element);
if (transformation->GetGeometryType() != mfem::Geometry::CUBE) {
throw std::invalid_argument("Direct radial profiles currently require hexahedral elements.");
}
auto &data = m_elements[element];
for (const double x : coordinates) for (const double y : coordinates) for (const double z : coordinates) {
Seed seed;
seed.point.Set3(x, y, z);
mean_field::mapping::MappingPointContext mapped;
if (state.mapping.EvaluatePoint(*transformation, seed.point, mapped) != mean_field::mapping::MappingStatus::valid) continue;
for (int d = 0; d < 3; ++d) {
seed.position[d] = mapped.physical_position(d);
data.minimum[d] = std::min(data.minimum[d], seed.position[d]);
data.maximum[d] = std::max(data.maximum[d], seed.position[d]);
}
data.seeds.push_back(seed);
}
}
}
LocatedPoint Locate(const mfem::Vector &target, int &hint) {
LocatedPoint result;
if (hint >= 0 && TryElement(hint, target, result)) return result;
std::vector<std::pair<double, int>> candidates;
candidates.reserve(m_elements.size());
for (int element = 0; element < static_cast<int>(m_elements.size()); ++element) {
if (element == hint || m_elements[element].seeds.empty()) continue;
const auto &data = m_elements[element];
double distance = 0.0;
double centerDistance = 0.0;
for (int d = 0; d < 3; ++d) {
const double outside = std::max({data.minimum[d] - target(d), target(d) - data.maximum[d], 0.0});
distance += outside * outside;
const double centered = target(d) - 0.5 * (data.minimum[d] + data.maximum[d]);
centerDistance += centered * centered;
}
candidates.emplace_back(distance + 1.0e-8 * centerDistance, element);
}
std::sort(candidates.begin(), candidates.end());
for (const auto &[distance, element] : candidates) {
if (TryElement(element, target, result)) {
hint = element;
return result;
}
}
hint = -1;
return result;
}
std::size_t ElementAttempts() const { return m_elementAttempts; }
private:
struct Seed final {
mfem::IntegrationPoint point;
std::array<double, 3> position{};
};
struct Element final {
std::array<double, 3> minimum{INFINITY, INFINITY, INFINITY};
std::array<double, 3> maximum{-INFINITY, -INFINITY, -INFINITY};
std::vector<Seed> seeds;
};
bool TryElement(const int element, const mfem::Vector &target, LocatedPoint &result) {
++m_elementAttempts;
const auto &seeds = m_elements[element].seeds;
if (seeds.empty()) return false;
const Seed *closest = &seeds.front();
double bestDistance = std::numeric_limits<double>::infinity();
for (const auto &seed : seeds) {
double distance = 0.0;
for (int d = 0; d < 3; ++d) distance += std::pow(target(d) - seed.position[d], 2);
if (distance < bestDistance) { bestDistance = distance; closest = &seed; }
}
if (Newton(element, closest->point, target, result)) return true;
mfem::IntegrationPoint center;
center.Set3(0.5, 0.5, 0.5);
return Newton(element, center, target, result);
}
bool Newton(const int element, mfem::IntegrationPoint point, const mfem::Vector &target, LocatedPoint &result) {
auto *transformation = m_state.finiteElements.mesh->GetElementTransformation(element);
const double tolerance = m_options.relativeLocationTolerance * std::max(m_radius, target.Norml2());
mfem::Vector residual(3), correction(3), trialResidual(3);
mfem::DenseMatrix totalJacobian(3), inverse(3);
mean_field::mapping::MappingPointContext mapped, trialMapped;
for (int iteration = 0; iteration < m_options.maximumNewtonIterations; ++iteration) {
if (m_state.mapping.EvaluatePoint(*transformation, point, mapped) != mean_field::mapping::MappingStatus::valid) return false;
residual = mapped.physical_position;
residual -= target;
const double error = residual.Norml2();
if (error <= tolerance) {
if (m_state.Evaluate(element, point, result.physical) != mean_field::mapping::MappingStatus::valid) return false;
result.found = true;
result.error = error;
return true;
}
transformation->SetIntPoint(&point);
mfem::Mult(mapped.mapping_jacobian, transformation->Jacobian(), totalJacobian);
if (!std::isfinite(totalJacobian.Det()) || totalJacobian.Det() <= 0.0) return false;
mfem::CalcInverse(totalJacobian, inverse);
inverse.Mult(residual, correction);
bool improved = false;
double alpha = 1.0;
for (int trial = 0; trial < 18; ++trial, alpha *= 0.5) {
mfem::IntegrationPoint candidate;
candidate.Set3(std::clamp(point.x - alpha * correction(0), 0.0, 1.0),
std::clamp(point.y - alpha * correction(1), 0.0, 1.0),
std::clamp(point.z - alpha * correction(2), 0.0, 1.0));
if (m_state.mapping.EvaluatePoint(*transformation, candidate, trialMapped) != mean_field::mapping::MappingStatus::valid) continue;
trialResidual = trialMapped.physical_position;
trialResidual -= target;
const double trialError = trialResidual.Norml2();
if (trialError <= tolerance) {
if (m_state.Evaluate(element, candidate, result.physical) != mean_field::mapping::MappingStatus::valid) return false;
result.found = true;
result.error = trialError;
return true;
}
if (trialError < error * (1.0 - 1.0e-4 * alpha)) {
point = candidate;
improved = true;
break;
}
}
if (!improved) return false;
}
return false;
}
SampleState &m_state;
double m_radius;
const RadialProfileOptions &m_options;
std::vector<Element> m_elements;
std::size_t m_elementAttempts{0};
};
inline std::array<double, 5> Values(const LocatedPoint &point, const Direction &direction, const bool origin) {
if (!point.found) return {NaN, NaN, NaN, NaN, NaN};
const auto &physical = point.physical;
double radial = 0.0;
double transverseSquared = 0.0;
for (int d = 0; d < 3; ++d) radial += physical.gravityGradientPhysical(d) * direction.value[d];
for (int d = 0; d < 3; ++d) {
const double transverse = physical.gravityGradientPhysical(d) - radial * direction.value[d];
transverseSquared += transverse * transverse;
}
return {physical.stellarMaterial ? physical.rho : NaN, physical.stellarMaterial ? physical.h : NaN,
physical.phi, origin ? NaN : radial, std::sqrt(transverseSquared)};
}
struct Moments final {
long double weight{0.0L};
long double mean{0.0L};
long double centeredSquared{0.0L};
void Add(const double value, const double addedWeight) {
if (!std::isfinite(value)) return;
// Starting from mean=0 and multiplying/dividing by the first
// weight injects O(epsilon) variance into a constant field.
if (weight == 0.0L) {
weight = addedWeight;
mean = value;
centeredSquared = 0.0L;
return;
}
const long double difference = static_cast<long double>(value) - mean;
weight += addedWeight;
mean += static_cast<long double>(addedWeight) * difference / weight;
centeredSquared += static_cast<long double>(addedWeight) * difference * (static_cast<long double>(value) - mean);
}
double Mean() const { return weight > 0.0L ? static_cast<double>(mean) : NaN; }
double Variance() const { return weight > 0.0L ? static_cast<double>(std::max(0.0L, centeredSquared / weight)) : NaN; }
};
inline std::ofstream Csv(const std::filesystem::path &path) {
std::ofstream stream(path);
if (!stream) throw std::runtime_error("Cannot write radial diagnostic output: " + path.string());
stream << std::setprecision(17);
return stream;
}
} // namespace radial_detail
// Means are conditional on successfully located finite values. Density and
// enthalpy are additionally conditional on stellar material; their coverage
// columns must be inspected. No exterior/missing value is replaced by zero.
// Axis/diagonal DG samples may be one-sided element-interface traces. They
// diagnose directional structure, and are NOT used as angular quadrature.
template <typename SampleState> inline RadialProfileReport WriteRadialProfiles(
SampleState &state, const N1Reference &reference, const std::filesystem::path &outputDirectory,
const RadialProfileOptions &options = {}
) {
using namespace radial_detail;
reference.Validate();
if (options.interiorShellCount < 1 || options.exteriorShellCount < 1 || options.muPointCount < 2 ||
options.azimuthPointCount < 4 || options.maximumNewtonIterations < 1 ||
!std::isfinite(options.exteriorRadiusMultiple) || options.exteriorRadiusMultiple <= 1.001 ||
!std::isfinite(options.relativeLocationTolerance) || options.relativeLocationTolerance <= 0.0) {
throw std::invalid_argument("Invalid direct radial-profile sampling options.");
}
int ranks = 0;
MPI_Comm_size(state.finiteElements.mesh->GetComm(), &ranks);
if (ranks != 1) throw std::invalid_argument("Direct physical radial profiles require one MPI rank.");
std::filesystem::create_directories(outputDirectory);
auto radial = Csv(outputDirectory / "radial_profiles.csv");
auto directional = Csv(outputDirectory / "directional_profiles.csv");
const std::array<std::string, 5> names{"density_material", "enthalpy_material", "potential", "gravity_radial", "gravity_nonradial_magnitude"};
radial << "sample_kind,radius,xi,r_over_R,requested_points,located_points,located_weight_fraction,material_weight_fraction,max_location_error";
for (const auto &name : names) radial << ',' << name << "_mean," << name << "_angular_rms," << name << "_analytic," << name << "_mean_error_scaled," << name << "_rms_error_scaled," << name << "_valid_weight_fraction";
radial << ",theta_density,theta_enthalpy,theta_potential,mu_points,phi_points\n";
directional << "radius,xi,family,ray,ux,uy,uz,located,element,attribute,stellar_material,location_error";
for (const auto &name : names) directional << ',' << name << ',' << name << "_analytic";
directional << ",theta_density,theta_enthalpy,theta_potential\n";
const auto angularDirections = AngularDirections(options);
const auto rays = Rays();
RadialProfileReport report;
double weightSum = 0.0;
std::array<double, 3> first{}, second{};
for (const auto &direction : angularDirections) {
weightSum += direction.weight;
for (int d = 0; d < 3; ++d) {
first[d] += direction.weight * direction.value[d];
second[d] += direction.weight * direction.value[d] * direction.value[d];
}
}
report.angularMomentError = std::abs(weightSum - 1.0);
for (int d = 0; d < 3; ++d) report.angularMomentError = std::max({report.angularMomentError, std::abs(first[d]), std::abs(second[d] - 1.0 / 3.0)});
if (report.angularMomentError > 1.0e-12) throw std::runtime_error("Spherical angular quadrature moment self-check failed.");
Moments constantField;
constexpr double constantValue = -1.873;
for (const auto &direction : angularDirections) constantField.Add(constantValue, direction.weight);
if (constantField.Mean() != constantValue || constantField.Variance() != 0.0) {
throw std::runtime_error("Constant-field weighted angular variance self-check failed.");
}
std::vector<double> radii{0.0};
for (int i = 1; i <= options.interiorShellCount; ++i) radii.push_back(0.99 * reference.radius * i / options.interiorShellCount);
radii.push_back(0.999 * reference.radius);
for (int i = 0; i < options.exteriorShellCount; ++i) {
const double fraction = options.exteriorShellCount > 1 ? static_cast<double>(i) / (options.exteriorShellCount - 1) : 0.0;
radii.push_back(reference.radius * (1.001 + fraction * (options.exteriorRadiusMultiple - 1.001)));
}
const double gravityScale = reference.gravitationalConstant * reference.mass / (reference.radius * reference.radius);
const std::array<double, 5> scales{reference.CentralDensity(), reference.CentralEnthalpy(), reference.CentralEnthalpy(), gravityScale, gravityScale};
PhysicalLocator locator(state, reference.radius, options);
std::vector<int> angularHints(angularDirections.size(), -1), rayHints(rays.size(), -1);
int originHint = -1;
for (const double radius : radii) {
const bool origin = radius == 0.0;
const auto analytic = reference.AtRadius(radius);
const std::array<double, 5> exact{analytic.density, analytic.enthalpy, analytic.potential, analytic.radialPotentialGradient, 0.0};
std::array<Moments, 5> moments;
std::size_t locatedCount = 0;
double locatedWeight = 0.0, materialWeight = 0.0, maxError = 0.0;
const std::size_t angularCount = origin ? 1 : angularDirections.size();
for (std::size_t index = 0; index < angularCount; ++index) {
const Direction direction = origin ? Direction{{0.0, 0.0, 0.0}, 1.0, "origin"} : angularDirections[index];
mfem::Vector target(3);
for (int d = 0; d < 3; ++d) target(d) = radius * direction.value[d];
auto located = locator.Locate(target, origin ? originHint : angularHints[index]);
++report.requestedPoints;
if (located.found) {
++report.locatedPoints;
++locatedCount;
locatedWeight += direction.weight;
if (located.physical.stellarMaterial) { materialWeight += direction.weight; ++report.materialPoints; }
maxError = std::max(maxError, located.error);
report.maximumLocationError = std::max(report.maximumLocationError, located.error);
}
const auto values = Values(located, direction, origin);
for (std::size_t field = 0; field < values.size(); ++field) {
moments[field].Add(values[field], direction.weight);
if (std::isfinite(values[field])) report.maximumScaledFieldError = std::max(report.maximumScaledFieldError, std::abs(values[field] - exact[field]) / scales[field]);
}
}
radial << (origin ? "origin_single_trace" : "physical_sphere") << ',' << radius << ',' << std::numbers::pi * radius / reference.radius << ',' << radius / reference.radius
<< ',' << angularCount << ',' << locatedCount << ',' << locatedWeight << ',' << materialWeight << ',' << (locatedCount ? maxError : NaN);
for (std::size_t field = 0; field < moments.size(); ++field) {
const double difference = moments[field].Mean() - exact[field];
const double angularRmsScaled = std::sqrt(moments[field].Variance()) / scales[field];
if (std::isfinite(angularRmsScaled)) report.maximumAngularRmsScaled = std::max(report.maximumAngularRmsScaled, angularRmsScaled);
radial << ',' << moments[field].Mean() << ',' << std::sqrt(moments[field].Variance()) << ',' << exact[field]
<< ',' << difference / scales[field] << ',' << std::sqrt(moments[field].Variance() + difference * difference) / scales[field] << ',' << moments[field].weight;
}
radial << ',' << moments[0].Mean() / reference.CentralDensity() << ',' << moments[1].Mean() / reference.CentralEnthalpy()
<< ',' << reference.NormalizedPotential(moments[2].Mean()) << ',' << (origin ? 0 : options.muPointCount) << ',' << (origin ? 0 : options.azimuthPointCount) << '\n';
const std::size_t rayCount = origin ? 1 : rays.size();
for (std::size_t index = 0; index < rayCount; ++index) {
const Direction direction = origin ? Direction{{0.0, 0.0, 0.0}, 1.0, "origin"} : rays[index];
mfem::Vector target(3);
for (int d = 0; d < 3; ++d) target(d) = radius * direction.value[d];
auto located = locator.Locate(target, origin ? originHint : rayHints[index]);
++report.requestedPoints;
if (located.found) {
++report.locatedPoints;
if (located.physical.stellarMaterial) ++report.materialPoints;
report.maximumLocationError = std::max(report.maximumLocationError, located.error);
}
const auto values = Values(located, direction, origin);
for (std::size_t field = 0; field < values.size(); ++field) {
if (std::isfinite(values[field])) report.maximumScaledFieldError = std::max(report.maximumScaledFieldError, std::abs(values[field] - exact[field]) / scales[field]);
}
directional << radius << ',' << std::numbers::pi * radius / reference.radius << ',' << direction.family << ',' << index;
for (const double component : direction.value) directional << ',' << component;
directional << ',' << located.found << ',' << (located.found ? located.physical.element : -1) << ',' << (located.found ? located.physical.attribute : -1)
<< ',' << (located.found && located.physical.stellarMaterial) << ',' << located.error;
for (std::size_t field = 0; field < values.size(); ++field) directional << ',' << values[field] << ',' << exact[field];
directional << ',' << values[0] / reference.CentralDensity() << ',' << values[1] / reference.CentralEnthalpy() << ',' << reference.NormalizedPotential(values[2]) << '\n';
}
}
report.locatorElementAttempts = locator.ElementAttempts();
return report;
}
} // namespace experiment::polytrope_validation

View File

@@ -0,0 +1,445 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <numbers>
#include <stdexcept>
#include <string>
#include <vector>
#include <mfem.hpp>
import mean_field;
#include "polytrope_analytic_self_checks.hpp"
#include "polytrope_validation_measurements.hpp"
#include "polytrope_radial_profiles.hpp"
namespace {
using namespace mean_field;
using namespace experiment::polytrope_validation;
using Clock = std::chrono::steady_clock;
struct Options final {
std::string mesh{"sandbox.smesh"};
std::filesystem::path output{"polytrope_validation_results"};
std::filesystem::path replay;
std::string mode{"solve"};
double absoluteTolerance{1.0e-8};
double relativeTolerance{1.0e-8};
double linearTolerance{0.03};
int maximumNewtonIterations{8};
int maximumLinearIterations{80};
int quadratureOrder{14};
int checkQuadratureOrder{18};
bool profiles{true};
RadialProfileOptions radial;
};
Options Parse(const int argc, char **argv) {
Options options;
for (int index = 1; index < argc; ++index) {
const std::string argument = argv[index];
auto value = [&]() -> std::string {
if (++index >= argc) throw std::invalid_argument("Missing value after " + argument);
return argv[index];
};
if (argument == "--mesh") options.mesh = value();
else if (argument == "--output") options.output = value();
else if (argument == "--self-check") options.mode = "self-check";
else if (argument == "--analytic-mesh") options.mode = "analytic-mesh";
else if (argument == "--solve") options.mode = "solve";
else if (argument == "--replay") {
options.mode = "replay";
options.replay = value();
options.mesh = (options.replay / "input.smesh").string();
}
else if (argument == "--absolute-tolerance") options.absoluteTolerance = std::stod(value());
else if (argument == "--relative-tolerance") options.relativeTolerance = std::stod(value());
else if (argument == "--linear-tolerance") options.linearTolerance = std::stod(value());
else if (argument == "--max-newton") options.maximumNewtonIterations = std::stoi(value());
else if (argument == "--max-linear-iterations") options.maximumLinearIterations = std::stoi(value());
else if (argument == "--quadrature-order") options.quadratureOrder = std::stoi(value());
else if (argument == "--check-quadrature-order") options.checkQuadratureOrder = std::stoi(value());
else if (argument == "--skip-profiles") options.profiles = false;
else if (argument == "--mu-points") options.radial.muPointCount = std::stoi(value());
else if (argument == "--phi-points") options.radial.azimuthPointCount = std::stoi(value());
else if (argument == "--exterior-shells") options.radial.exteriorShellCount = std::stoi(value());
else if (argument == "--help") {
std::cout << "polytrope_validation_experiment [--self-check | --analytic-mesh | --solve]\n"
" [--mesh sandbox.smesh] [--output NEW_DIRECTORY] [--skip-profiles]\n"
" [--absolute-tolerance 1e-8] [--relative-tolerance 1e-8]\n"
" [--linear-tolerance 0.03] [--max-newton 8] [--max-linear-iterations 80]\n"
" [--quadrature-order 14] [--check-quadrature-order 18]\n"
" [--replay SOLVER_OUTPUT_DIRECTORY] (saved nonrotating fields; no Newton context)\n"
" [--mu-points 6] [--phi-points 12] [--exterior-shells 8]\n"
"Single MPI rank. Default: nonrotating n=1 production solve.\n"
"Exit codes: 0 all requested checks pass; 1 nonlinear failure; 2 execution error;\n"
"3 verification failure. Existing output directories are never overwritten.\n";
options.mode = "help";
return options;
} else throw std::invalid_argument("Unknown option: " + argument);
}
if (!std::isfinite(options.absoluteTolerance) || options.absoluteTolerance < 0.0 ||
!std::isfinite(options.relativeTolerance) || options.relativeTolerance < 0.0 ||
!(options.absoluteTolerance > 0.0 || options.relativeTolerance > 0.0) ||
!(options.linearTolerance > 0.0 && options.linearTolerance < 1.0) ||
options.maximumNewtonIterations < 1 || options.maximumLinearIterations < 1 ||
options.quadratureOrder < 2 || options.checkQuadratureOrder <= options.quadratureOrder ||
options.radial.muPointCount < 2 || options.radial.azimuthPointCount < 4 || options.radial.exteriorShellCount < 1) {
throw std::invalid_argument("Invalid tolerance, iteration limit, or quadrature orders.");
}
if (options.mode == "replay") options.mesh = (options.replay / "input.smesh").string();
return options;
}
std::ofstream File(const std::filesystem::path &path) {
std::ofstream stream(path);
stream.exceptions(std::ios::failbit | std::ios::badbit);
stream << std::setprecision(17);
return stream;
}
bool SelfChecks(const Options &options) {
const auto report = RunAnalyticSelfChecks();
auto stream = File(options.output / "analytic_self_checks.csv");
stream << "check,observed,expected,scale,absolute_error,scaled_error,tolerance,passed\n";
for (const auto &check : report.checks) {
stream << check.name << ',' << check.observed << ',' << check.expected << ',' << check.scale << ','
<< check.AbsoluteError() << ',' << check.ScaledError() << ',' << check.tolerance << ',' << check.passed << '\n';
if (!check.passed) std::cerr << "Analytic check failed: " << check.name << " scaled error=" << check.ScaledError() << '\n';
}
std::cout << "Independent analytic checks: " << report.checks.size() << ", passed=" << report.Passed() << std::endl;
return report.Passed();
}
void WriteMetrics(const std::filesystem::path &path, const Measurements &measurements) {
auto stream = File(path);
stream << "metric,value\n";
for (const auto &[name, value] : measurements) stream << name << ',' << value << '\n';
}
std::map<std::string, std::string> ReadMetadata(const std::filesystem::path &directory) {
std::ifstream stream(directory / "metadata.txt");
if (!stream) throw std::runtime_error("Cannot read replay metadata.");
std::map<std::string, std::string> result;
for (std::string line; std::getline(stream, line);) {
const auto separator = line.find('=');
if (separator != std::string::npos) result[line.substr(0, separator)] = line.substr(separator+1);
}
return result;
}
Measurements ReadSavedSolverMetrics(const std::filesystem::path &directory) {
std::ifstream stream(directory / "physical_metrics.csv");
if (!stream) throw std::runtime_error("Replay needs completed physical_metrics.csv to preserve solver/border diagnostics.");
Measurements result;
std::string line;
std::getline(stream, line);
while (std::getline(stream, line)) {
const auto separator = line.find(',');
if (separator == std::string::npos) throw std::runtime_error("Malformed saved physical metrics.");
const auto name = line.substr(0, separator);
if (name == "normalized_bordered_residual" || name == "normalized_unbordered_residual" ||
name == "normalized_central_border_action" || name == "central_border" ||
name == "bernoulli_constant" || name == "angular_velocity_norm") {
result[name] = std::stod(line.substr(separator+1));
}
}
if (result.size() != 6 || result.at("angular_velocity_norm") != 0.0) {
throw std::runtime_error("Replay currently requires complete saved diagnostics and exactly zero rotation.");
}
return result;
}
template <typename SampleState>
bool Measure(SampleState &state, const N1Reference &reference, const Options &options,
Measurements additional = {}) {
std::cout << "Physical volume integration, order=" << options.quadratureOrder << std::endl;
const auto base = MeasureVolumes(state, reference, options.quadratureOrder);
WriteMetrics(options.output / "volume_metrics_base.csv", base);
std::cout << "Independent higher-order integration, order=" << options.checkQuadratureOrder << std::endl;
auto metrics = MeasureVolumes(state, reference, options.checkQuadratureOrder);
auto quadrature = File(options.output / "quadrature_comparison.csv");
quadrature << "metric,base,check,absolute_difference,relative_difference\n";
for (const auto &[name, high] : metrics) {
const double low = base.at(name);
quadrature << name << ',' << low << ',' << high << ',' << std::abs(high-low) << ','
<< std::abs(high-low) / std::max(std::abs(high), 1.0e-300) << '\n';
}
metrics["quadrature_virial_absolute_change"] = std::abs(metrics.at("virial_signed") - base.at("virial_signed"));
metrics["quadrature_binding_relative_change"] = std::abs(metrics.at("binding_energy") - base.at("binding_energy")) / std::abs(reference.BindingEnergy());
std::cout << "Stellar surface and all-element corner sampling" << std::endl;
metrics.merge(MeasureSurfaceAndCorners(state, reference, options.checkQuadratureOrder));
metrics.merge(additional);
if (options.profiles) {
std::cout << "Physical radial projection (stellar interior and finite exterior)" << std::endl;
const auto profiles = WriteRadialProfiles(state, reference, options.output, options.radial);
metrics["profile_requested_points"] = profiles.requestedPoints;
metrics["profile_missing_points"] = profiles.requestedPoints - profiles.locatedPoints;
metrics["profile_material_points"] = profiles.materialPoints;
metrics["profile_locator_element_attempts"] = profiles.locatorElementAttempts;
metrics["profile_maximum_location_error"] = profiles.maximumLocationError;
metrics["profile_angular_moment_error"] = profiles.angularMomentError;
metrics["profile_maximum_scaled_error"] = profiles.maximumScaledFieldError;
metrics["profile_maximum_angular_rms_scaled"] = profiles.maximumAngularRmsScaled;
}
metrics["negative_density_maximum_scaled"] = std::max(0.0, -metrics.at("minimum_density")) / reference.CentralDensity();
metrics["negative_enthalpy_maximum_scaled"] = std::max(0.0, -metrics.at("minimum_enthalpy")) / reference.CentralEnthalpy();
WriteMetrics(options.output / "physical_metrics.csv", metrics);
// Initial screening budgets, declared before running the solver. A pass
// is not a mesh-convergence certificate. The complete errors are saved.
std::map<std::string, double> budgets{
{"mass_relative_error", 1.0e-4}, {"volume_radius_relative_error", 1.0e-4},
{"surface_radius_relative_rms_error", 1.0e-4},
{"density_relative_l2_error", 1.0e-4}, {"enthalpy_relative_l2_error", 1.0e-4},
{"potential_relative_l2_error", 1.0e-4}, {"gravity_gradient_relative_l2_error", 1.0e-4},
{"binding_relative_error", 1.0e-4}, {"pressure_integral_relative_error", 1.0e-4},
{"moment_of_inertia_relative_error", 1.0e-4}, {"virial_error", 1.0e-6},
{"force_virial_error", 1.0e-6}, {"gravity_energy_consistency", 1.0e-6},
{"eos_enthalpy_scaled_rms", 1.0e-6}, {"bernoulli_scaled_rms_variation", 1.0e-4},
{"quadrature_virial_absolute_change", 1.0e-8}, {"quadrature_binding_relative_change", 1.0e-8},
{"invalid_stellar_corner_samples", 0.0}, {"kinetic_energy", 1.0e-14},
{"negative_density_maximum_scaled", 1.0e-8}, {"negative_enthalpy_maximum_scaled", 1.0e-8}
};
if (options.profiles) budgets["profile_missing_points"] = 0.0;
if (options.profiles && options.mode == "analytic-mesh") budgets["profile_maximum_scaled_error"] = 1.0e-8;
if (options.profiles && options.mode == "analytic-mesh") budgets["profile_maximum_angular_rms_scaled"] = 1.0e-10;
if (metrics.contains("normalized_unbordered_residual")) budgets["normalized_unbordered_residual"] = 1.0e-8;
auto checks = File(options.output / "verification_checks.csv");
checks << "metric,observed,maximum_allowed,passed\n";
bool passed = true;
for (const auto &[name, budget] : budgets) {
const double value = metrics.at(name);
const bool okay = std::isfinite(value) && std::abs(value) <= budget;
checks << name << ',' << value << ',' << budget << ',' << okay << '\n';
passed = passed && okay;
if (!okay) std::cout << "Screen failed: " << name << '=' << value << " budget=" << budget << '\n';
}
for (const std::string name : {"mass", "binding_energy", "pressure_integral", "virial_ratio", "virial_error",
"force_virial_error", "density_relative_l2_error", "enthalpy_relative_l2_error",
"potential_relative_l2_error", "surface_radius_relative_rms_error"}) {
std::cout << name << '=' << metrics.at(name) << '\n';
}
std::cout << "Physical screening passed=" << passed << " (not a resolution-convergence claim)" << std::endl;
return passed;
}
double BlockNorm(const mfem::Vector &vector, const int offset, const int size) {
long double sum = 0.0L;
for (int index = offset; index < offset + size; ++index) sum += static_cast<long double>(vector(index)) * vector(index);
return std::sqrt(sum);
}
int Run(const Options &options) {
if (options.mode == "help") return 0;
if (!std::filesystem::create_directory(options.output)) {
throw std::invalid_argument("Output directory already exists; choose a new --output directory.");
}
auto metadata = File(options.output / "metadata.txt");
const N1Reference reference{utils::G, utils::MASS, utils::RADIUS};
reference.Validate();
metadata << "mode=" << options.mode << "\nmesh=" << std::filesystem::absolute(options.mesh).string()
<< "\ncompiled=" << __DATE__ << ' ' << __TIME__ << "\ncompiler=" << __VERSION__
<< "\nmpi_ranks=1\nmodel=nonrotating_n1_fixed_mass_fixed_central_density_zero_surface_pressure"
<< "\nG=" << reference.gravitationalConstant << "\nM=" << reference.mass << "\nR=" << reference.radius
<< "\nK=" << reference.PolytropicConstant() << "\nrho_c=" << reference.CentralDensity()
<< "\nabsolute_tolerance=" << options.absoluteTolerance << "\nrelative_tolerance=" << options.relativeTolerance
<< "\nlinear_tolerance=" << options.linearTolerance << "\nmax_newton=" << options.maximumNewtonIterations
<< "\nmax_linear_iterations=" << options.maximumLinearIterations
<< "\npolynomial_increment=" << MEAN_FIELD_UNIFORM_POLYNOMIAL_ORDER_INCREMENT
<< "\nnormalization=production_frozen_physical_Riesz_diagonal\nprofiles=" << options.profiles << '\n';
metadata << "mu_points=" << options.radial.muPointCount << "\nphi_points=" << options.radial.azimuthPointCount
<< "\nexterior_shells=" << options.radial.exteriorShellCount << '\n';
metadata.flush();
if (!SelfChecks(options)) return 3;
if (options.mode == "self-check") return 0;
const auto meshSnapshot = options.output / "input.smesh";
std::filesystem::copy_file(options.mesh, meshSnapshot);
metadata << "mesh_snapshot=" << std::filesystem::absolute(meshSnapshot).string() << '\n';
utils::Args arguments;
arguments.mesh_file = meshSnapshot.string();
arguments.p.rtol = arguments.p.atol = 1.0e-12;
auto finiteElements = fem::setup_fem(arguments.mesh_file, arguments, 0);
if (!finiteElements.okay()) throw std::runtime_error("Could not construct finite elements.");
metadata << "mesh_bytes=" << std::filesystem::file_size(meshSnapshot)
<< "\nelements=" << finiteElements.mesh->GetNE()
<< "\ndensity_order=" << finiteElements.densityFes->GetMaxElementOrder()
<< "\nenthalpy_order=" << finiteElements.enthalpyFes->GetMaxElementOrder()
<< "\npotential_order=" << finiteElements.gravityPotentialFes->GetMaxElementOrder()
<< "\ngravity_flux_order=" << finiteElements.gravityFluxFes->GetMaxElementOrder()
<< "\ndisplacement_order=" << finiteElements.displacementFes->GetMaxElementOrder() << '\n';
metadata.flush();
if (options.mode == "analytic-mesh") {
AnalyticMeshState analytic(finiteElements, reference);
const bool passed = Measure(analytic, reference, options);
metadata << "physical_screen_passed=" << passed << '\n';
return passed ? 0 : 3;
}
if (options.mode == "replay") {
const auto source = ReadMetadata(options.replay);
if (source.at("mode") != "solve" || source.at("model") != "nonrotating_n1_fixed_mass_fixed_central_density_zero_surface_pressure" ||
std::stod(source.at("G")) != reference.gravitationalConstant || std::stod(source.at("M")) != reference.mass ||
std::stod(source.at("R")) != reference.radius) {
throw std::runtime_error("Replay source does not match this nonrotating n=1 experiment.");
}
auto savedMetrics = ReadSavedSolverMetrics(options.replay);
PhysicalState physical(finiteElements, options.replay);
const bool converged = source.at("solver_converged") == "1";
metadata << "replay_source=" << std::filesystem::absolute(options.replay).string()
<< "\nsolver_converged=" << converged
<< "\nsolver_diagnostics=copied_from_source_not_recomputed\n";
if (!converged) metadata << "solver_failure=" << source.at("solver_failure") << '\n';
metadata.flush();
const bool passed = Measure(physical, reference, options, std::move(savedMetrics));
metadata << "physical_screen_passed=" << passed << '\n';
return !converged ? 1 : (passed ? 0 : 3);
}
auto stellarModel = model::StellarModel(
eos::Polytrope({.n = 1.0, .K = reference.PolytropicConstant()}),
surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}),
integral::FixedTotalMass({.Mtotal = dimensions::MassValue{reference.mass}}),
integral::FixedAngularMomentum({.Jtotal = dimensions::AngularMomentumValue{0.0},
.axis = {0.0, 0.0, 1.0}, .center = {0.0, 0.0, 0.0}}),
constraint::FixedCentralDensity({.RhoC = dimensions::DensityValue{reference.CentralDensity()}})
);
auto discretization = equilibrium::makeStellarDiscretization(std::move(finiteElements),
normalization::PhysicalRieszDiagonal{dimensions::LengthValue{reference.radius}, reference.gravitationalConstant});
std::cout << "Constructing production context; nonrotating n=1, nonlinear atol=" << options.absoluteTolerance << std::endl;
const auto start = Clock::now();
auto context = solver::makeContext(std::move(stellarModel), std::move(discretization),
preconditioning::makePreconditioner(), solver::linear::FGMRES({.restartLength = 40, .printLevel = -1}));
metadata << "context_seconds=" << std::chrono::duration<double>(Clock::now()-start).count() << '\n';
metadata.flush();
// Exercise accepted-field reconstruction before the expensive solve,
// and retain a seed baseline to detect physical degradation by Newton.
std::cout << "Measuring production seed before Newton" << std::endl;
solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &state, const fem::FEM &fem) {
PhysicalState physical(state, fem);
auto seedMetrics = MeasureVolumes(physical, reference, options.quadratureOrder);
seedMetrics["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm;
seedMetrics["normalized_unbordered_residual"] = physical.normalizedUnborderedResidualNorm;
seedMetrics["central_border"] = physical.centralBorder;
WriteMetrics(options.output / "seed_physical_metrics.csv", seedMetrics);
std::cout << "Seed |F|=" << physical.normalizedBorderedResidualNorm
<< ", density relative L2=" << seedMetrics.at("density_relative_l2_error")
<< ", virial error=" << seedMetrics.at("virial_error") << std::endl;
});
auto trajectory = File(options.output / "newton_history.csv");
trajectory << "iteration,residual,step,trials,iteration_seconds,linear_iterations,linear_relative_residual,linear_seconds\n";
auto observer = solver::nonlinear::makeObserver(
[](const solver::nonlinear::BeforeIteration &event) {
std::cout << "Newton " << event.iteration << ": |F|=" << event.residualNorm << std::endl;
},
[&](const solver::nonlinear::AfterIteration &event) {
trajectory << event.iteration << ',' << event.residualNorm << ',' << event.acceptedStepLength << ','
<< event.lineSearchTrials << ',' << event.iterationSeconds << ','
<< (event.linearSolve ? event.linearSolve->iterations : 0) << ','
<< (event.linearSolve ? event.linearSolve->relativeTrueResidualNorm : 0.0) << ','
<< (event.linearSolve ? event.linearSolve->solveSeconds : 0.0) << '\n';
trajectory.flush();
std::cout << " step=" << event.acceptedStepLength << ", |F|=" << event.residualNorm
<< ", seconds=" << event.iterationSeconds;
if (event.linearSolve) std::cout << ", linear_iterations=" << event.linearSolve->iterations
<< ", true_relative=" << event.linearSolve->relativeTrueResidualNorm;
std::cout << std::endl;
}
);
auto nonlinear = solver::nonlinear::Newton(solver::nonlinear::NewtonOptions{
.relativeTolerance = options.relativeTolerance, .absoluteTolerance = options.absoluteTolerance,
.maximumIterations = options.maximumNewtonIterations,
.linearSolve = {.relativeTolerance = options.linearTolerance, .absoluteTolerance = 0.0,
.maximumIterations = options.maximumLinearIterations}, .backtracking = {}
});
const auto report = [&]() {
auto equilibriumSolver = solver::make(context, nonlinear, observer);
return equilibriumSolver.evaluate();
}(); // Release the solver before the guarded diagnostic callback.
metadata << "solver_converged=" << report.converged()
<< "\naccepted_steps=" << report.completedNonlinearIterations() << '\n';
if (!report.converged()) metadata << "solver_failure=" << report.failure().message << '\n';
metadata.flush();
std::cout << "Solver converged=" << report.converged() << "; measuring last accepted state" << std::endl;
bool passed = false;
solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &state, const fem::FEM &fem) {
// Preserve expensive accepted coefficients before postprocessing.
// This is experiment data, not a versioned production checkpoint.
auto accepted = File(options.output / "accepted_state.txt");
accepted << state.AcceptedPhysicalState().Size() << '\n';
state.AcceptedPhysicalState().Print(accepted, 1);
accepted.close();
auto layout = File(options.output / "state_layout.csv");
layout << "block,offset,size\n";
for (const auto &block : state.Problem().GetManifest().valueBlocks()) {
layout << block.stableId << ',' << block.offset << ',' << block.size << '\n';
}
PhysicalState physical(state, fem);
auto saveField = [&](const char *name, const mfem::ParGridFunction &field) {
auto stream = File(options.output / (std::string(name) + ".gf"));
field.Save(stream);
};
saveField("density", physical.density);
saveField("enthalpy", physical.enthalpy);
saveField("potential", physical.potential);
saveField("gravity_gradient_reference", physical.gravityGradientReference);
saveField("displacement", physical.displacement);
auto residuals = File(options.output / "residual_blocks.csv");
residuals << "block,size,physical_bordered_l2,physical_unbordered_l2,normalized_bordered_l2,normalized_unbordered_l2\n";
for (const auto &block : state.Problem().GetManifest().residualBlocks()) {
residuals << block.stableId << ',' << block.size << ','
<< BlockNorm(physical.physicalResidualBordered, block.offset, block.size) << ','
<< BlockNorm(physical.physicalResidualUnbordered, block.offset, block.size) << ','
<< BlockNorm(physical.normalizedBorderedResidual, block.offset, block.size) << ','
<< BlockNorm(physical.normalizedUnborderedResidual, block.offset, block.size) << '\n';
}
auto reconstruction = File(options.output / "field_reconstruction.csv");
reconstruction << "field,reduced_size,full_true_size,maximum_round_trip_error\n";
for (const auto &field : physical.reconstructionReports) {
reconstruction << field.field << ',' << field.reducedSize << ',' << field.fullTrueSize << ',' << field.maximumRoundTripError << '\n';
}
metadata << "state_size=" << state.AcceptedPhysicalState().Size()
<< "\naccepted_normalized_residual=" << physical.normalizedBorderedResidualNorm << '\n';
if (physical.centralDensityReport) {
metadata << "central_constraint_density_inferred_from_h=" << physical.centralDensityReport->achievedDensity
<< "\ncentral_constraint_h=" << physical.centralDensityReport->achievedEnthalpy << '\n';
}
metadata.flush();
passed = Measure(physical, reference, options, {
{"normalized_bordered_residual", physical.normalizedBorderedResidualNorm},
{"normalized_unbordered_residual", physical.normalizedUnborderedResidualNorm},
{"normalized_central_border_action", physical.normalizedCentralBorderActionNorm},
{"central_border", physical.centralBorder}, {"bernoulli_constant", physical.bernoulliConstant},
{"angular_velocity_norm", physical.rotation.angular_velocity().Norml2()}
});
});
metadata << "physical_screen_passed=" << passed << "\ntotal_seconds=" << std::chrono::duration<double>(Clock::now()-start).count() << '\n';
return !report.converged() ? 1 : (passed ? 0 : 3);
}
} // namespace
int main(int argc, char **argv) {
mfem::Mpi::Init(argc, argv);
int result = 0;
try {
int ranks = 0;
MPI_Comm_size(MPI_COMM_WORLD, &ranks);
if (ranks != 1) throw std::invalid_argument("Run this verification on exactly one MPI rank.");
mfem::Device device("cpu");
std::cout << std::setprecision(12);
result = Run(Parse(argc, argv));
} catch (const std::exception &error) {
std::cerr << "polytrope verification failure: " << error.what() << std::endl;
result = 2;
}
mfem::Mpi::Finalize();
return result;
}

View File

@@ -0,0 +1,339 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <map>
#include <numbers>
#include <stdexcept>
#include <string>
#include "polytrope_analytic_reference.hpp"
#include "polytrope_physical_state.hpp"
namespace experiment::polytrope_validation {
using Measurements = std::map<std::string, double>;
// Exact fields evaluated on the actual mapped mesh. This checks the same
// integration and physical-point locator used for a numerical solution,
// without constructing a Newton context or using its Lane-Emden seed.
class AnalyticMeshState final {
public:
const mean_field::fem::FEM &finiteElements;
mean_field::mapping::GridFunctionMappingEvaluator mapping;
N1Reference reference;
AnalyticMeshState(const mean_field::fem::FEM &fem, const N1Reference &analytic)
: finiteElements(fem),
mapping(*fem.domainMapperStateless, *fem.displacement, *fem.compactificationCoordinate),
reference(analytic) {
}
[[nodiscard]] bool isStellar(const int element) const {
using Schema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema;
return Schema::template attribute_belongs_to<mean_field::utils::domain::Stellar>(
finiteElements.mesh->GetAttribute(element)
);
}
mean_field::mapping::MappingStatus Evaluate(
const int element, const mfem::IntegrationPoint &point, PhysicalPoint &result
) {
auto *transformation = finiteElements.mesh->GetElementTransformation(element);
transformation->SetIntPoint(&point);
const auto status = mapping.EvaluatePoint(*transformation, point, result.mapping);
if (status != mean_field::mapping::MappingStatus::valid) return status;
const double radius = result.mapping.physical_position.Norml2();
const auto analytic = reference.AtRadius(radius);
result.element = element;
result.attribute = transformation->Attribute;
result.stellarMaterial = isStellar(element);
result.rho = analytic.density;
result.h = analytic.enthalpy;
result.phi = analytic.potential;
result.rotationPotential = 0.0;
result.gravityGradientPhysical.SetSize(3);
result.enthalpyGradientPhysical.SetSize(3);
result.potentialGradientPhysical.SetSize(3);
for (int component = 0; component < 3; ++component) {
const double gradient = radius > 0.0
? analytic.radialPotentialGradient * result.mapping.physical_position(component) / radius : 0.0;
result.gravityGradientPhysical(component) = gradient;
result.potentialGradientPhysical(component) = gradient;
result.enthalpyGradientPhysical(component) = radius <= reference.radius ? -gradient : 0.0;
}
return status;
}
};
struct ErrorIntegral final {
long double errorSquared{0.0L};
long double referenceSquared{0.0L};
double maximumScaledError{0.0};
void Add(const double value, const double exact, const double weight, const double scale) {
const long double difference = static_cast<long double>(value) - exact;
errorSquared += weight * difference * difference;
referenceSquared += static_cast<long double>(weight) * exact * exact;
maximumScaledError = std::max(maximumScaledError, std::abs(value - exact) / scale);
}
[[nodiscard]] double RelativeL2() const {
return referenceSquared > 0.0L ? static_cast<double>(std::sqrt(errorSquared / referenceSquared))
: std::numeric_limits<double>::quiet_NaN();
}
};
template <typename SampleState>
Measurements MeasureVolumes(SampleState &state, const N1Reference &reference, const int quadratureOrder) {
long double volume = 0.0L, mass = 0.0L, binding = 0.0L, forceBinding = 0.0L;
long double pressureIntegral = 0.0L, enthalpyPressureIntegral = 0.0L, kinetic = 0.0L;
long double momentOfInertia = 0.0L, bernoulliOffsetMean = 0.0L, bernoulliCenteredSquared = 0.0L;
long double closureSquared = 0.0L, closureDensityInnerProduct = 0.0L;
long double gradientMismatch = 0.0L, hydrostaticGradient = 0.0L;
std::array<long double, 3> firstMoment{};
ErrorIntegral densityError, enthalpyError, potentialError, pressureError, gravityError;
double minimumDensity = std::numeric_limits<double>::infinity();
double minimumEnthalpy = std::numeric_limits<double>::infinity();
double minimumDeterminant = std::numeric_limits<double>::infinity();
double bernoulliMinimum = std::numeric_limits<double>::infinity();
double bernoulliMaximum = -std::numeric_limits<double>::infinity();
std::uint64_t samples = 0, negativeDensitySamples = 0, negativeEnthalpySamples = 0;
PhysicalPoint point;
const double densityScale = reference.CentralDensity();
const double enthalpyScale = reference.CentralEnthalpy();
const double pressureScale = reference.PolytropicConstant() * densityScale * densityScale;
const double gravityScale = reference.gravitationalConstant * reference.mass /
(reference.radius * reference.radius);
for (int element = 0; element < state.finiteElements.mesh->GetNE(); ++element) {
if (!state.isStellar(element)) continue;
auto *transformation = state.finiteElements.mesh->GetElementTransformation(element);
const auto &rule = mfem::IntRules.Get(transformation->GetGeometryType(), quadratureOrder);
for (int q = 0; q < rule.GetNPoints(); ++q) {
const auto &ip = rule.IntPoint(q);
if (state.Evaluate(element, ip, point) != mean_field::mapping::MappingStatus::valid) {
throw std::runtime_error("Invalid physical mapping in polytrope volume verification.");
}
transformation->SetIntPoint(&ip);
// Explicitly check the orientation of both the reference map
// and the deformation before integrating physical volume.
const double referenceDeterminant = transformation->Jacobian().Det();
const double weight = ip.weight * referenceDeterminant * point.mapping.mapping_determinant;
if (!(referenceDeterminant > 0.0) || !std::isfinite(referenceDeterminant)) {
throw std::runtime_error("Non-positive or non-finite reference Jacobian in polytrope volume verification.");
}
if (!(weight > 0.0) || !std::isfinite(weight) || !std::isfinite(point.rho) ||
!std::isfinite(point.h) || !std::isfinite(point.phi) || !std::isfinite(point.rotationPotential)) {
throw std::runtime_error("Non-finite field or non-positive physical integration weight.");
}
++samples;
const auto &position = point.mapping.physical_position;
const double radius = position.Norml2();
const auto exact = reference.AtRadius(radius);
// Do not clamp numerical density/enthalpy. Negative values are
// reported, and pressure consistency is checked independently.
const double pressure = reference.PolytropicConstant() * point.rho * point.rho;
const double pressureFromEnthalpy = point.h * point.h / (4.0 * reference.PolytropicConstant());
const long double specificBernoulli = static_cast<long double>(point.h) + point.phi - point.rotationPotential;
volume += weight;
mass += weight * point.rho;
binding += 0.5L * weight * point.rho * point.phi;
forceBinding -= weight * point.rho * (position * point.gravityGradientPhysical);
pressureIntegral += weight * pressure;
enthalpyPressureIntegral += weight * pressureFromEnthalpy;
// RigidRotation::potential is positive +|Omega x r|^2/2.
kinetic += weight * point.rho * point.rotationPotential;
momentOfInertia += weight * point.rho * (position(0) * position(0) + position(1) * position(1));
// Weighted Welford accumulation about the fixed analytic
// Bernoulli constant C=-h_c resolves small spatial variations
// without subtracting two O(h_c^2) second moments. No fitted
// potential offset is applied to any physical field/error.
const long double bernoulliOffset = specificBernoulli + enthalpyScale;
const long double bernoulliDelta = bernoulliOffset - bernoulliOffsetMean;
bernoulliOffsetMean += (static_cast<long double>(weight) / volume) * bernoulliDelta;
bernoulliCenteredSquared += weight * bernoulliDelta * (bernoulliOffset - bernoulliOffsetMean);
const double closure = point.h - 2.0 * reference.PolytropicConstant() * point.rho;
closureSquared += weight * closure * closure;
closureDensityInnerProduct += weight * point.rho * closure;
bernoulliMinimum = std::min(bernoulliMinimum, static_cast<double>(specificBernoulli));
bernoulliMaximum = std::max(bernoulliMaximum, static_cast<double>(specificBernoulli));
minimumDensity = std::min(minimumDensity, point.rho);
minimumEnthalpy = std::min(minimumEnthalpy, point.h);
minimumDeterminant = std::min(minimumDeterminant, point.mapping.mapping_determinant);
negativeDensitySamples += point.rho < 0.0;
negativeEnthalpySamples += point.h < 0.0;
densityError.Add(point.rho, exact.density, weight, densityScale);
enthalpyError.Add(point.h, exact.enthalpy, weight, enthalpyScale);
potentialError.Add(point.phi, exact.potential, weight, enthalpyScale);
pressureError.Add(pressure, exact.pressure, weight, pressureScale);
for (int component = 0; component < 3; ++component) {
if (!std::isfinite(point.gravityGradientPhysical(component)) ||
!std::isfinite(point.potentialGradientPhysical(component)) ||
!std::isfinite(point.enthalpyGradientPhysical(component))) {
throw std::runtime_error("Non-finite physical field gradient in polytrope volume verification.");
}
firstMoment[component] += weight * point.rho * position(component);
const double exactGradient = radius > 0.0
? exact.radialPotentialGradient * position(component) / radius : 0.0;
gravityError.Add(point.gravityGradientPhysical(component), exactGradient, weight, gravityScale);
const double mismatch = point.gravityGradientPhysical(component) - point.potentialGradientPhysical(component);
gradientMismatch += weight * mismatch * mismatch;
const double hydrostatic = point.enthalpyGradientPhysical(component) + point.gravityGradientPhysical(component);
hydrostaticGradient += weight * hydrostatic * hydrostatic;
}
}
}
if (!(volume > 0.0L) || !(mass > 0.0L) || !(binding < 0.0L)) {
throw std::runtime_error("Polytrope verification requires positive stellar volume/mass and negative binding energy.");
}
Measurements result{
{"quadrature_order", static_cast<double>(quadratureOrder)}, {"stellar_samples", static_cast<double>(samples)},
{"volume", static_cast<double>(volume)}, {"mass", static_cast<double>(mass)},
{"mass_relative_error", static_cast<double>(std::abs(mass / reference.mass - 1.0L))},
{"volume_radius", static_cast<double>(std::cbrt(3.0L * volume / (4.0L * std::numbers::pi_v<long double>)))},
{"binding_energy", static_cast<double>(binding)}, {"force_binding_energy", static_cast<double>(forceBinding)},
{"pressure_integral", static_cast<double>(pressureIntegral)},
{"enthalpy_pressure_integral", static_cast<double>(enthalpyPressureIntegral)},
{"kinetic_energy", static_cast<double>(kinetic)},
{"virial_signed", static_cast<double>((2.0L * kinetic + binding + 3.0L * pressureIntegral) / std::abs(binding))},
{"virial_error", static_cast<double>(std::abs(2.0L * kinetic + binding + 3.0L * pressureIntegral) / std::abs(binding))},
{"virial_ratio", static_cast<double>((2.0L * kinetic + 3.0L * pressureIntegral) / std::abs(binding))},
{"force_virial_error", static_cast<double>(std::abs(2.0L * kinetic + forceBinding + 3.0L * pressureIntegral) / std::abs(binding))},
{"enthalpy_virial_error", static_cast<double>(std::abs(2.0L * kinetic + binding + 3.0L * enthalpyPressureIntegral) / std::abs(binding))},
{"enthalpy_force_virial_error", static_cast<double>(std::abs(2.0L * kinetic + forceBinding + 3.0L * enthalpyPressureIntegral) / std::abs(binding))},
{"gravity_energy_consistency", static_cast<double>(std::abs(binding - forceBinding) / std::abs(binding))},
{"binding_relative_error", static_cast<double>(std::abs(binding / reference.BindingEnergy() - 1.0L))},
{"pressure_integral_relative_error", static_cast<double>(std::abs(pressureIntegral / reference.PressureIntegral() - 1.0L))},
{"pressure_integral_eos_disagreement", static_cast<double>(std::abs(pressureIntegral - enthalpyPressureIntegral) / reference.PressureIntegral())},
{"closure_projection_pressure_gap", static_cast<double>(closureSquared / (4.0L * reference.PolytropicConstant()))},
{"closure_density_inner_product", static_cast<double>(closureDensityInnerProduct)},
{"closure_projection_pressure_gap_relative_defect", static_cast<double>((enthalpyPressureIntegral - pressureIntegral -
closureSquared / (4.0L * reference.PolytropicConstant())) / reference.PressureIntegral())},
{"moment_of_inertia", static_cast<double>(momentOfInertia)},
{"moment_of_inertia_relative_error", static_cast<double>(std::abs(momentOfInertia / reference.MomentOfInertia() - 1.0L))},
{"bernoulli_mean", static_cast<double>(bernoulliOffsetMean - enthalpyScale)},
{"bernoulli_mean_scaled_error", static_cast<double>(std::abs(bernoulliOffsetMean) / enthalpyScale)},
{"bernoulli_scaled_range", (bernoulliMaximum - bernoulliMinimum) / enthalpyScale},
{"bernoulli_scaled_rms_variation", static_cast<double>(std::sqrt(std::max(0.0L, bernoulliCenteredSquared / volume)) / enthalpyScale)},
{"eos_enthalpy_scaled_rms", static_cast<double>(std::sqrt(closureSquared / volume) / enthalpyScale)},
{"gravity_gradient_vs_broken_potential_gradient_scaled_rms", static_cast<double>(std::sqrt(gradientMismatch / volume) / gravityScale)},
{"nonrotating_hydrostatic_gradient_scaled_rms", static_cast<double>(std::sqrt(hydrostaticGradient / volume) / gravityScale)},
{"minimum_density", minimumDensity}, {"minimum_enthalpy", minimumEnthalpy},
{"negative_density_samples", static_cast<double>(negativeDensitySamples)},
{"negative_enthalpy_samples", static_cast<double>(negativeEnthalpySamples)},
{"minimum_mapping_determinant", minimumDeterminant}
};
result["volume_radius_relative_error"] = std::abs(result.at("volume_radius") / reference.radius - 1.0);
for (const auto &[name, error] : std::array<std::pair<const char *, const ErrorIntegral *>, 5>{{
{"density", &densityError}, {"enthalpy", &enthalpyError}, {"potential", &potentialError},
{"pressure", &pressureError}, {"gravity_gradient", &gravityError}}}) {
result[std::string(name) + "_relative_l2_error"] = error->RelativeL2();
result[std::string(name) + "_maximum_scaled_error"] = error->maximumScaledError;
}
for (int component = 0; component < 3; ++component) {
result["center_of_mass_" + std::to_string(component)] = static_cast<double>(firstMoment[component] / mass);
}
return result;
}
template <typename SampleState>
Measurements MeasureSurfaceAndCorners(SampleState &state, const N1Reference &reference, const int order) {
auto &mesh = *state.finiteElements.mesh;
PhysicalPoint mapped;
double minimumRadius = std::numeric_limits<double>::infinity(), maximumRadius = 0.0;
double maximumSurfaceEnthalpy = 0.0, maximumSurfacePotentialError = 0.0;
long double area = 0.0L, radiusIntegral = 0.0L, radiusError = 0.0L;
std::uint64_t boundarySamples = 0, invalidCornerSamples = 0, cornerSamples = 0;
double minimumCornerDeterminant = std::numeric_limits<double>::infinity();
double maximumCornerCondition = 0.0;
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) {
if (mesh.GetBdrAttribute(boundary) != 1) continue; // Canonical sandbox stellar surface.
// The tagged stellar surface is an interior material interface
// when a vacuum region is present. Resolve the actual mesh face
// to retain both adjacent traces in that case.
const int faceIndex = mesh.GetBdrElementFaceIndex(boundary);
if (faceIndex < 0) throw std::runtime_error("Missing stellar surface mesh face.");
auto *face = mesh.GetFaceElementTransformations(faceIndex);
if (face == nullptr || face->Elem1 == nullptr) throw std::runtime_error("Missing stellar surface transformation.");
const bool first = state.isStellar(face->Elem1No);
if (!first && (face->Elem2 == nullptr || !state.isStellar(face->Elem2No))) {
throw std::runtime_error("Stellar surface has no stellar-material trace.");
}
const int element = first ? face->Elem1No : face->Elem2No;
const auto &rule = mfem::IntRules.Get(face->GetGeometryType(), order);
for (int q = 0; q < rule.GetNPoints(); ++q) {
const auto &ip = rule.IntPoint(q);
// Field evaluation may reuse MFEM's cached element transforms;
// restore both adjacent face traces before each quadrature point.
face = mesh.GetFaceElementTransformations(faceIndex);
face->SetAllIntPoints(&ip);
const auto volumePoint = first ? face->Elem1->GetIntPoint() : face->Elem2->GetIntPoint();
mfem::DenseMatrix referenceFaceJacobian(face->Jacobian());
if (state.Evaluate(element, volumePoint, mapped) != mean_field::mapping::MappingStatus::valid) {
throw std::runtime_error("Invalid mapping on stellar surface.");
}
mfem::DenseMatrix physicalFaceJacobian(3, 2);
mfem::Mult(mapped.mapping.mapping_jacobian, referenceFaceJacobian, physicalFaceJacobian);
const double weight = ip.weight * physicalFaceJacobian.Weight();
const double radius = mapped.mapping.physical_position.Norml2();
if (!(weight > 0.0) || !std::isfinite(weight) || !std::isfinite(radius) ||
!std::isfinite(mapped.h) || !std::isfinite(mapped.phi)) {
throw std::runtime_error("Non-finite field or non-positive physical surface integration weight.");
}
area += weight;
radiusIntegral += weight * radius;
radiusError += weight * (radius - reference.radius) * (radius - reference.radius);
minimumRadius = std::min(minimumRadius, radius);
maximumRadius = std::max(maximumRadius, radius);
maximumSurfaceEnthalpy = std::max(maximumSurfaceEnthalpy, std::abs(mapped.h) / reference.CentralEnthalpy());
maximumSurfacePotentialError = std::max(maximumSurfacePotentialError,
std::abs(mapped.phi + reference.CentralEnthalpy()) / reference.CentralEnthalpy());
++boundarySamples;
}
}
for (int element = 0; element < mesh.GetNE(); ++element) {
if (!state.isStellar(element)) continue;
const auto geometry = mesh.GetElementBaseGeometry(element);
const auto &vertices = *mfem::Geometries.GetVertices(geometry);
const auto &center = mfem::Geometries.GetCenter(geometry);
for (int vertex = 0; vertex < vertices.GetNPoints(); ++vertex) {
for (const double inset : {0.0, 0.005, 0.02}) {
const auto &v = vertices.IntPoint(vertex);
mfem::IntegrationPoint ip;
ip.Set3((1.0-inset)*v.x+inset*center.x, (1.0-inset)*v.y+inset*center.y, (1.0-inset)*v.z+inset*center.z);
++cornerSamples;
if (state.Evaluate(element, ip, mapped) != mean_field::mapping::MappingStatus::valid) {
++invalidCornerSamples;
continue;
}
auto *transformation = mesh.GetElementTransformation(element);
transformation->SetIntPoint(&ip);
mfem::DenseMatrix totalJacobian(3);
mfem::Mult(mapped.mapping.mapping_jacobian, transformation->Jacobian(), totalJacobian);
minimumCornerDeterminant = std::min(minimumCornerDeterminant, mapped.mapping.mapping_determinant);
const double determinant = totalJacobian.Det();
const double smallest = totalJacobian.CalcSingularvalue(2);
const double largest = totalJacobian.CalcSingularvalue(0);
if (!(determinant > 0.0) || !std::isfinite(determinant) || !(smallest > 0.0) ||
!std::isfinite(smallest) || !std::isfinite(largest)) ++invalidCornerSamples;
else maximumCornerCondition = std::max(maximumCornerCondition, largest / smallest);
}
}
}
if (!(area > 0.0L) || !std::isfinite(area)) throw std::runtime_error("No finite positive stellar surface area.");
return {{"surface_samples", static_cast<double>(boundarySamples)}, {"surface_area", static_cast<double>(area)},
{"surface_radius_minimum", minimumRadius}, {"surface_radius_maximum", maximumRadius},
{"surface_radius_area_mean", static_cast<double>(radiusIntegral / area)},
{"surface_radius_relative_rms_error", static_cast<double>(std::sqrt(radiusError / area) / reference.radius)},
{"surface_radius_relative_range", (maximumRadius - minimumRadius) / reference.radius},
{"surface_enthalpy_maximum_scaled", maximumSurfaceEnthalpy},
{"surface_potential_maximum_scaled_error", maximumSurfacePotentialError},
{"stellar_corner_samples", static_cast<double>(cornerSamples)},
{"invalid_stellar_corner_samples", static_cast<double>(invalidCornerSamples)},
{"minimum_stellar_corner_mapping_determinant", minimumCornerDeterminant},
{"maximum_stellar_corner_element_condition", maximumCornerCondition}};
}
} // namespace experiment::polytrope_validation

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Refine a saved STROID mesh once, retaining the original and provenance.
Run with a Python environment containing the multiblock-capable STROID build.
The output is a complete, already-refined mesh: pass it to the validation
experiment without any further refinement, including during saved-field replay.
"""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import stroid
from stroid import _stroid
from stroid.IO import LoadStroidMesh, SaveStroidMesh
from stroid.refinement import UniformRefinement
def digest(path):
with Path(path).open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def counts(mesh):
result = stroid.stats.ComputeMeshStats(
mesh, stroid.stats.MeshStatFeatures.ELEMENT_COUNT
).element_counts
return {name: getattr(result, name) for name in
("total", "core", "envelope", "vacuum", "other")}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output_directory", type=Path,
help="New directory; existing directories are refused")
args = parser.parse_args()
source = args.input.resolve(strict=True)
original_digest = digest(source)
mesh = LoadStroidMesh(str(source))
if not hasattr(mesh.config, "core_mapping"):
raise RuntimeError("STROID is too old: no core_mapping support")
if not mesh.has_mesh() or not mesh.has_rmesh():
raise RuntimeError("Both physical and logical reference meshes are required")
before = counts(mesh)
initial_level = mesh.refinement_levels
mapping = mesh.config.core_mapping
order = mesh.config.order
args.output_directory.mkdir(exist_ok=False)
print(f"Loaded {mesh}; mapping={mapping}, geometry order={order}, "
f"stored refinement level={initial_level}", flush=True)
# This is an ADDITIONAL level on the loaded mesh, not regeneration from
# MeshConfig.refinement_levels. STROID rebuilds the high-order geometry.
UniformRefinement(mesh, 1)
after = counts(mesh)
if mesh.refinement_levels != initial_level + 1:
raise RuntimeError("STROID did not advance exactly one refinement level")
if any(after[name] != 8 * count for name, count in before.items()):
raise RuntimeError(f"Expected eight children per hex: {before} -> {after}")
if mesh.config.core_mapping != mapping or mesh.config.order != order:
raise RuntimeError("Refinement changed mapping strategy or geometry order")
target = args.output_directory / "refined.smesh"
SaveStroidMesh(mesh, str(target), "One additional UniformRefinement of " + str(source))
reloaded = LoadStroidMesh(str(target))
if counts(reloaded) != after or reloaded.refinement_levels != initial_level + 1:
raise RuntimeError("Saved refinement failed its load/metadata round trip")
if reloaded.config.core_mapping != mapping or reloaded.config.order != order:
raise RuntimeError("Saved refinement lost its mapping/order configuration")
if digest(source) != original_digest:
raise RuntimeError("Input mesh changed during the operation")
provenance = {
"source": str(source), "source_sha256": original_digest,
"refined_mesh": str(target.resolve()), "refined_sha256": digest(target),
"operation": "stroid.refinement.UniformRefinement(loaded_mesh, 1)",
"additional_levels": 1, "initial_level": initial_level,
"final_level": mesh.refinement_levels,
"geometry_order": order, "core_mapping": mapping,
"counts_before": before, "counts_after": after,
"python": sys.executable, "stroid_version": stroid.__version__,
"stroid_extension": _stroid.__file__,
"stroid_extension_sha256": digest(_stroid.__file__),
"geometry_policy": "STROID reprojects refined logical geometry; not fixed physical-polynomial subdivision",
"downstream_extra_refinements": 0,
}
with (args.output_directory / "refinement.json").open("x") as stream:
json.dump(provenance, stream, indent=2)
stream.write("\n")
print(f"Saved {mesh} to {target}; load with extra refinement=0", flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Read-only, standard-library summary of geometry_quality_experiment artifacts."""
import argparse
import csv
import math
from pathlib import Path
def rows(path):
if not path.exists():
return []
with path.open(newline="") as stream:
return list(csv.DictReader(stream))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
args = parser.parse_args()
root = args.directory
print("GEOMETRY (boundaries are censored at alpha=1)")
for path in sorted(root.glob("*_geometry_elements.csv")):
data = rows(path)
limited = [r for r in data if r["limited"] == "1"]
if not limited:
print(path.stem, "no sampled boundary <=1")
continue
first = limited[0]
boundary = float(first["boundary_step"])
ties = [r["element"] for r in limited if float(r["boundary_step"]) <= boundary * (1 + 1e-6)]
print(path.stem, f"boundary={boundary:.10g}", f"limiter={first['element']}",
f"attr={first['attribute']}", f"ties_1ppm={','.join(ties)}",
f"radial_gradient={float(first['limiter_radial_gradient']):.7g}")
for key in first:
if "reference" in key and ("sigma" in key or "det" in key):
print(" ", key, first[key])
print("\nLINEAR SOLVES")
for row in rows(root / "solves.csv"):
print(row)
print("\nSURFACE (unweighted nodal fractional changes)")
for row in rows(root / "surface_summary.csv"):
print(row)
surface_rows = rows(root / "surface.csv")
for case in sorted({r["case"] for r in surface_rows}):
groups = {}
for row in surface_rows:
if row["case"] != case:
continue
radius = float(row["radius"])
key = tuple(sorted(round(abs(float(row[c]) / radius), 8) for c in ("x", "y", "z")))
groups.setdefault(key, []).append(float(row["correction_fraction"]))
print(case, "cubic_symmetry_groups=", len(groups), "max_within_group_spread=",
max((max(v) - min(v) for v in groups.values()), default=math.nan))
print("\nACCEPTED RESIDUAL BLOCKS")
for row in rows(root / "blocks.csv"):
if row["case"] == "accepted" and row["kind"] == "residual":
print(row["block"], "normalized_l2=" + row["normalized_l2"])
print("\nFINITE DIFFERENCES")
for row in rows(root / "finite_differences.csv"):
if float(row["action_norm"]) > 1e-12:
print(row["epsilon"], row["row"], "relative_error=" + row["relative_error"])
print("\nMAPPING CHECKS")
for path in sorted(root.glob("*_geometry_mapping_checks.csv")):
data = rows(path)
errors = [float(r["relative_mapping_matrix_error"]) for r in data]
errors = [v for v in errors if math.isfinite(v)]
print(path.stem, "max_matrix_error=", max(errors, default=math.nan),
"invalid_samples=", sum(not math.isfinite(float(r["direct_det"])) for r in data))
print("\nEXTENSION CHECKS")
for row in rows(root / "extension_checks.csv"):
print(row)
print("\nCORE DIAGONAL AT NEWTON LIMITER")
steps = {r["case"]: float(r["safe_step"]) for r in rows(root / "solves.csv")}
for path in sorted(root.glob("*_core_diagonal.csv")):
data = rows(path)
for row in data:
if abs(float(row["s"]) - 0.010885670926971493) < 1e-12:
print(path.stem, "actual_u=", row["actual_radial_displacement"],
"desired_u=", row["desired_radial_displacement"],
"actual_gradient=", row["actual_radial_gradient"],
"desired_gradient=", row["desired_radial_gradient"])
case = path.stem.removesuffix("_core_diagonal")
for alpha in sorted({1.0, steps.get(case, 1.0)}):
determinants = []
for row in data:
if "relative_det_coefficient_0" not in row:
continue
c = [float(row[f"relative_det_coefficient_{i}"]) for i in range(4)]
det = ((c[3] * alpha + c[2]) * alpha + c[1]) * alpha + c[0]
determinants.append((det, float(row["s"])))
if determinants:
print(" ", case, "alpha=", alpha, "min_diagonal_det_and_s=", min(determinants))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""Create a static SVG and Markdown summary of polytrope verification CSVs.
Uses only the Python standard library. Does not rerun the solver or alter input
CSVs; writes polytrope_profiles.svg and polytrope_summary.md in the input directory.
"""
import argparse
import csv
import html
import math
from pathlib import Path
FIELDS = (
("density_material", "theta_density", "density", "#2563eb"),
("enthalpy_material", "theta_enthalpy", "enthalpy", "#15803d"),
("potential", "theta_potential", "potential", "#c2410c"),
("gravity_radial", None, "radial gravity gradient", "#7e22ce"),
)
def read_rows(path):
with path.open(newline="") as stream:
return list(csv.DictReader(stream))
def number(value):
try:
return float(value)
except (TypeError, ValueError):
return math.nan
def read_metrics(directory):
return {row["metric"]: number(row["value"])
for row in read_rows(directory / "physical_metrics.csv")}
def metadata(directory):
path = directory / "metadata.txt"
if not path.exists():
return {}
return dict(line.split("=", 1) for line in path.read_text().splitlines() if "=" in line)
def finite_max(values):
return max((value for value in values if math.isfinite(value)), default=math.nan)
def format_number(value):
return f"{value:.5g}" if math.isfinite(value) else "not available"
def reference_scales(rows):
origin = next((row for row in rows if number(row.get("xi")) == 0.0), None)
if origin is None:
raise ValueError("radial_profiles.csv must contain its analytic origin reference")
density = number(origin["density_material_analytic"])
enthalpy = number(origin["enthalpy_material_analytic"])
nonzero = next(row for row in rows if number(row.get("xi")) > 0.0)
radius = math.pi * number(nonzero["radius"]) / number(nonzero["xi"])
if not all(math.isfinite(value) and value > 0.0 for value in (density, enthalpy, radius)):
raise ValueError("Non-finite/non-positive fixed analytic reference scales")
return {"density_material": density, "enthalpy_material": enthalpy,
"potential": enthalpy, "gravity_radial": enthalpy / radius,
"radius": radius}
def points(rows, column, scale=1.0, interior=False):
result = []
for row in rows:
xi = number(row.get("xi"))
if interior and xi > math.pi:
continue
result.append((xi, number(row.get(column)) / scale))
return result
def path_segments(data, xmap, ymap, logarithmic=False):
segments, current = [], []
for x, y in data:
if not math.isfinite(x) or not math.isfinite(y) or (logarithmic and y <= 0.0):
if current:
segments.append(current)
current = []
continue
current.append((xmap(x), ymap(y)))
if current:
segments.append(current)
return segments
class Figure:
def __init__(self, title):
self.parts = [
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="910" viewBox="0 0 1200 910" role="img">',
f"<title>{html.escape(title)}</title>",
'<desc>Fixed-reference n=1 physical-radius profiles, absolute mean errors, angular scatter, and sampling coverage.</desc>',
'<rect width="1200" height="910" fill="#ffffff"/>',
'<style>text{font-family:Arial,Helvetica,sans-serif;fill:#1f2937;font-size:12px}.title{font-size:22px;font-weight:bold}.panel{font-size:15px;font-weight:bold}.small{font-size:11px}</style>',
f'<text class="title" x="65" y="35">{html.escape(title)}</text>',
'<text x="65" y="58">Physical spheres; ξ = πr/R. Reference R and central scales are prescribed, never fitted.</text>',
]
def panel(self, ident, box, title, xlabel, ylabel, xlim, ylim, series, log=False):
left, top, width, height = box
xmap = lambda value: left + width * (value - xlim[0]) / (xlim[1] - xlim[0])
if log:
lower, upper = math.log10(ylim[0]), math.log10(ylim[1])
ymap = lambda value: top + height * (upper - math.log10(value)) / (upper - lower)
ticks = [(10.0 ** exponent, f"1e{exponent}")
for exponent in range(math.ceil(lower), math.floor(upper) + 1)]
if len(ticks) > 7:
ticks = ticks[::math.ceil(len(ticks) / 7)]
else:
ymap = lambda value: top + height * (ylim[1] - value) / (ylim[1] - ylim[0])
ticks = [(ylim[0] + i * (ylim[1] - ylim[0]) / 4.0,
f"{ylim[0] + i * (ylim[1] - ylim[0]) / 4.0:.2g}") for i in range(5)]
self.parts.append(f'<text class="panel" x="{left}" y="{top - 58}">{html.escape(title)}</text>')
self.parts.append(f'<defs><clipPath id="{ident}"><rect x="{left}" y="{top}" width="{width}" height="{height}"/></clipPath></defs>')
for value, label in ticks:
y = ymap(value)
self.parts.append(f'<line x1="{left}" x2="{left + width}" y1="{y:.3f}" y2="{y:.3f}" stroke="#e5e7eb"/>')
self.parts.append(f'<text x="{left - 9}" y="{y + 4:.3f}" text-anchor="end">{label}</text>')
for i in range(5):
value = xlim[0] + i * (xlim[1] - xlim[0]) / 4.0
x = xmap(value)
self.parts.append(f'<line x1="{x:.3f}" x2="{x:.3f}" y1="{top}" y2="{top + height}" stroke="#f1f5f9"/>')
self.parts.append(f'<text x="{x:.3f}" y="{top + height + 20}" text-anchor="middle">{value:.3g}</text>')
self.parts.append(f'<rect x="{left}" y="{top}" width="{width}" height="{height}" fill="none" stroke="#64748b"/>')
self.parts.append(f'<text x="{left + width / 2}" y="{top + height + 42}" text-anchor="middle">{html.escape(xlabel)}</text>')
self.parts.append(f'<text transform="translate({left - 57},{top + height / 2}) rotate(-90)" text-anchor="middle">{html.escape(ylabel)}</text>')
legend_index = 0
for item in series:
dash = ' stroke-dasharray="6 4"' if item.get("dash") else ""
for segment in path_segments(item["data"], xmap, ymap, log):
if len(segment) == 1:
x, y = segment[0]
self.parts.append(f'<circle clip-path="url(#{ident})" cx="{x:.3f}" cy="{y:.3f}" r="2" fill="{item["color"]}"/>')
else:
coordinates = " ".join(f"{x:.3f},{y:.3f}" for x, y in segment)
self.parts.append(f'<polyline clip-path="url(#{ident})" points="{coordinates}" fill="none" stroke="{item["color"]}" stroke-width="1.8"{dash}/>')
if item.get("label"):
x = left + (legend_index % 2) * width / 2
y = top - 35 + (legend_index // 2) * 17
self.parts.append(f'<line x1="{x}" x2="{x + 20}" y1="{y}" y2="{y}" stroke="{item["color"]}" stroke-width="2"{dash}/>')
self.parts.append(f'<text class="small" x="{x + 26}" y="{y + 4}">{html.escape(item["label"])}</text>')
legend_index += 1
def write(self, path, control):
note = "Solid: measured state. Dashed same-color: analytic-mesh control." if control else "Mean errors and scatter are separately scaled by fixed central/reference values."
self.parts.append(f'<text class="small" x="65" y="864">{html.escape(note)}</text>')
self.parts.append('<text class="small" x="65" y="884">Missing/nonfinite samples are not connected; zero errors are omitted on logarithmic axes. Material means are conditional.</text>')
self.parts.append("</svg>")
path.write_text("\n".join(self.parts) + "\n")
def logarithmic_limits(series):
values = [y for item in series for _, y in item["data"] if math.isfinite(y) and y > 0.0]
if not values:
return 1e-16, 1.0
lower = max(-300, math.floor(math.log10(min(values))))
upper = max(lower + 2, math.ceil(math.log10(max(values))))
return 10.0 ** lower, 10.0 ** upper
def make_figure(directory, rows, control_rows):
scales = reference_scales(rows)
control_scales = reference_scales(control_rows) if control_rows else {}
if control_rows:
for key in scales:
if not math.isclose(scales[key], control_scales[key], rel_tol=1e-12):
raise ValueError(f"Control and measured fixed-reference scales differ: {key}")
figure = Figure("n = 1 polytrope: physical profile verification")
analytic = [(math.pi * i / 300, math.sin(math.pi * i / 300) / (math.pi * i / 300) if i else 1.0)
for i in range(301)]
profile_series = [{"data": analytic, "color": "#111827", "label": "analytic sin(ξ)/ξ", "dash": True}]
for _, column, label, color in FIELDS[:3]:
profile_series.append({"data": points(rows, column, interior=True), "color": color, "label": label})
profile_values = [y for item in profile_series for _, y in item["data"] if math.isfinite(y)]
lo, hi = min(profile_values), max(profile_values)
padding = max(0.05, 0.05 * (hi - lo))
figure.panel("profiles", (85, 150, 470, 255), "Interior dimensionless profiles", "ξ = πr/R", "θ from each field",
(0.0, math.pi), (lo - padding, hi + padding), profile_series)
maximum_xi = finite_max(number(row["xi"]) for row in rows)
mean_series, scatter_series = [], []
for field, _, label, color in FIELDS:
mean_series.append({"data": [(x, abs(y)) for x, y in points(rows, field + "_mean_error_scaled")], "color": color, "label": label})
scatter_series.append({"data": points(rows, field + "_angular_rms", scales[field]), "color": color, "label": label})
if control_rows:
mean_series.append({"data": [(x, abs(y)) for x, y in points(control_rows, field + "_mean_error_scaled")], "color": color, "dash": True})
scatter_series.append({"data": points(control_rows, field + "_angular_rms", control_scales[field]), "color": color, "dash": True})
figure.panel("mean_errors", (690, 150, 440, 255), "Absolute spherical-mean error", "ξ = πr/R", "absolute error / fixed scale",
(0.0, maximum_xi), logarithmic_limits(mean_series), mean_series, log=True)
figure.panel("scatter", (85, 565, 470, 230), "Angular RMS about the spherical mean", "ξ = πr/R", "angular RMS / fixed scale",
(0.0, maximum_xi), logarithmic_limits(scatter_series), scatter_series, log=True)
coverage = [
{"data": points(rows, "located_weight_fraction"), "color": "#111827", "label": "point location"},
{"data": points(rows, "material_weight_fraction"), "color": "#64748b", "label": "stellar material"},
{"data": points(rows, "density_material_valid_weight_fraction"), "color": "#2563eb", "label": "finite density", "dash": True},
{"data": points(rows, "enthalpy_material_valid_weight_fraction"), "color": "#15803d", "label": "finite enthalpy", "dash": True},
]
figure.panel("coverage", (690, 565, 440, 230), "Sampling coverage (inspect before means)", "ξ = πr/R", "fraction of requested angular weight",
(0.0, maximum_xi), (-0.03, 1.03), coverage)
figure.write(directory / "polytrope_profiles.svg", bool(control_rows))
def make_report(directory, rows, metrics, checks, control_directory, control_metrics):
info = metadata(directory)
failed = [row for row in checks if row.get("passed", "").lower() not in ("1", "true")]
lines = ["# Polytrope physical verification", "", f"Source: `{directory.resolve()}`.", "",
f"Declared screening checks: **{len(checks) - len(failed)}/{len(checks)} passed**. "
"These budgets are not a mesh-convergence certificate.", ""]
if info:
lines.append(f"Mode: `{info.get('mode', 'unknown')}`. Solver convergence: `{info.get('solver_converged', 'not applicable/reported')}`.")
if "solver_failure" in info:
lines.extend(["", "Solver failure: " + info["solver_failure"]])
lines.append("")
lines.extend(["![Fixed-reference physical profiles, errors, angular scatter, and coverage](polytrope_profiles.svg)", "",
"## Main diagnostics", ""])
if control_directory:
lines.extend([f"Analytic-mesh control: `{control_directory.resolve()}`. Control errors are shown directly, not subtracted from numerical errors.", ""])
lines.append("| Metric | Measured |" + (" Analytic-mesh control |" if control_directory else ""))
lines.append("|---|---:|" + ("---:|" if control_directory else ""))
selected = ("mass", "mass_relative_error", "volume_radius_relative_error", "surface_radius_relative_rms_error",
"density_relative_l2_error", "enthalpy_relative_l2_error", "potential_relative_l2_error",
"gravity_gradient_relative_l2_error", "binding_energy", "pressure_integral", "virial_error",
"force_virial_error", "enthalpy_virial_error", "enthalpy_force_virial_error",
"gravity_energy_consistency", "eos_enthalpy_scaled_rms", "closure_projection_pressure_gap",
"closure_projection_pressure_gap_relative_defect",
"normalized_bordered_residual", "normalized_unbordered_residual", "normalized_central_border_action",
"invalid_stellar_corner_samples", "maximum_stellar_corner_element_condition",
"profile_missing_points", "profile_maximum_location_error", "profile_maximum_scaled_error",
"profile_maximum_angular_rms_scaled")
for key in selected:
if key not in metrics:
continue
row = f"| `{key}` | {format_number(metrics[key])} |"
if control_directory:
row += f" {format_number(control_metrics.get(key, math.nan))} |"
lines.append(row)
lines.extend(["", "## Radial-profile diagnostics", "",
"| Field | Max absolute mean error / fixed scale | Max angular RMS / fixed scale |", "|---|---:|---:|"])
scales = reference_scales(rows)
for field, _, label, _ in FIELDS:
mean_error = finite_max(abs(number(row.get(field + "_mean_error_scaled"))) for row in rows)
scatter = finite_max(number(row.get(field + "_angular_rms")) / scales[field] for row in rows)
lines.append(f"| {label} | {format_number(mean_error)} | {format_number(scatter)} |")
incomplete = [row for row in rows if number(row.get("located_weight_fraction")) < 1.0 - 1e-10]
partial_material = [row for row in rows if 1e-10 < number(row.get("material_weight_fraction")) < 1.0 - 1e-10]
lines.extend(["", f"Shells with incomplete point-location coverage: **{len(incomplete)}**. "
f"Shells crossing the numerical material boundary: **{len(partial_material)}**.", "",
"Density/enthalpy means are conditional on located stellar material and finite values. "
"Missing samples and undefined exterior material fields are never zero-filled. "
"Angular RMS exposes nonspherical variation that a spherical mean can hide. "
"Origin data are single traces; radial gravity is undefined there. "
"Gravity is outward-positive ∇Φ, not inward acceleration.", ""])
if failed:
lines.extend(["## Failed declared screens", "", "| Metric | Observed | Maximum allowed |", "|---|---:|---:|"])
for row in failed:
lines.append(f"| `{row['metric']}` | {format_number(number(row['observed']))} | {format_number(number(row['maximum_allowed']))} |")
lines.append("")
lines.append("All plotted normalizations use the prescribed analytic reference. No radius, central density, or potential offset is fitted.")
(directory / "polytrope_summary.md").write_text("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
parser.add_argument("--control-directory", type=Path)
args = parser.parse_args()
rows = read_rows(args.directory / "radial_profiles.csv")
if not rows:
parser.error("radial_profiles.csv contains no rows")
metrics = read_metrics(args.directory)
checks = read_rows(args.directory / "verification_checks.csv")
control_rows = read_rows(args.control_directory / "radial_profiles.csv") if args.control_directory else []
control_metrics = read_metrics(args.control_directory) if args.control_directory else {}
make_figure(args.directory, rows, control_rows)
make_report(args.directory, rows, metrics, checks, args.control_directory, control_metrics)
print(args.directory / "polytrope_profiles.svg")
print(args.directory / "polytrope_summary.md")
if __name__ == "__main__":
main()