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:
301
experiments/GEOMETRY_QUALITY_EXPERIMENT.md
Normal file
301
experiments/GEOMETRY_QUALITY_EXPERIMENT.md
Normal 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 Gauss–Lobatto 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.
|
||||
237
experiments/GEOMETRY_QUALITY_FINDINGS_2026-09-08.md
Normal file
237
experiments/GEOMETRY_QUALITY_FINDINGS_2026-09-08.md
Normal 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.
|
||||
90
experiments/POLYTROPE_H_CONVERGENCE_2026-09-09.md
Normal file
90
experiments/POLYTROPE_H_CONVERGENCE_2026-09-09.md
Normal 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.
|
||||
377
experiments/POLYTROPE_VALIDATION.md
Normal file
377
experiments/POLYTROPE_VALIDATION.md
Normal 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 Lane–Emden 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.
|
||||
252
experiments/POLYTROPE_VALIDATION_FINDINGS_2026-09-09.md
Normal file
252
experiments/POLYTROPE_VALIDATION_FINDINGS_2026-09-09.md
Normal 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.3–1.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) |
|
||||
|
||||

|
||||
|
||||
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 Lane–Emden 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.
|
||||
482
experiments/compare_polytrope_refinement.py
Normal file
482
experiments/compare_polytrope_refinement.py
Normal 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())
|
||||
521
experiments/geometry_quality_diagnostics.hpp
Normal file
521
experiments/geometry_quality_diagnostics.hpp
Normal 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 ¢er = 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
|
||||
472
experiments/geometry_quality_experiment.cpp
Normal file
472
experiments/geometry_quality_experiment.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
155
experiments/polytrope_analytic_reference.hpp
Normal file
155
experiments/polytrope_analytic_reference.hpp
Normal 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
|
||||
221
experiments/polytrope_analytic_self_checks.hpp
Normal file
221
experiments/polytrope_analytic_self_checks.hpp
Normal 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
|
||||
395
experiments/polytrope_physical_state.hpp
Normal file
395
experiments/polytrope_physical_state.hpp
Normal 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
|
||||
435
experiments/polytrope_radial_profiles.hpp
Normal file
435
experiments/polytrope_radial_profiles.hpp
Normal 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
|
||||
445
experiments/polytrope_validation_experiment.cpp
Normal file
445
experiments/polytrope_validation_experiment.cpp
Normal 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;
|
||||
}
|
||||
339
experiments/polytrope_validation_measurements.hpp
Normal file
339
experiments/polytrope_validation_measurements.hpp
Normal 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 ¢er = 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
|
||||
96
experiments/refine_polytrope_mesh.py
Normal file
96
experiments/refine_polytrope_mesh.py
Normal 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()
|
||||
96
experiments/summarize_geometry_quality.py
Normal file
96
experiments/summarize_geometry_quality.py
Normal 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()
|
||||
287
experiments/summarize_polytrope_validation.py
Normal file
287
experiments/summarize_polytrope_validation.py
Normal 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(["", "",
|
||||
"## 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()
|
||||
Reference in New Issue
Block a user