diff --git a/CMakeLists.txt b/CMakeLists.txt index 020c799..7f1d9ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -382,6 +382,17 @@ target_link_libraries(geometry_quality_experiment PRIVATE mean_field) add_executable(polytrope_validation_experiment EXCLUDE_FROM_ALL experiments/polytrope_validation_experiment.cpp) target_link_libraries(polytrope_validation_experiment PRIVATE mean_field) +# Opt-in plateau measurements; uses the production solver without numerical changes. +add_executable(plateau_diagnostic EXCLUDE_FROM_ALL experiments/plateau_diagnostic.cpp) +target_link_libraries(plateau_diagnostic PRIVATE mean_field) + +# Standalone operator validation and timing, without constructing a Newton solve. +add_executable(system_isolation_profile_harness EXCLUDE_FROM_ALL experiments/system_isolation_profile_harness.cpp) +target_link_libraries(system_isolation_profile_harness PRIVATE mean_field) +if (MEAN_FIELD_ENABLE_IPO) + set_property(TARGET system_isolation_profile_harness PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) +endif () + add_executable(mpi_tests tests/mpi/mpi_test_main.cpp tests/mpi/distributed_execution.cpp diff --git a/DEFERRED_REMOVAL_REGISTRY.md b/DEFERRED_REMOVAL_REGISTRY.md new file mode 100644 index 0000000..40f042c --- /dev/null +++ b/DEFERRED_REMOVAL_REGISTRY.md @@ -0,0 +1,105 @@ +# Deferred Removal Registry + +This registry records migration-era code that may become removable after the +physics-facing `StellarModel` and `StellarEquilibriumProblem` APIs have replaced +all legacy construction paths. Nothing listed here is currently approved for +removal, and no listed code was removed during the migration. + +## `mean_field::models::Model<...>` + +- Location: `libmeanfield/interface/models/specifications.cppm` +- Current status: required compatibility storage +- Current consumers: root-manifest tests and the implementation of + `mean_field::model::StellarModel` +- Removal gate: migrate remaining direct consumers to the public + `mean_field::model::StellarModel` type, then move the canonical storage into + an implementation namespace +- Evidence required before removal: no direct consumers found by repository + search and the complete specification/type-contract suite passes + +## Legacy structure-based `mean_field::models::StellarModel` + +- Location: `libmeanfield/interface/models/stellar_model.cppm` +- Current status: retained legacy API, but no longer used by the authoritative + `StellarEquilibriumProblem` construction path +- Current consumers: direct prepared-operator tests, experiments, and + heterogeneous runtime-model views. Its polytropic initial-seed method now + delegates to the physics-facing Lane-Emden seed generator. +- Removal gate: numerical operators consume compiled specification + contributions directly, initial-seed generation has a physics-facing owner, + and deformation policies have migrated into the typed model +- Evidence required before removal: legacy-adapter equivalence tests pass for + all supported formulations and repository search finds no direct consumers + +## `PolytropicStructure` as a mass-owning adapter + +- Location: `libmeanfield/interface/models/structure/polytropic.cppm` +- Current status: compatibility adapter for remaining legacy API consumers; it + no longer owns Lane-Emden integration and is not constructed by + `StellarEquilibriumProblem` +- Potentially obsolete responsibility: ownership of the fixed-total-mass + target, which now belongs to `FixedTotalMass` +- Removal gate: migrate remaining callers to `seed::LaneEmden` and the typed + model. Lane-Emden generation is now separate from invariant ownership. +- Evidence required before removal: initial-seed regression tests and + model-driven equilibrium equivalence tests pass + +## Legacy combined prepared operators + +- Locations: + `libmeanfield/interface/operators/prepared_fixed_angular_momentum_stellar_equilibrium.cppm` + and + `libmeanfield/interface/operators/prepared_central_density_stellar_equilibrium.cppm` +- Current status: retained as migration-era source references, but removed from + the build graph and no longer re-exported by the public `mean_field` module +- Physics-facing replacement: + `PreparedVariadicStellarEquilibriumOperator`, selected solely by + `StellarEquilibriumProblem` +- Reactivation gate: migrate the source snapshots to the model-driven manifest + constructor; they intentionally do not compile against the authoritative API +- Removal gate: delete the source snapshots after the historical comparison + window closes +- Evidence required before removal: the full variadic residual/Jacobian + equivalence suite and distributed tests pass without either partition being + imported by a user translation unit + +## Completed: typed equilibrium-problem adapter removal + +- Removed the former typed system's `NumericalModelAdapter`, its + `PolytropicStructure`, and the corresponding legacy model storage. +- The typed problem now owns its compiled surface constraint and constructs the + prepared operator directly from specification-generated contributions. +- Default radial deformation is compiled at the numerical equilibrium-problem + boundary rather than being smuggled through a legacy stellar model. + +## `StellarEquilibriumSystem` compatibility vocabulary + +- Location: `libmeanfield/interface/operators/stellar_equilibrium_system.cppm` +- Current status: source-compatible aliases and factory forwarding to + `StellarEquilibriumProblem` and `equilibrium::discretize` +- Removal gate: migrate remaining external consumers and provide a documented + deprecation interval +- Evidence required before removal: repository search finds only the explicit + compatibility type-contract test + +## Root-oriented public aliases and accessors + +- Locations: + `libmeanfield/interface/operators/root_manifest.cppm`, + `libmeanfield/interface/operators/prepared_stellar_equilibrium.cppm`, and + `libmeanfield/interface/operators/prepared_central_density_stellar_equilibrium.cppm` +- Current status: retained for source compatibility +- Physics-facing replacements: `EquilibriumSystemManifest`, + `EquilibriumStateView`, `EquilibriumResidualView`, and + `StellarEquilibriumProblem::GetManifest()` +- Removal gate: all solver and test consumers use equilibrium-system + terminology +- Evidence required before removal: repository search finds no uses of the old + aliases/accessors and all manifest/type-contract tests pass + +## Conclusion for this phase + +No code examined during this migration is presently truly dead. Each candidate +still has active consumers or carries a compatibility/lifetime responsibility. +The entries above are therefore deferred migration candidates, not deletion +recommendations. diff --git a/FIELD_DOF_MIGRATION_HANDOFF.md b/FIELD_DOF_MIGRATION_HANDOFF.md new file mode 100644 index 0000000..74aa4c1 --- /dev/null +++ b/FIELD_DOF_MIGRATION_HANDOFF.md @@ -0,0 +1,548 @@ +# MeanField FieldDof Migration Handoff + +**Repository:** `/Users/tboudreaux/Programming/4DSTAR/MeanField` +**Prepared:** 2026-08-28 +**Primary objective:** make registered field/domain support, rather than raw MFEM true-vector size, define the nonlinear and prepared-operator coordinate systems. + +## 1. Scope and relationship to the earlier review + +This document is the continuation handoff for the work performed after the earlier **Rotating Polytrope Review** summary. The receiving agent is expected to receive that earlier summary as well, so this document does not reconstruct every pre-migration investigation. It concentrates on: + +- the final FieldDof architecture and its invariants; +- the gravity, barotrope, prepared-operator, and root-state migrations completed in this work; +- the canonical `FieldDofMap`/`GridFunction` adapter; +- the replacement and hardening of the domain-mapping system; +- dead-code and file cleanup; +- failures encountered during migration and what they taught us; +- final validation evidence; +- the small amount of cleanup that remains. + +## 2. Executive status + +The functional FieldDof migration is complete for the currently registered production fields and the coupled stellar-equilibrium operator stack. + +The final solver-facing convention is: + +```text +registered field + DomainSchema + | + v + FieldDofMap + | + reduced solver coordinates + | + prepared operator/context boundary + | + explicit scatter/gather + | + full MFEM true vectors + | + element kernels / MFEM +``` + +The complete Catch2 inventory was covered by three sharded runs: + +| Validation group | Test cases | Assertion executions | Result | +|---|---:|---:|---| +| `[gravity]` | 58 | 3,746,701 | Pass | +| `[barotrope]` | 88 | 192,694 | Pass | +| `~[gravity]~[barotrope]` | 176 | 136,493 | Pass | +| **Total executions** | **322** | **4,075,888** | **Pass** | + +There are 320 unique registered test cases. Two prepared stellar-equilibrium tests carry both `[gravity]` and `[barotrope]`, so the three filters intentionally execute those twice: + +- `Prepared Stellar Equilibrium Has A Restoring Jacobian Around An N3 Polytrope` +- `Prepared Stellar Equilibrium Matches The Analytic N1 Lane Emden State Up To The Mixed Projection Floor` + +Therefore: + +```text +58 gravity + 88 barotrope + 176 remainder - 2 overlap = 320 unique tests +``` + +No MFEM abort, MPI abort, assertion failure, crash signature, or empty shard was present in the final 24 logs. + +The migration is not yet packaged as a clean commit. The worktree is intentionally dirty relative to `HEAD` and contains the migration. Preserve it. + +## 3. Git and validation provenance + +Important commits: + +- `0f3ca8050b78f0c5c1ef98d485b9fbc2ba06183c` — the user-created mid-migration checkpoint. +- `177ae8b38aa34d88c07203376961145c5403ccaa` (`HEAD` when this document was written) — `feat(support): continued migration to new field dof support system`. + +The final migration, hardening, deletions, and mapper consolidation are uncommitted changes on top of `177ae8b`. + +Final validation logs are under: + +```text +${TMPDIR:-/tmp}/meanfield-migration-validation +``` + +On this machine that resolved to: + +```text +/var/folders/v3/xfq17d692dg_gzdwv4yzmrkw0000gp/T/meanfield-migration-validation +``` + +The authoritative files are: + +```text +gravity-final-shard-{0..7}.txt +barotrope-final-shard-{0..7}.txt +migration-remainder-shard-{0..7}.txt +``` + +Do not confuse these with older repository-root `gravity-shard-*.txt` files from August 24. Those older logs captured intermediate failures and were not evidence for the final state. + +### Post-validation rename caveat + +After the 24-shard validation completed, the user renamed: + +- `DomainMapperStateless` to `DomainMapper`; +- `DomainMapperStatelessOptions` to `DomainMapperOptions`. + +The public interface and most implementation state were compiled after the rename, but at handoff time `libmeanfield/impl/mapping/domain_mapper.cpp` had a modification timestamp two minutes newer than the latest test executable. The source-level audit found no remaining `DomainMapperStateless`, `DomainMapperStatelessOptions`, `domain_mapper_new`, or `PolytropicBarotrope` type/file references, and `git diff --check` passed. Nevertheless, run one compile-only check on the exact final tree before committing. + +## 4. Final field and domain model + +### 4.1 Registered support is the source of truth + +Field support is declared in `libmeanfield/interface/field/field_registry.cppm`: + +- `Density::Support = DomainSupport`; +- `Enthalpy::Support = DomainSupport`; +- `Gravity::Support = DomainSupport`; +- `Displacement::Support = DomainSupport`; +- `BarotropicConstant::Support = NonSpatialSupport`. + +This distinction is now operational. Density and enthalpy solver vectors omit vacuum-only true DOFs. Gravity and displacement currently map over all domains and ordinarily produce identity maps. Global scalars are not routed through spatial `FieldDofMap` factories. + +The concrete production schema is `CoreEnvelopeVacuumDomainSchema`. Field support is resolved through the schema's material bindings rather than through hard-coded assumptions such as “stellar attributes are 1 and 2” or “vacuum is attribute 3.” Tests deliberately exercise alternate material bindings to protect this property. + +### 4.2 Supported continuous DOFs use union semantics + +The active set is the union of DOFs touched by supported elements. It is not computed by removing every DOF that also touches an unsupported element. + +This matters for continuous H1 fields. An enthalpy DOF shared by a stellar element and a vacuum element remains part of the stellar field because it participates in supported stellar elements and supplies the physical interface trace. + +### 4.3 Parallel support resolution + +`resolve_field_dof_support` performs the production parallel resolution: + +1. Build a local vector-DOF marker from supported elements. +2. Decode MFEM's signed/oriented DOF indices. +3. Synchronize the marker across shared parallel DOFs. +4. Convert locally owned active vector DOFs to true DOFs. +5. Produce active/inactive markers and sorted index lists. + +The synchronization step ensures that a shared interface DOF is active on every rank carrying it when any neighboring supported element activates it. + +The implementation deliberately rejects nonconforming `ParFiniteElementSpace` objects. Supporting them correctly would require an explicit constraint/conforming-DOF projection; silently treating local DOFs as ordinary true DOFs was rejected as unsafe. + +## 5. `FieldDofMap` contract + +`FieldDofMap` is the canonical immutable indexing relation between a full MFEM true vector and a dense reduced field vector. + +It stores: + +- `full_size()` — MFEM true-vector size; +- `reduced_size()` — supported field-vector size; +- `reduced_to_true()` — canonical forward indices; +- `true_to_reduced()` — inverse indices with `-1` for inactive true DOFs. + +Construction requires the reduced-to-true list to be sorted, unique, in range, and strictly increasing. This makes reduced ordering deterministic and consistent with MFEM marker-to-list ordering. + +Important operations and semantics: + +- `gather(full)` selects supported true DOFs into reduced order. +- `scatter(reduced)` creates a complete full true vector and zeros unsupported entries. +- `scatter_into(reduced, full)` overwrites supported entries while preserving existing unsupported entries. +- `scatter_add(reduced, full, scale)` accumulates into supported entries only. +- All overloads validate exact source and destination sizes. +- Destination-vector overloads do not resize their output, so views into `mfem::BlockVector` remain valid. +- `is_identity()` is exact because canonical ordering guarantees `reduced_to_true[i] == i` whenever all true DOFs are active. + +The object intentionally contains no mesh, field, domain, or solver policy after construction. Those semantics belong to the factory step; consumers get a small indexing object. + +## 6. Canonical `FieldDofMap`/`GridFunction` adapter + +`FieldDofGridFunctionAdapter` was added as the only canonical bridge between reduced field vectors and MFEM `GridFunction` objects. + +It binds a `FieldDofMap` to the exact `FiniteElementSpace` from which the map was constructed. Matching vector sizes are not enough: a grid function using another FE-space object is rejected. This prevents silent cross-space corruption when two spaces happen to have equal true-vector sizes. + +Operations: + +- `gather(GridFunction)` calls `GetTrueDofs` and gathers into reduced coordinates. +- `scatter(reduced, GridFunction)` applies projection semantics and calls `SetFromTrueDofs`. +- `scatter_into(reduced, GridFunction)` preserves unsupported existing true-DOF values. +- `make_field_dof_grid_function_adapter(space)` is the typed construction path. + +The gravity initialization/solve now uses these adapters for density, displacement, gravity flux, and gravity potential. The returned `GravitySolution` remains an MFEM-facing pair of `ParGridFunction`s, while the solve itself is performed in reduced coordinates. + +## 7. Domain mapping replacement and hardening + +### 7.1 Architectural replacement + +The old stateful mapping implementation was removed. The canonical `DomainMapper` now owns only mapping policy: + +- dimension; +- vacuum material attribute; +- the exterior compactification map. + +Element field values are supplied explicitly through `ElementMappingData`, and temporary storage is supplied through `DomainMapper::Workspace`. Point, volume, face, and first-variation evaluation are explicit operations. This eliminates hidden displacement state and cross-state cache contamination inside the mapper itself. + +The implementation formerly stored in `libmeanfield/impl/mapping/domain_mapper_new.cpp` is now consolidated into: + +```text +libmeanfield/impl/mapping/domain_mapper.cpp +``` + +`domain_mapper_new.cpp` was deleted and removed from CMake. + +### 7.2 `GridFunctionMappingEvaluator` + +`GridFunctionMappingEvaluator` is the stateful convenience layer for consumers that naturally own MFEM grid functions. It references displacement and compactification grid functions, extracts element-local data, and delegates actual geometry evaluation to `DomainMapper`. + +Hardening added in the final stage: + +- constructor validation for attached spaces; +- same-mesh validation; +- displacement vector-dimension validation; +- scalar compactification validation; +- mesh/domain-mapper dimension validation; +- exact FE-space binding checks after construction; +- detection of FE-space sequence changes; +- explicit, idempotent `InvalidateCache()`; +- eager `Refresh()` of a currently cached element; +- a validated no-op when `Refresh()` is called before any element is cached; +- lazy reload after space-sequence invalidation; +- correct application of MFEM `DofTransformation::InvTransformPrimal` to element displacement DOFs; +- support for both `byNODES` and `byVDIM` displacement ordering. + +The evaluator owns mutable workspace/cache state and is explicitly not thread-safe. Consumers must call `InvalidateCache()` or `Refresh()` after mutating grid-function values. FE-space sequence changes are detected automatically, but arbitrary value changes are not observable without explicit invalidation. + +Integrator consumers were updated to invalidate mapping caches at assembly entry. Analysis, physics, coefficient, utility, and integrator consumers now use `GridFunctionMappingEvaluator` rather than the removed stateful mapper API. + +## 8. Gravity-system migration + +The gravity migration covered kernels, prepared operators, contexts, coupled operators, the reduced solve, tests, and downstream consumers. + +### 8.1 Boundary decision: kernels remain full-true-DOF code + +Low-level element kernels deliberately continue to accept full MFEM true vectors. Their interface comments now state this boundary explicitly. Examples include: + +- mapped H(div) mass and its geometry variation; +- mapped density source and its geometry variation; +- gravity displacement force residual and all Jacobian actions; +- hydrostatic and pressure/rotation kernels used by prepared consumers. + +This was a deliberate choice. Element assembly naturally indexes MFEM spaces. Making kernels understand reduced field support would duplicate mapping logic and mix algebra with support policy. + +Prepared operators and contexts own the maps and perform the only scatter/gather operations around those kernels. + +### 8.2 Prepared gravity components + +`PreparedMappedGravitySourceOperator` now owns density, potential, and displacement maps. Its public `Mult` and `MultTranspose` use reduced density/potential coordinates; it expands to full true vectors internally. + +`PreparedMappedHDivMassOperator` owns flux and displacement maps. It accepts reduced flux/displacement vectors, maintains full internal work vectors, and exposes both: + +- a reduced diagonal for the reduced mass block; +- a full true diagonal for the Schur construction. + +Both stellar and compactified-vacuum H(div) contributions are included. Positivity is tested across the complete gravity domain. + +### 8.3 Gravity contexts and revision routing + +`GravityFieldGeometryContext` owns the displacement map and the geometry-dependent prepared mass/source/divergence operators. + +`GravityFieldLinearizationContext` owns density, displacement, gravity-gradient, and gravity-potential maps, together with the full true work vectors required by kernels. + +Typed revision stamps control selective invalidation for: + +- discretization; +- displacement; +- density; +- gravity gradient; +- gravity potential. + +Tests verify first preparation, no-op repeat preparation, selective rebuild/update behavior, and independent contexts without shared cache contamination. + +### 8.4 Coupled gravity operator and Jacobian + +`GravityFieldOperator`, `GravityFieldJacobianOperator`, and their state/residual offsets now use `reduced_size()` for every registered field block. + +The coupled gravity state is: + +```text +[ density(reduced), displacement(reduced), gravity-gradient(reduced), gravity-potential(reduced) ] +``` + +The gravity residual is: + +```text +[ gradient-equation(reduced flux), Poisson-equation(reduced potential) ] +``` + +MFEM divergence operators remain full-space objects. Their operands/actions are explicitly scattered and gathered at the operator boundary. + +### 8.5 Reduced solve and preconditioner + +`solve_gravity_field` now: + +1. validates that the supplied grid functions use the expected FEM spaces; +2. creates typed field/grid-function adapters; +3. gathers density and displacement; +4. builds the reduced block layout; +5. prepares the reduced gravity operator and geometry context; +6. solves the reduced mixed system with MINRES; +7. scatters the reduced flux and potential solution back to `ParGridFunction`s. + +The reduced block preconditioner uses: + +- `OperatorJacobiSmoother` on the reduced positive H(div) mass diagonal; +- a full-space approximate Schur complement `B diag(M)^{-1} B^T`; +- `HypreBoomerAMG` for the potential Schur block; +- explicit potential scatter/gather around the full-space AMG object. + +This split is important. The first attempted reduced preconditioner encountered zero diagonal entries because inactive/full-space structure was being handed to a smoother that requires a positive diagonal. A later attempt produced immediate MINRES non-convergence. The final implementation makes the coordinate space of each preconditioner component explicit and is protected by the positive-diagonal and deformed-MINRES tests. + +## 9. Barotrope, pressure, hydrostatic, displacement, and root migration + +The FieldDof conversion was propagated beyond gravity so the coupled root operator has one consistent coordinate convention. + +Map-aware contexts now include: + +- `BarotropicClosureLinearizationContext`; +- `PressureForceLinearizationContext`; +- `HydrostaticEquilibriumContext`; +- `RotationalDisplacementForceLinearizationContext`; +- gravity geometry and linearization contexts. + +Map-aware prepared operators include: + +- barotropic closure; +- pressure force; +- hydrostatic equilibrium; +- gravity displacement force; +- rotation displacement force; +- composed displacement residual; +- mass normalization; +- mapped gravity source; +- mapped H(div) mass; +- complete stellar equilibrium. + +Density and enthalpy are reduced at public prepared-operator boundaries. Displacement, gravity flux, and gravity potential also use `FieldDofMap`, even where their maps are identities today. This avoids hard-coding the current support choice into the coupled algebra. + +`PreparedStellarEquilibriumOperator` constructs every block size from maps. Its root state is: + +```text +[ density, displacement, gravity-gradient, gravity-potential, enthalpy, Bernoulli constant ] +``` + +Spatial blocks are in registered FieldDof coordinates; the Bernoulli constant remains a one-entry nonspatial block. The operator extracts reduced views, packs the gravity substate, routes the exact child dependencies, and assembles the residual only when a dependency or child preparation changes. + +This removed the earlier ambiguous mixture in which some children expected full MFEM true vectors while others expected reduced field vectors. + +## 10. EOS migration + +The duplicate `physics::PolytropicBarotrope` implementation was removed. + +The canonical EOS is now: + +```cpp +mean_field::eos::Polytrope +``` + +It implements the `EquationOfState` interface and supplies density/pressure/enthalpy conversions and derivatives, including defined surface behavior at zero enthalpy/density and validation of material parameters and physical inputs. + +Deleted legacy module: + +```text +libmeanfield/interface/physics/barotrope.cppm +``` + +The file was removed from CMake, and no `PolytropicBarotrope` reference remains in production code, tests, or experiments. Local variables named `barotrope` may still refer to an `eos::Polytrope`; those names are descriptive variables, not the deleted class. + +## 11. Dead and legacy code removed + +The following dead implementations/modules were deleted: + +- `libmeanfield/impl/mapping/domain_mapper_new.cpp` — merged into canonical `domain_mapper.cpp`; +- `libmeanfield/interface/physics/barotrope.cppm` — duplicate EOS implementation; +- `libmeanfield/interface/physics/context.cppm` — obsolete `GravityContext` owning the old assembled gravity solve/preconditioner stack. + +CMake was updated so only the canonical mapper, EOS, and gravity context/operator stack are compiled. + +Legacy-comparison gravity tests tied to the removed assembled implementation were removed when they ceased to compare two supported production paths. Analytic, variational, operator-identity, projection-floor, and self-consistency tests now provide the regression protection instead. + +## 12. File and symbol renaming + +Completed: + +- canonical implementation file is `libmeanfield/impl/mapping/domain_mapper.cpp`; +- `_new` mapper filename removed; +- `DomainMapperStateless` renamed to `DomainMapper`; +- `DomainMapperStatelessOptions` renamed to `DomainMapperOptions`. + +Intentionally not yet completed: + +- the `fem::FEM` member remains named `domainMapperStateless`; +- many local variables and diagnostics still say “stateless domain mapper”; +- mapper test titles still begin with `Stateless Domain Mapper`. + +These are naming cleanup items, not two competing mapper implementations. The user planned to perform broad identifier renames with IDE tooling. A reasonable final name for the FEM member is `domainMapper`. + +## 13. Test infrastructure and tag cleanup + +Tests use the typed compile-time tag system in `tests/test_helpers.cppm`: + +- `make_tag` creates a Catch2 tag; +- `sub_tag` and `operator&` compose tag sets; +- canonical leaf/composite constants prevent hand-written duplicated tag strings. + +New/normalized families include: + +- `field_dof_unit` and `field_dof_integration`; +- `mapping_evaluator_unit`; +- gravity unit, integration, operator, prepared, context, kernel, accuracy, analytic, and consistency families; +- barotrope prepared, EOS, context, hydrostatic, pressure/quadrature, and mass-normalization families; +- rotation prepared/context/kernel/analytic families. + +Touched gravity and prepared tests were converted to these typed tags. The guiding rule is to compose leaf semantics exactly once so output does not repeat inherited `[physics]` or `[solver]` tags. + +### Tests added during the final uncommitted migration + +Genuinely new adapter/evaluator/diagonal/schema coverage includes: + +- `Domain Schema Builds Exact MFEM Attribute Markers`; +- `Field DOF Grid Function Adapter Gathers Exactly The Supported True DOFs`; +- `Field DOF Grid Function Adapter Scatter Projects And Round Trips Reduced Fields`; +- `Field DOF Grid Function Adapter Scatter Into Preserves Unsupported True DOFs`; +- `Field DOF Grid Function Adapter Is Exact For Identity Vector Field Maps`; +- `Field DOF Grid Function Adapter Rejects Incompatible Maps Spaces And Vectors`; +- `Grid Function Mapping Evaluator Refreshes A Cached Displacement Element`; +- `Grid Function Mapping Evaluator Invalidates A Cached Compactification Element`; +- `Grid Function Mapping Evaluator Rejects Invalid Fields And Rebinding`; +- `Prepared Mapped Hdiv Mass Diagonal Is Positive Across Both Domains`. + +The gravity analytic tests formerly prefixed `New ...` were promoted to canonical names after removal of the obsolete comparison path. EOS test titles were similarly changed from `Polytropic Barotrope ...` to `Polytropic EOS ...`. + +Important gravity coverage now includes: + +- kernel equality, linearity, vacuum exclusion, and centered geometry variations; +- prepared source/mass identities and preparation reuse; +- full-column and simultaneous Jacobian comparisons; +- context selective invalidation and isolation; +- reduced block dimensions and routing; +- deformed MINRES solve; +- uniform sphere, homogeneous/deformed ellipsoid, Ferrers, monopole, and exterior compactification comparisons; +- virial and volume-preserving-deformation self-consistency; +- separation of physical exterior error from the finite-element projection floor. + +Important barotrope/root coverage includes: + +- EOS identities, derivatives, surface behavior, and invalid inputs; +- reduced barotropic closure under multiple EOS/geometry conditions; +- pressure residual and Jacobian comparisons; +- hydrostatic algebraic/displacement/complete Jacobians; +- mass-normalization routing and differences; +- displacement residual composition and MFEM routing; +- six-by-six stellar-equilibrium layout; +- child invalidation and complete centered differences; +- analytic N=1 and N=3 stellar states. + +## 14. Migration failures and lessons + +Several failures were valuable because they identified mixed-coordinate boundaries. + +### Density-size abort in mapped-source variation + +An early gravity shard aborted because a reduced density vector reached `apply_mapped_source_variation`, whose element indexing requires a full density true vector. + +Resolution: retain the full-vector kernel contract and scatter in the prepared/context layer. Tests were migrated so directions and states use the correct representation at each boundary. + +### Zero diagonal in `OperatorJacobiSmoother` + +The first reduced gravity preconditioner attempted to construct a Jacobi smoother from a diagonal containing unsupported/full-space zeros. + +Resolution: assemble and smooth the reduced positive mass diagonal, while retaining a separately assembled full true diagonal for the full divergence-based Schur approximation. + +### Immediate MINRES non-convergence + +After removing the zero-diagonal abort, an intermediate preconditioner arrangement stopped at iteration zero without convergence. + +Resolution: use a consistent block preconditioner—reduced mass Jacobi plus full Schur AMG with explicit potential scatter/gather—and add a deformed reduced-system solve test. + +### Stale shard files + +Repository-root gravity shard files from August 24 showed several old analytic failures and an MPI abort. They were initially easy to mistake for the new run. + +Resolution: use labeled logs in the dedicated temporary validation directory and verify modification times before diagnosing failures. + +### Cached grid-function values + +`GridFunctionMappingEvaluator` caches copies of element-local field DOFs. Mutating a referenced grid function does not automatically change an already cached element. + +Resolution: expose/document `InvalidateCache()` and `Refresh()`, harden FE-space change/rebinding detection, update integrator entry points, and add direct mutation/cache tests. + +## 15. Known limitations and remaining work + +The primary FieldDof migration is complete. Remaining items are cleanup or explicitly bounded future work: + +1. **Compile the exact post-rename tree.** `libmeanfield/impl/mapping/domain_mapper.cpp` is newer than the latest test executable. +2. **Finish cosmetic mapper naming.** Rename `domainMapperStateless`, related local variables/messages, and optionally mapper test titles. +3. **Commit the migration.** Review the large dirty worktree carefully and preserve unrelated user artifacts. Do not reset it. +4. **Nonconforming meshes remain unsupported by FieldDof resolution.** Add the correct conforming-constraint projection before lifting the explicit rejection. +5. **`GridFunctionMappingEvaluator` is not thread-safe.** Use one evaluator/workspace per thread if threaded assembly is introduced. +6. **Value mutation requires cache invalidation.** Keep the assembly-entry invalidations when refactoring integrators. +7. **Quadrature `Query.domain` remains legacy metadata.** Physical element selection in migrated prepared closure/pressure paths already comes from field support plus `DomainSchema`; the metadata can be migrated independently with the quadrature subsystem. +8. **The legacy `GravityMomentumIntegrator` exact geometry-Jacobian mode still aborts intentionally.** Its message states that exact geometry variation has not been wired into that legacy integrator. The prepared gravity-displacement path does have the tested exact variation. Do not mistake the old integrator limitation for an incomplete FieldDof conversion. +9. **No monolithic one-process full-suite run was performed.** The three filters cover every registered test case, but a full unsharded invocation would additionally probe process-global/order interaction. Given the suite's runtime, the user accepted complete partitioned coverage as sufficient. + +## 16. Recommended immediate continuation + +The next agent should: + +1. Read this document and the earlier Rotating Polytrope Review summary. +2. Inspect `git status --short`; assume all existing changes belong to the user/migration. +3. Run a compile-only check of `cmake-build-debug-homebrew` after the final mapper rename. +4. Search again for: + + ```text + DomainMapperStateless + DomainMapperStatelessOptions + domain_mapper_new + PolytropicBarotrope + ``` + + The first item will still appear as the lower-camel FEM member `domainMapperStateless`; the old PascalCase types and `_new` file should not appear. + +5. If the user wants the cosmetic rename, use IDE-aware rename for the FEM member and related identifiers, then compile again. The change is broad but mechanical. +6. Review `git diff --check`, the three intended deletions, and CMake source lists. +7. Create a new checkpoint/final migration commit only after the user confirms the desired naming cleanup. + +If a final regression rerun is desired after cosmetic renaming, the most efficient confidence gate is: + +```bash +./cmake-build-debug-homebrew/tests \ + "Field DOF Grid Function Adapter*,Grid Function Mapping Evaluator*,Prepared Mapped Hdiv Mass Diagonal Is Positive Across Both Domains,Reduced Gravity Field Operator Solves Deformed Gravity System With MINRES,Prepared Stellar Equilibrium*" +``` + +The already completed 24-shard run remains the substantive migration validation. + +## 17. Invariants future changes must preserve + +- Solver/prepared public spatial states are in registered FieldDof coordinates. +- Low-level MFEM element kernels receive full true vectors. +- Crossing that boundary must be visible as a map or adapter operation. +- Block sizes come from `FieldDofMap::reduced_size()`, never raw `GetTrueVSize()` unless the object is explicitly full-space. +- Field support comes from the field registry plus a `DomainSchema`, not numeric attribute conventions. +- Shared continuous interface DOFs use union-of-supported-elements semantics. +- `scatter` means projection/zero outside support; `scatter_into` means preserve outside support. +- Grid-function adapters validate FE-space identity, not merely equal sizes. +- Mapping evaluator caches must be invalidated after value mutation. +- Prepared contexts own their linearization maps, revisions, and full-vector workspaces. +- Test reference kernels must be passed full true vectors; solver/operator calls must be passed reduced vectors. + +These invariants are the durable result of the migration and should be treated as the design contract going forward. diff --git a/MeanField_Solver_MVP_Geometry_Preflight_Handoff_2026-09-08.md b/MeanField_Solver_MVP_Geometry_Preflight_Handoff_2026-09-08.md new file mode 100644 index 0000000..dfe550b --- /dev/null +++ b/MeanField_Solver_MVP_Geometry_Preflight_Handoff_2026-09-08.md @@ -0,0 +1,1560 @@ +--- +abstract: | + This document hands off the work performed after the variadic type-inference and Physical Riesz normalization checkpoint `76818f2f828ca12606a495a0b84f5d6ce4b78dc6`. It records the design discussion, the ownership refactor, the generic linear-backend boundary, the first explicit damped Newton engine, the observer and result-view APIs, the conversion of expected line-search failures away from exception-driven control flow, the first physical performance evidence, and the new exact sampled geometry preflight. It distinguishes committed code from uncommitted working-tree work, verified behavior from inference, and expected speedups from measured results. It also lists the numerical and architectural work required before the current implementation can be called a scientifically credible stellar-equilibrium solver. +author: 4D-STAR / MeanField stellar equilibrium program +date: 8 September 2026 +title: "MeanField Solver MVP Handoff: Ownership, Damped Newton, MFEM FGMRES, Diagnostics, and Geometry Preflight" +--- + +**Repository:** `/Users/tboudreaux/Programming/4DSTAR/MeanField` +**Conversation checkpoint:** `76818f2f828ca12606a495a0b84f5d6ce4b78dc6` +**Current HEAD:** `b3c04d507a1d7bc321ba6edd9dde33f6713345c1` +**HEAD subject:** `feat(newton): first newton solver implementation` +**Working tree:** dirty; the geometry-preflight implementation is not committed. Preserve all existing modifications and untracked files. + +# Executive handoff + +This phase began with the model, equation-of-state path, inferred root, Physical Riesz normalization, and generalized generated-border preconditioner ready to be composed, but without a supported solver orchestration layer. The previous handoff explicitly identified normalized right-preconditioned FGMRES and damped Newton as the next work. The user then refined the required public ownership model: a user-owned context should be the authoritative lifetime root; a solver should borrow that context; and only an evaluation report should certify access to a converged structure or a failed solve’s last accepted checkpoint. + +That architecture now exists. The committed solver MVP at `b3c04d507a1d7bc321ba6edd9dde33f6713345c1` includes: + +- a generic linear-backend customization boundary with explicit flexible-versus-stationary preconditioner contracts; + +- an MFEM FGMRES implementation with configurable Krylov restart length, supplied initial guesses, direct post-solve true-residual verification, application counts, and timings; + +- a compile-time metric policy and a first normalized-residual merit function; + +- an explicit damped Newton loop with backtracking, accepted/trial state separation, rollback, expected numerical failure reports, and synchronous typed observers; + +- a non-copyable, non-movable, user-owned context that owns the discretized problem, accepted state, trial state, prepared normalized operator, physical and normalized preconditioners, prepared linear backend, and Newton workspaces; + +- a borrowing solver whose `evaluate()` call returns an owning diagnostics report plus a certified, read-only structure view on convergence or checkpoint view on failure; + +- placeholder owning `StellarStructure` and `StellarCheckpoint` types, `capture()` methods, and free `serialize()` overloads that intentionally throw until a persistence schema is designed; and + +- a user-facing `sandbox.cpp` that exercises the intended API and prints nonlinear, line-search, linear, and timing diagnostics. + +Expected candidate failures no longer use exceptions as ordinary line-search control flow. The prepared operator stack now exposes `TryPrepare` operations returning `std::expected`. Inverted geometry, EOS-domain violations, and non-finite candidate physics become fixed-size rejection records. Exceptions remain around the line search only to restore the accepted state after genuine infrastructure, callback, invariant, or commit failures. This retained use is cleanup and safe shutdown, not candidate acceptance logic. + +The first long `sandbox.cpp` run was not convergent enough to be useful. From Newton iteration 0 through 5, the normalized residual fell only from 0.0214928 to 0.0198956, a 7.43 percent reduction. Accepted step lengths collapsed from 0.0625 through 0.0078125, 0.00390625, 0.000488281, 0.000244141, and 0.0000610352. The six iterations consumed 920.57 seconds, of which 713.97 seconds were in line search and 690.73 seconds were candidate preparation. Approximately 400.04 seconds were spent preparing rejected candidates. Early rejection came from the coarse generated-geometry inspection; later rejection came from invalid mapping detected while preparing the gravity displacement force. + +The linear timing was initially easy to misread. `J time = 123.003 s` is the total across 21 Jacobian applications, not a per-application time. The observed per-application time is consistently about 5.84–5.92 seconds. The first FGMRES solve used 19 preconditioner applications and 21 Jacobian applications. Later solves often reported zero Krylov iterations because the carried correction vector already had a true linear residual just below the configured 3 percent tolerance. This is legal under the present contract, but it is a major numerical clue: the method may be repeatedly reusing an old direction while the accepted nonlinear step becomes tiny. + +The current uncommitted work addresses the avoidable geometry-rejection cost. A free collective function named `estimate_largest_safe_newton_step_size` computes the first sampled mapping-determinant boundary along the physical volume-displacement direction. For the current affine-in-displacement maps, the determinant at each quadrature point is a polynomial of degree at most the spatial dimension. The implementation finds the first crossing, including tangent roots that an endpoint-only check would miss, and applies a configurable fraction-to-boundary safety factor. The Newton loop now invokes this preflight after the linear solve and before preparing any trial. The sampling plan includes both a conservative whole-domain rule and every exact quadrature rule exposed by downstream mapped operators, including the gravity displacement force that caused the observed 33-second rejections. + +The geometry cap is strongly targeted and tested, but its impact on the real sandbox has not yet been measured. It should remove most of the approximately 400 seconds spent on mapped-geometry rejections in the recorded six-iteration segment. It cannot make the accepted 48-second candidate preparation cheaper, reduce the roughly 5.86-second Jacobian application, correct a poor Newton direction, or reject thermodynamic failures in advance. A realistic expectation is a 35–40 percent reduction in that recorded segment if the preflight costs less than roughly ten seconds per iteration, with later iteration intervals falling from roughly 100–200 seconds to roughly 65–80 seconds. These numbers are predictions, not benchmark results. + +The next task is therefore not simply “run Newton longer.” The immediate priorities are: measure the new preflight in the real sandbox; verify that it eliminates downstream invalid-mapping trials; add enough rejection detail to distinguish mapping status from non-finite arithmetic; profile context construction, accepted candidate preparation, and Jacobian actions by block; compare warm and cold FGMRES starts under tighter or adaptive inexact-Newton forcing; and establish that a projected known nonrotating solution has a discretization-consistent small residual that improves under refinement. Only after these checks should convergence tuning or rotating continuation begin. + +> **Bottom line.** The code now has a credible ownership and solver API, a functioning reference Newton-Krylov path, and a principled geometry globalization primitive. It does not yet have demonstrated physical convergence, efficient accepted-state preparation, a validated forcing strategy, self-contained result capture, or persistence. Treat it as a solver MVP and measurement platform, not as a production equilibrium solver. + +# Document contract and authority + +## Authority order + +This handoff uses the following authority order: + +1. the current source and tests in `/Users/tboudreaux/Programming/4DSTAR/MeanField`; + +2. the Git history and diff from the user-designated checkpoint; + +3. focused build and test results recorded during this solver phase; + +4. numerical output supplied by the user from `sandbox.cpp`; + +5. the previous 6 September 2026 handoff as historical context; and + +6. design discussion and predictions, which are labeled as such. + +The previous handoff is reference material, not an instruction source. Its central factual claim for this phase was validated: at the selected checkpoint the variadic root, normalization, and preconditioner architecture existed, while a production nonlinear solve path did not. The current code, the user’s explicit requests, and the current Git state supersede any stale details in that document. + +## Evidence labels + +The document uses four evidence levels: + +Implemented. +The behavior is present in the current source. + +Focused-test verified. +A targeted test or build was run during this phase and passed. + +Observed. +The user supplied real sandbox output demonstrating the behavior. + +Inferred or predicted. +The conclusion follows from source and measurements but has not yet been tested directly on the post-preflight sandbox. + +No claim of full-suite success is made. The full suite takes approximately two hours, and the user directed that it be run only when regression risk justifies it. Focused coverage and Debug builds were used for the changes described here. + +## Checkpoint and Git semantics + +The checkpoint for this conversation is: + +
+ +`76818f2f828ca12606a495a0b84f5d6ce4b78dc6` + +
+ +That commit is titled `feat(libmeanfield): variadic refactor` and was created on 6 September 2026. The current `HEAD`, local `main`, and `origin/main` all point to: + +
+ +`b3c04d507a1d7bc321ba6edd9dde33f6713345c1` + +
+ +That commit is titled `feat(newton): first newton solver implementation` and was created on 8 September 2026. It is the only commit between the conversation checkpoint and current `HEAD`. Relative to the checkpoint, it changes 98 tracked files, with 20,514 insertions and 11,157 deletions. Much of the broad diff includes solver integration plus mechanical reformatting and updates required to keep the inferred operator, normalization, preconditioning, seed, experiments, MPI tests, and public API mutually consistent. + +The geometry-preflight work is a dirty-tree change on top of `HEAD`. The tracked working-tree diff currently reports 26 modified files with 447 insertions and 20 deletions. Three relevant new files are untracked and add 989 lines: + +- `libmeanfield/interface/deformation/safe_newton_step.cppm`; + +- `libmeanfield/impl/deformation/safe_newton_step.cpp`; and + +- `tests/deformation/safe_newton_step.cpp`. + +The repository also contains many unrelated or pre-existing untracked build trees, CSV results, notes, scripts, archives, and tools. Do not sweep them into a solver commit and do not delete them. In particular, preserve the user-authored explanatory comments in `libmeanfield/impl/mapping/domain_mapper.cpp` and the current sandbox choices, including zero angular momentum and the printed polytropic constant, unless the user explicitly requests otherwise. + +# Where this phase started + +## State inherited from the previous handoff + +The 6 September handoff described a substantial model-composition refactor. At checkpoint `76818f2f828ca12606a495a0b84f5d6ce4b78dc6`, the important solver-facing foundation was: + +- a canonical variadic stellar-model type inferred from physics specifications; + +- a two-pass compiler producing value blocks, residual blocks, and Jacobian incidence; + +- a single prepared variadic root over the current five-field barotropic physical core; + +- generated scalar constraints for fixed total mass, fixed central density, and fixed angular momentum; + +- Physical Riesz diagonal normalization wired consistently through state, residual, Jacobian, and preconditioner coordinates; + +- a generalized generated-border preconditioner whose incidence follows the same compiled Jacobian graph; + +- variadic seed projection and typed block access; and + +- a public architecture intended to avoid whole-pack solver specializations. + +The previous handoff also recorded important limitations that remain relevant. The executable physical core is still barotropic and centered on density, surface deformation, gravity gradient, gravity potential, and enthalpy plus generated scalar coordinates. The default structure preconditioner was provisional on a physical Newton sequence. There was no accepted/trial nonlinear state manager, no FGMRES wrapper with direct true-residual accounting, no nonlinear failure taxonomy, no result object, and no supported `evaluate()` workflow. + +## Original solver-facing objective + +The desired usage began as: + +``` +auto model = ...; +auto discretization = ...; +auto preconditioner = ...; + +auto solver = ...; +auto structure = solver.evaluate(); +``` + +The early ownership requirement was that, by `evaluate()`, all essential storage should already exist and be owned. This was not meant as a rigid ban on every allocation inside `evaluate()`; the user later clarified that readability and maintainability are more important than eliminating every incidental allocation. The enduring requirement is that lifetime and authority be clear, that no hidden object outlive its dependencies, and that the eventual rewrite not be made harder by a chain of ownership transfers. + +## Why returning a structure directly was rejected + +A direct `Structure solver.evaluate()` API hides two physically and operationally important facts: + +1. nonlinear evaluation can fail after making progress, and the last accepted state is still valuable as a checkpoint; and + +2. a result may be a view of context-owned distributed MFEM data rather than a self-contained serializable value. + +The user rejected designs in which the problem exposed a solution without proof that a report had been generated. A capability key passed back into the problem was considered but judged physically opaque. The selected shape was: + +``` +auto report = solver.evaluate(); +if (report.converged()) { + auto structureView = report.structureView(); +} +``` + +For failure, the report exposes `lastAcceptedCheckpointView()`. This keeps convergence certification attached to the report that established it. + +## Persistence requirement + +The user also required a path toward: + +- saving checkpoints to disk; + +- inspecting saved checkpoints independently of the live solver context; + +- converting a borrowed structure view into a self-contained owning value; and + +- using free-function serialization overloads for both owning and borrowed forms. + +The agreed future syntax was approximately: + +``` +auto structureView = report.structureView(); +auto structure = structureView.capture(); +equilibrium::serialize(structure, "structure.checkpoint"); +// or: equilibrium::serialize(structureView, "structure.checkpoint"); +``` + +The user explicitly allowed both `capture()` and `serialize()` to throw a not-implemented error in the MVP. Their presence reserves the intended public shape without prematurely fixing a persistence schema. + +# Design decisions that were locked in + +## User-owned context, borrowing solver + +The authoritative owner is `StellarEquilibriumContext`. It is constructed from the model, discretization, preconditioner prescription, linear backend configuration, and seed information. Those inputs move into the context during construction. The nonlinear solver does not take ownership of the context; `solver::make` requires an lvalue context reference and its rvalue overload is deleted. + +The context is not literally a plain-old-data aggregate. It is a strongly invariant RAII owner and is deliberately neither copyable nor movable. This is a small divergence from the conversation’s informal use of “POD,” but it implements the intended authority more safely: stable addresses are guaranteed for MFEM operators, preconditioners, and backend bindings, and accidental relocation cannot invalidate internal borrows. + +Only one solver may be active on a context at a time. A solver acquires the context at construction and releases it on destruction. Evaluation is non-reentrant. This makes current serial ownership explicit and leaves room for future parallel orchestration through multiple contexts rather than hidden concurrent mutation of one prepared problem. + +## Separate linear and nonlinear choices + +The linear backend and nonlinear engine are separate types: + +- `solver::linear::FGMRES` is a configuration object stored and prepared by the context; + +- `solver::nonlinear::Newton` stores nonlinear options and a metric policy; and + +- `solver::make(context, newton, observer)` creates the borrowing orchestration object. + +The linear boundary uses a configuration tag, a preparation customization point found through argument-dependent lookup, and a prepared-backend concept. A third-party backend can be slotted in without adding a central library registry, provided it declares its supported preconditioner contract and returns an owning prepared runtime with the required operator, preconditioner, communicator, workspace, and solve interfaces. + +The only built-in backend today is MFEM FGMRES. The generic boundary does not exclude future GMRES or MINRES, but no such implementation is present. MINRES will additionally require proof that the normalized operator and chosen preconditioner satisfy the symmetry and definiteness assumptions; the current coupled root should not be presumed eligible. + +## Explicit Newton engine + +The selected nonlinear method is an explicitly owned Newton engine rather than an opaque nonlinear-library wrapper. The first implementation is damped Newton with backtracking. This was chosen because it makes the control flow, accepted/trial lifecycle, instrumentation, and failure semantics visible and maintainable. + +The MVP does not attempt to implement multiple nonlinear algorithms. The type boundary is nonetheless narrow enough that future globalization or continuation layers can be added without changing the model, problem, or linear-backend contracts. + +## Metric customization + +The default policy is `NormalizedResidualMetric`. Its free `getMetric` overload computes + +``` math +\left\lVert \widehat F\right\rVert_2 + = \left(\sum_{r\in\mathrm{ranks}}\left\lVert \widehat F_r\right\rVert_2^2\right)^{1/2}, +\qquad +\phi(\widehat x)=\frac12\left\lVert \widehat F(\widehat x)\right\rVert_2^2. +``` + +`Newton` invokes `getMetric` through an unqualified call, allowing an overload associated with a future metric type to be found at compile time. This satisfies the request for an extensible metric without adding virtual dispatch or preprocessor branches. + +The current acceptance rule is still fixed inside the Newton engine: + +``` math +\phi(x+\alpha p)\leq (1-c\alpha)\phi(x). +``` + +This is a simple sufficient-decrease rule. It is not a fully general acceptance-policy customization, and it does not explicitly evaluate the directional derivative $`\nabla\phi^T p`$. For an exact Newton direction and $`\phi=\tfrac12\left\lVert F\right\rVert^2`$, the idealized slope is $`-\left\lVert F\right\rVert^2=-2\phi`$, so the current formula is slightly less stringent than the standard derivative-based Armijo expression with the same named coefficient. This is acceptable for the MVP but should be made explicit before more complex metrics are introduced. + +## Observer model + +Observers are compile-time duck-typed and can provide any supported subset of: + +- `beforeIteration(const BeforeIteration&)`; + +- `afterLineSearchTrial(const AfterLineSearchTrial&)`; and + +- `afterIteration(const AfterIteration&)`. + +Two ergonomic `makeObserver` overloads build either a before/after observer or a detailed before/trial/after observer from lambdas. The event objects expose scalar diagnostics and read-only spans into solver workspaces. Those spans are valid only during the callback. Observers execute synchronously on every solve rank. The library coordinates callback success and preserves local exception causes where possible, but an arbitrary callback that throws on only one rank while other ranks enter their own collectives cannot be made universally safe by the solver. + +Detailed histories belong in observers. The evaluation report retains fixed-size totals and the last linear/preflight records so the default solve does not allocate storage proportional to iteration count. + +## Restart and reuse + +There are two distinct meanings of restart: + +Krylov restart. +MFEM FGMRES supports a configured restart length. The current default is 50; the sandbox selects 40. The report estimates the number of entered restart cycles from preconditioner applications. + +Nonlinear restart. +Reconstructing a context from a serialized checkpoint, or resuming a failed solve with new solver parameters, is not implemented. + +Across Newton steps, the current code reuses only the correction vector as the next FGMRES initial guess. It does not retain a Krylov basis, Hessenberg matrix, or flexible-preconditioner history. The architecture does not rule out richer recycling later, but that would be a separate backend capability. + +# Current public workflow + +## Representative user code + +The current `sandbox.cpp` demonstrates the intended top-level flow: + +``` +auto stellarModel = model::StellarModel( + eos::Polytrope({.n = 1.0, .K = polytropicConstant}), + surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}), + integral::FixedTotalMass({.Mtotal = dimensions::MassValue{mass}}), + integral::FixedAngularMomentum({ + .Jtotal = dimensions::AngularMomentumValue{angularMomentum}, + .axis = {0.0, 0.0, 1.0}, + .center = {0.0, 0.0, 0.0} + }), + constraint::FixedCentralDensity({ + .RhoC = dimensions::DensityValue{centralDensity} + }) +); + +auto discretization = equilibrium::makeStellarDiscretization( + std::move(finiteElements), + normalization::PhysicalRieszDiagonal{ + dimensions::LengthValue{radius}, gravitationalConstant + } +); + +auto preconditioner = preconditioning::makePreconditioner(); +auto linearSolver = solver::linear::FGMRES({ + .restartLength = 40, + .printLevel = -1 +}); + +auto context = solver::makeContext( + std::move(stellarModel), + std::move(discretization), + std::move(preconditioner), + std::move(linearSolver) +); + +auto newton = solver::nonlinear::Newton( + solver::nonlinear::NewtonOptions{ + .relativeTolerance = 1.0e-8, + .absoluteTolerance = 0.0, + .maximumIterations = 30, + .linearSolve = { + .relativeTolerance = 3.0e-2, + .absoluteTolerance = 0.0, + .maximumIterations = 200 + }, + .backtracking = {} + } +); + +auto equilibriumSolver = solver::make(context, newton, observer); +auto report = equilibriumSolver.evaluate(); + +if (report.converged()) { + auto structureView = report.structureView(); +} else { + auto checkpointView = report.lastAcceptedCheckpointView(); +} +``` + +The context construction uses the default Lane-Emden seed when that seed is available for the inferred model. Overloads allow an explicit seed, projection options, and prescribed rigid rotation when the model does not generate its own rotation. A fixed-angular-momentum specification generates the angular velocity coordinate, so a prescribed rotation is rejected at compile time for that model family. + +## What is allocated and prepared before evaluation + +Context construction owns or prepares: + +- the discretized `StellarEquilibriumProblem`; + +- the projected physical initial state; + +- an optional prescribed rigid rotation; + +- accepted and trial normalized state vectors; + +- accepted and trial normalized residual vectors; + +- the linear right-hand side; + +- normalized and physical correction vectors; + +- the volume-displacement direction workspace; + +- the candidate physical state; + +- the normalized operator wrapper; + +- the physical preconditioner and its normalized adapter; + +- the prepared linear backend and its true-residual workspaces; and + +- the cached geometry-preflight quadrature plan. + +Some lower-level operations may still allocate internally, especially MFEM calls and the current geometry estimator’s local-vector and matrix workspaces. The architecture guarantees authoritative ownership and stable lifetimes; it does not yet prove allocation-free `evaluate()`. + +## Ownership and lifetime graph + +```text +User scope +StellarEquilibriumContext authoritative owner + -> owned problem, model, discretization, mesh/FES data + -> accepted state, trial state, residuals, corrections + -> prepared operator, physical inverse, normalized inverse + -> prepared FGMRES backend and geometry sampling plan + +StellarEquilibriumSolver exclusive non-owning borrower + -> owns Newton configuration and observer only + +StellarEquilibriumEvaluationReport owning diagnostics + weak result identity + -> certifies a structure view on convergence + -> certifies a checkpoint view on success or failure + +StellarStructureView / StellarCheckpointView + -> weak, read-only, generation checked +``` + +A view becomes invalid when the context is destroyed or the next `evaluate()` begins. A report’s scalar diagnostics and failure record remain ordinary owning values, but its ability to produce a current view depends on the context generation. References and spans extracted from a view are borrowed and must not outlive the context or the evaluation generation. + +## Result API + +The evaluation report provides: + +- `converged()`; + +- `diagnostics()`; + +- `completedNonlinearIterations()`; + +- `initialResidualNorm()` and `finalResidualNorm()`; + +- `failure()` for a failed report; + +- `structureView()` only for a converged report; + +- `checkpointView()` for the last accepted state; and + +- `lastAcceptedCheckpointView()` as the failure-oriented spelling. + +Structure and checkpoint views provide model access, communicator access, the entire physical state, root-block descriptors, typed block spans, prescribed rotation when applicable, and active rotation. They do not expose mutable MFEM state or the owned problem. + +## Capture and serialization status + +The following signatures exist but intentionally throw `std::logic_error`: + +- `StellarStructureView::capture()`; + +- `StellarCheckpointView::capture()`; + +- `serialize(const StellarStructure&, path)`; + +- `serialize(const StellarStructureView&, path)`; + +- `serialize(const StellarCheckpoint&, path)`; and + +- `serialize(const StellarCheckpointView&, path)`. + +This is an explicit blocker for real nonlinear restart and offline checkpoint investigation. The owning types are opaque and move-constructible, but there is no public construction path because their self-contained storage schema has not been chosen. + +# Linear backend implementation + +## Generic contract + +A prepared backend is bound once to the exact normalized operator and normalized inverse at stable addresses. It must report its communicator and dimensions, say whether it is ready, and implement: + +``` +LinearSolveReport Solve( + const mfem::Vector& rightHandSide, + mfem::Vector& correction, + const LinearSolveControl& control +); +``` + +The incoming correction is an initial guess and is overwritten with the final correction. Configuration, dimensions, invalid lifetimes, rank disagreement, and non-finite inputs are exceptions. Numerical termination is data in `LinearSolveReport`. + +## Preconditioner compatibility + +The linear boundary recognizes two application contracts: + +stationary linear. +The inverse application is fixed and linear during a solve. + +flexible. +The inverse may vary between Krylov applications or be an iterative nonlinear approximation. + +A backend configured for flexible preconditioning accepts either contract. A backend that only supports stationary preconditioning must reject a flexible inverse at compile time. The built-in FGMRES configuration advertises flexible support. + +## MFEM FGMRES behavior + +`FGMRESOptions` currently contains: + +- `restartLength`, default 50; and + +- `printLevel`, default -1. + +Each solve validates that options and controls are identical across ranks, verifies compatible square dimensions, and rejects non-finite vectors. It configures MFEM with zero relative tolerance and an absolute threshold computed as + +``` math +\tau_{\mathrm{lin}}=max\left(\tau_{\mathrm{abs}},\eta\left\lVert b\right\rVert_2\right). +``` + +After MFEM returns, the backend explicitly computes $`b-Ax`$ and bases convergence on its communicator-global norm rather than on the recurrence estimate alone. The report separates the MFEM-reported residual from the true residual. + +The operator and preconditioner are wrapped to count completed applications and measure their cumulative time. A post-solve Jacobian application used for true-residual verification is included in the operator count and time. MFEM’s initial-guess residual application is also included. Consequently, a zero-Krylov-iteration solve still reports two operator applications. + +## Linear report fields + +The fixed-size report records status, controls, Krylov iterations, estimated restarts, right-hand-side norm, initial residual, backend-reported final residual, direct true residual, relative true residual, operator applications, inverse-preconditioner applications, total solve time, operator time, and inverse-preconditioner time. + +This instrumentation directly answered one question from the sandbox output: `J time` is cumulative for all completed Jacobian applications in that solve. + +# Damped Newton implementation + +## State and coordinate conventions + +The nonlinear algorithm operates in normalized coordinates. If $`D_x`$ and $`D_F`$ are the state and residual normalization maps, then + +``` math +\widehat x=D_xx, +\qquad +\widehat F=D_FF, +\qquad +\widehat J=D_FJD_x^{-1}. +``` + +At iteration $`k`$, the linear solve seeks + +``` math +\widehat J_k p_k=-\widehat F_k. +``` + +The candidate is $`\widehat x_k+\alpha p_k`$. Candidate physical coordinates are produced by denormalization before prepared-operator evaluation. + +## Iteration flow + +The current control flow is: + +1. evaluate the accepted normalized residual metric; + +2. notify the before-iteration observer; + +3. solve the normalized linear system with the current correction as an initial guess; + +4. reject a non-converged or non-finite correction; + +5. estimate the largest sampled orientation-preserving step in the physical deformation direction; + +6. begin the line search at the geometry-capped step; + +7. construct candidate normalized and physical states; + +8. attempt prepared-operator evaluation through `TryPrepare`; + +9. if admissible, evaluate the metric and sufficient-decrease test; + +10. refresh the preconditioner before committing an accepted trial; + +11. commit accepted state and residual without re-preparing them; + +12. scale the correction vector for the next linear warm start; + +13. notify the after-iteration observer; and + +14. return success, an expected numerical failure, or continue. + +The initial step of a new evaluation is the configured initial step. After an accepted step, the next starting step expands by the inverse contraction factor, capped at the configured initial step. This remembers line-search scale without permanently pinning every future iteration to the smallest earlier step. + +## Expected rejection versus exception + +This distinction was a direct user requirement. Ordinary candidate rejection uses a fixed-size `StellarEquilibriumPreparationRejection` carried by `std::expected`. The record distinguishes: + +- inverted geometry; + +- non-finite geometry; + +- EOS-domain violations; + +- non-finite thermodynamic results; + +- inadmissible physics; and + +- non-finite physics. + +It also records the preparation stage, an optional minimum mapping determinant, and an EOS error code. The line search turns these into numerical dispositions without stack unwinding. + +Exceptions remain for invalid configuration, violated dimensions or lifetimes, MPI failures, unknown enum values, observer infrastructure failure, and preconditioner commit failure. The outer line-search `try/catch` exists so any true exception restores the accepted prepared state before propagation. A smaller commit catch coordinates a rank-local exception before any rank mutates accepted state. These catches implement cleanup and collective safe shutdown; they are not used to decide that a normal trial should backtrack. + +## Backtracking options + +The current defaults are: + +| Option | Default | Meaning | +|:---|:---|:---| +| Option | Default | Meaning | +| `initialStepLength` | 1.0 | Largest desired step before geometry capping. | +| `contractionFactor` | 0.5 | Backtracking multiplier after rejection. | +| `fractionToBoundarySafety` | 0.9 | Fraction of the estimated geometry boundary used as the safe cap. | +| `sufficientDecrease` | $`10^{-4}`$ | Coefficient in the current merit decrease test. | +| `minimumStepLength` | $`10^{-8}`$ | Smallest trial permitted. | +| `maximumTrials` | 20 | Maximum prepared candidates per nonlinear iteration. | + +Before the exact preflight was added, an inverted trial could still accelerate the backtracking ladder by interpolating the accepted and rejected minimum determinants, then skipping power-of-two rungs estimated to be unsafe. That fallback remains useful if a mapped point is missed by the preflight or a future map does not support the exact polynomial path. + +## Warm-start rule + +After accepting $`x_{k+1}=x_k+\alpha p_k`$, the code applies the frozen-Jacobian heuristic + +``` math +p_{k+1}^{(0)}=(1-\alpha)p_k. +``` + +This follows from the local approximation $`F(x_k+\alpha p_k)\approx(1-\alpha)F(x_k)`$. If the scaled vector becomes non-finite, all ranks fall back to zero. The heuristic is inexpensive and the backend contract correctly treats it as an initial guess. Its observed interaction with a loose inexact-Newton tolerance is now one of the highest-priority numerical questions. + +## Failure taxonomy and rollback + +Expected evaluation failures are returned rather than thrown. The public reasons are: + +- inadmissible state; + +- non-finite state; + +- non-finite residual; + +- linear-solve failure; + +- globalization failure; + +- stagnation; + +- iteration limit; and + +- unspecified. + +The last accepted state is restored after an unaccepted line search and certified as a checkpoint. A converged report certifies the same storage as a structure. The `stagnation` enum exists but no dedicated stagnation detector currently produces it. + +# Diagnostics now available + +## Per-trial diagnostics + +The detailed trial event exposes: + +- iteration and trial indices; + +- step length; + +- accepted, inadmissible, non-finite, or insufficient-decrease disposition; + +- a preparation-stage string such as `generated geometry` or `gravity displacement force`; + +- optional residual norm and merit; + +- optional minimum mapping determinant; + +- preparation and metric times; and + +- borrowed candidate state and residual spans. + +The current event does not expose the lower-level mapping status or the complete `StellarEquilibriumPreparationRejection`. This means that the recorded phrase `source = gravity displacement force` proves where rejection occurred but not whether the kernel saw an inverted Jacobian, another invalid mapping status, or non-finite arithmetic. Extending this diagnostic without allocating should be considered. + +## Per-iteration diagnostics + +The after-iteration event reports the accepted step, number of trials, previous and current metrics, total iteration time excluding observer work, geometry-preflight time and certificate, line-search time, candidate-preparation time, metric time, preconditioner-refresh time, rollback time, and the full last linear report. + +## Evaluation totals + +The evaluation report retains attempted and accepted nonlinear iterations; total line-search trials; counts by rejection class; geometry-limited iteration count; initial and final residual norms; last accepted step; aggregate timing categories; the last geometry certificate; and the last linear report. + +No growing iteration history is stored by default. A user who needs histories should copy selected fields in an observer. + +# Observed sandbox behavior before geometry preflight + +## Configuration represented by the run + +The supplied run used the Debug sandbox with a normalized $`n=1`$ polytropic model, fixed mass, fixed angular momentum, and fixed central density, with angular momentum currently set to zero. The linear tolerance was 0.03 relative to the linear right-hand side, the maximum linear iteration count was 200, and FGMRES restart length was 40. + +Finite-element setup took 0.156279 seconds. Context setup took 86.6662 seconds before Newton iteration 0. Context construction includes seed projection, initial operator preparation, normalization setup, physical-preconditioner preparation, normalized inverse construction, and linear-backend preparation. The current timing does not separate those phases. + +## Iteration summary + +| $`k`$ | $`\left\lVert F_k\right\rVert`$ | accepted $`\alpha`$ | trials | iter. s | line s | $`J`$ calls | $`J`$ s | +|---:|---:|---:|---:|---:|---:|---:|---:| +| $`k`$ | $`\left\lVert F_k\right\rVert`$ | accepted $`\alpha`$ | trials | iter. s | line s | $`J`$ calls | $`J`$ s | +| 0 | 0.0214928 | 0.0625 | 2 | 194.789 | 53.646 | 21 | 123.003 | +| 1 | 0.0201477 | 0.0078125 | 3 | 98.159 | 86.467 | 2 | 11.689 | +| 2 | 0.0199897 | 0.00390625 | 3 | 136.953 | 118.448 | 3 | 17.563 | +| 3 | 0.0199115 | 0.000488281 | 5 | 197.776 | 185.926 | 2 | 11.848 | +| 4 | 0.0199017 | 0.000244141 | 3 | 130.261 | 118.532 | 2 | 11.727 | +| 5 | 0.0198968 | 0.0000610352 | 4 | 162.629 | 150.949 | 2 | 11.678 | + +The printed residual at the start of Newton 6 was 0.0198956. Across six accepted steps, the reduction was only 7.43 percent. The reduction becomes approximately proportional to the tiny accepted step, which is consistent with following a direction that is locally useful but globalization-limited rather than approaching quadratic Newton convergence. + +## Where the time went + +For iterations 0–5: + +| Category | Seconds | Fraction of iteration time | +|:---|---:|---:| +| Category | Seconds | Fraction of iteration time | +| Total nonlinear iteration time | 920.5666 | 100.0 percent | +| Line search | 713.9680 | 77.6 percent | +| Candidate preparation within line search | 690.7343 | 75.0 percent | +| Linear solve | 171.4161 | 18.6 percent | +| Accepted candidate preparation alone | 290.6897 | 31.6 percent | +| Rejected candidate preparation, by subtraction | 400.0446 | 43.5 percent | +| Accepted preconditioner refresh | 23.1827 | 2.5 percent | + +The categories are not all independent. Candidate preparation is contained in line-search time, and operator/preconditioner times are contained in linear-solve time. Per-rank maximum timings can also make subcategory sums differ slightly from wall-clock totals. + +Accepted candidates consistently required about 48.2–48.8 seconds of preparation. A generated-geometry rejection took about 1.5 seconds. A trial that survived the coarse geometry inspection but failed during gravity displacement force preparation took about 32.8–33.4 seconds. This is why eliminating downstream mapped-geometry trials is valuable even though the geometry gate does not reduce the cost of a successful candidate. + +## Jacobian and preconditioner timing + +The first solve reported 21 Jacobian applications totaling 123.003 seconds, or 5.857 seconds per completed application. Across iterations 0–5, individual averages were 5.857, 5.844, 5.854, 5.924, 5.863, and 5.839 seconds. The consistency confirms that `J time` is cumulative and that a single Debug Jacobian application costs about 5.86 seconds for this problem. + +The first solve reported 19 inverse-preconditioner applications totaling 18.1196 seconds, about 0.954 seconds each. The linear solve took 135.281 seconds. Jacobian actions therefore dominate the first Krylov solve. Geometry preflight cannot reduce this cost because the Newton direction must be computed before its safe step can be estimated. + +## The zero-iteration FGMRES clue + +Iteration 1 reported: + +- zero Krylov iterations; + +- initial true residual divided by $`\left\lVert b\right\rVert`$ of 0.0296713; and + +- configured relative tolerance 0.03. + +Iterations 3–5 show the same pattern with relative initial residuals near 0.0268. Because the supplied warm start already satisfies the stopping threshold, MFEM correctly performs no preconditioner applications. The two Jacobian calls are the initial-guess residual evaluation and post-solve true-residual verification. + +This behavior is contract-correct but may be algorithmically counterproductive. The direction used at a new nonlinear state can be essentially the prior direction scaled by $`1-\alpha`$. When $`\alpha`$ is tiny, that scaling is nearly one. The solver may therefore carry almost the same direction through multiple iterations, accept it under a 3 percent linear forcing tolerance, and continue taking smaller geometry-limited steps. This is a hypothesis that should be tested by controlled runs, not silently “fixed” by disabling warm starts. + +## What has not been established about the residual + +The code has extensive analytic, finite-difference, manufactured, and subsystem tests. Those do not establish that the complete normalized residual is small at a known good full stellar solution. The Lane-Emden-projected sandbox seed begins at 0.0214928 in the current normalized norm, but there is not yet a documented refinement study proving how that number scales with mesh size or polynomial order. + +The next correctness experiment should project the analytic nonrotating $`n=1`$ solution, evaluate every residual block separately, and repeat under refinement without changing physical tolerances. This will distinguish seed projection error, discretization error, normalization effects, missing physical terms, and nonlinear-solver behavior. A global norm alone cannot make that distinction. + +# Why gravity displacement force became the visible rejection source + +The initial generated-geometry stage builds the surface-induced volume displacement and inspects mapped geometry with its own conservative rule. Passing that inspection proves only that those sampled points are orientation preserving. + +The gravity displacement force later uses the quadrature rule selected for its own weak form. It maps density, gravity gradient, and deformation at those points, prepares reusable element data, and rejects the candidate if any mapping status is invalid. Before the new shared sampling plan, those operator-specific points were not necessarily identical to the generated-geometry inspection points. A candidate could therefore pass the cheap early inspection, spend roughly 33 seconds preparing gravity and mechanical data, and only then discover an invalid mapped point in the gravity force. + +The log source identifies the preparation stage but not the exact lower-level reason. Source inspection shows that the gravity force distinguishes invalid mapping from non-finite arithmetic internally, and that invalid mapping retains a `MappingStatus`. The public trial event currently compresses this to an inadmissible/non-finite disposition and stage name. Thus the most likely explanation is a quadrature coverage mismatch, but the old output alone does not prove which mapping status occurred. + +# Largest safe Newton step estimator + +## Purpose and public form + +The new free function is: + +``` +LargestSafeNewtonStepSizeEstimate +estimate_largest_safe_newton_step_size( + const mapping::DomainMapper& domainMapper, + const mfem::ParFiniteElementSpace& displacementSpace, + const mfem::ParGridFunction& compactificationCoordinate, + const mfem::Vector& acceptedVolumeDisplacement, + const mfem::Vector& volumeNewtonDirection, + std::span geometryRules, + const LargestSafeNewtonStepSizeOptions& options = {} +); +``` + +It evaluates the path + +``` math +d(\alpha)=d_0+\alpha\,\delta d, +\qquad 0\leq\alpha\leq\alpha_{\max}, +``` + +where both displacement vectors use the displacement space’s true-DOF layout. The compactification coordinate remains fixed. The operation is collective on the displacement-space communicator and returns an identical global certificate on every rank. + +## Exact sampled determinant calculation + +For the current domain deformation and Kelvin compactification, the mapped Jacobian at a fixed reference quadrature point is affine along the displacement direction: + +``` math +J_{\mathrm{map}}(\alpha)=J_0+\alpha\,\delta J. +``` + +In dimension $`d\leq3`$, the determinant is a polynomial of degree at most $`d`$: + +``` math +g(\alpha)=\det\left(J_0+\alpha\delta J\right)-\delta_{\mathrm{floor}}. +``` + +The implementation constructs its coefficients by determinant multilinearity in the columns. It examines derivative critical points, divides the normalized interval into monotone pieces, and bisects the first non-positive boundary. Inspecting critical points is essential: for example, $`g(\alpha)=(1-2\alpha)^2`$ is positive at both endpoints but touches zero at $`\alpha=1/2`$. + +The smallest boundary across all supplied points and ranks is selected with a collective minimum-location reduction. If geometry limits the requested maximum step, the returned safe step is + +``` math +\alpha_{\mathrm{safe}}=\theta\,\alpha_{\mathrm{boundary}}, +\qquad 0<\theta<1. +``` + +If no sampled point reaches the determinant floor, the requested maximum step is returned unchanged. + +## Certificate fields + +The result records: + +- safe step and first boundary step; + +- global minimum determinant at the accepted state; + +- global minimum determinant at the requested maximum step; + +- determinant of the limiting polynomial at the safe step; + +- total sampled quadrature-point count; + +- whether geometry was limiting; and + +- limiting rank, local element, local rule index, and quadrature-point index. + +These fields are exposed through the iteration observer and the fixed-size evaluation diagnostics. + +## Sampling plan + +The context constructs the sampling plan once. It includes: + +1. one conservative whole-domain rule per element, with order $`\max(p+2,2dp)`$ for displacement order $`p`$ and spatial dimension $`d`$; and + +2. the exact mapped-geometry rules used by the prepared H(div) gravity mass, gravity source, barotropic closure, hydrostatic equilibrium, pressure force, gravity displacement force, rotational displacement force, mass normalization, and fixed-angular-momentum integral when present. + +Prepared operators expose these stable rules through `VisitMappedGeometryRules`. The combined plan is sorted by element and rule pointer and deduplicated in $`O(N\log N)`$ time. Pointer identity removes repeated references to the same stable MFEM integration rule. Equivalent rules represented by distinct objects may remain, which is safe but can add work. + +## New direction plumbing + +The Newton correction initially exists in normalized root coordinates. Geometry preflight therefore performs the following conversion: + +1. denormalize the full correction into physical root coordinates; + +2. extract the surface-deformation block from the variadic root; + +3. apply the prepared domain-deformation Jacobian to produce a physical volume-displacement direction; and + +4. evaluate mapping determinant polynomials along that direction. + +This path is exposed by `BuildVolumeDisplacementDirection` through the physical core, variadic prepared operator, and problem wrapper. It does not reconstruct the entire candidate residual. + +## Map capability contract + +`ExteriorDomainMap` now has `IsAffineInDisplacement()`, defaulting to false. Kelvin compactification overrides it with true. The context also requires the selected domain deformation to declare that it is linear on reference geometry. These are honest capability checks: the exact determinant-polynomial proof is not silently applied to a future nonlinear mapping. + +## Newton integration + +After FGMRES returns a finite converged correction, Newton requests a safe step no larger than its remembered next line-search step. If the safe step is below `minimumStepLength`, evaluation returns globalization failure without preparing a candidate. Otherwise the first expensive trial uses the cap directly. Subsequent rejection still uses ordinary backtracking and the older determinant interpolation fallback. + +The sandbox now prints preflight time, sample count, safe step, boundary, limiting rank and element, total geometry-preflight time, and number of geometry-limited iterations. + +## Guarantee boundary + +The estimate is exact for the determinant polynomial at every supplied quadrature point under the advertised affine mapping assumptions. It is not a proof over the continuous element between quadrature points. The additional conservative rule reduces this gap, while exact downstream rules guarantee coverage of the points those prepared operators presently evaluate. + +The preflight only addresses mapping orientation and the configured determinant floor. It does not predict: + +- EOS-domain violations; + +- density, pressure, or enthalpy admissibility; + +- non-finite arithmetic unrelated to mapping; + +- insufficient residual decrease; or + +- a poor or nearly tangential Newton direction. + +The solver currently requests determinant floor zero. A positive conditioning floor is supported by the free function but is not yet a nonlinear option separate from the fraction-to-boundary safety. + +# Expected performance effect of geometry preflight + +## Prediction based on the recorded run + +The geometry pass replaces repeated 1.5-second generated-geometry rejections and, more importantly, repeated 33-second gravity-force mapping rejections with one mapping-only scan per Newton direction. It does not replace the accepted candidate preparation. + +Based on the old timing profile, the expected iteration intervals are approximately: + +| Iteration | Old observed time | Post-preflight expectation | +|:---|:---|:---| +| Iteration | Old observed time | Post-preflight expectation | +| Newton 0 | 194.8 s | Roughly 195–205 s; little gain because the linear solve dominates and the rejected geometry check was cheap. | +| Newton 1 | 98.2 s | Roughly 67–75 s if the preflight replaces the 33-second rejected trial. | +| Newton 2 | 137.0 s | Roughly 74–82 s if it replaces two rejected gravity trials. | +| Newton 3 | 197.8 s | Roughly 68–75 s if it replaces four rejected gravity trials. | +| Newton 4–5 | 130–163 s | Roughly 67–75 s if it replaces the repeated gravity trials. | + +Across the six-iteration segment, a 35–40 percent reduction is plausible if the preflight remains under roughly 3–10 seconds per iteration. This estimate must be replaced with a real benchmark immediately. + +## What successful output should look like + +The first trial should normally use a non-power-of-two step printed as the geometry safe cap and should be accepted with a positive minimum determinant. The number of inadmissible mapping trials should fall sharply. A first trial still rejected by `gravity displacement force` for invalid mapping would indicate one of: + +- a missing downstream quadrature rule; + +- a mismatch between the volume direction used by preflight and the candidate deformation; + +- a false affine-in-displacement capability claim; + +- an operator evaluating mapped geometry outside its advertised rules; or + +- a lower-level mapping status unrelated to determinant sign. + +Such a result should be treated as a correctness bug in the preflight coverage or diagnostics, not merely as evidence to reduce the safety factor blindly. + +# Verification performed during this phase + +## Build discipline + +Formatting was run before each code build. Debug targets for the main test executable and sandbox compiled successfully after the final geometry-rule deduplication and normalized-direction wiring. No physical, analytic, or finite-difference tolerance was loosened. A final whitespace/error check on the diff was clean. + +## Focused test evidence + +The following results were recorded: + +| Scope | Result | What it establishes | +|:---|:---|:---| +| Scope | Result | What it establishes | +| Two-rank safe-step estimator tests | 5 cases, 32 assertions passed | First boundary, tangent root, positive floor, unconstrained step, and collective invalid-input behavior. | +| Standalone Newton/geometry focus | 6 cases, 55 assertions passed | Geometry-related Newton and estimator paths at the then-current stage. | +| Final Physical Riesz geometry-cap integration test | 1 case, 23 assertions passed, about 170 s | A normalized surface-only correction is denormalized, converted to volume displacement, capped before trial preparation, and accepted in one trial. | +| Generated rotation/angular momentum and warm-start/backtracking focus | 2 cases, 231,772 assertions passed, about 417 s combined | Existing generated-rotation physics and nonlinear reuse behavior were not broken by the refactor. | +| Debug build of tests and sandbox | Passed | The current module graph and public sandbox compile. | + +The very large assertion count in the rotation/operator focus reflects element- and degree-of-freedom-level comparisons, not hundreds of thousands of independent scenarios. + +## What the tests cover structurally + +The new and existing solver tests cover: + +- generic linear-backend and preconditioner-contract detection; + +- stable prepared ownership before evaluation; + +- explicit/default seeds and rotation availability; + +- read-only result views and opaque owning capture targets; + +- failure-reason preservation; + +- FGMRES restart configuration, initial guesses, and true-residual reports; + +- context/view generation and solver-destruction lifetimes; + +- paired observer hooks and line-search trial records; + +- backtracking scale reuse and correction-vector warm starts; + +- accepted final steps at the iteration limit; + +- rollback after metric, observer, and preconditioner exceptions; + +- recovery after non-finite or malformed backend corrections; + +- end-to-end production context to MFEM FGMRES wiring; and + +- the geometry cap at the normalized full-root boundary. + +## Why the full suite was not run + +The full suite takes approximately two hours. The changes were concentrated in solver orchestration, mapped-rule exposure, and a new free geometry function, and the focused tests exercised those boundaries plus representative existing physics. Under the user’s instruction to use judgment, the full suite was not rerun solely for this handoff. It should be run before merging or checkpointing a broader follow-up that changes shared mapping, normalization, or operator semantics. + +## Unverified claims + +The following are not established by the current tests: + +- that the post-preflight real sandbox is faster; + +- that every old gravity-force rejection is eliminated; + +- that the physical nonrotating problem converges; + +- that the residual of a known good solution decreases under refinement; + +- that the current warm-start and 3 percent forcing policy are robust; + +- that context setup or Jacobian actions scale acceptably; + +- that a nonzero-angular-momentum sequence converges; or + +- that capture, serialization, and nonlinear restart work. + +# Current state by subsystem + +| Subsystem | Status | Current meaning | +|:---|:---|:---| +| Subsystem | Status | Current meaning | +| Variadic model/compiler | Implemented and inherited | Canonical inferred root remains the stable physics boundary. | +| Physical Riesz normalization | Implemented and exercised | Solver operates in normalized coordinates and denormalizes directions when physical geometry is required. | +| Problem object | Implemented | Owns model, discretization, compiled surface constraint, and prepared variadic operator. | +| User context | Implemented | Authoritative lifetime root; stable, non-copyable, non-movable; prepares all main numerical components. | +| Linear backend abstraction | Implemented | ADL preparation and contract-aware type checking permit future backends. | +| MFEM FGMRES | Implemented and focused-test verified | Restart length, warm start, true residual, counts, timings, and distributed validation exist. | +| Newton engine | MVP implemented | Normalized merit, backtracking, rollback, expected failures, and fixed-size diagnostics exist. | +| Observer API | Implemented and tested | Ergonomic two- or three-callback construction; synchronous on every rank. | +| Result views | Implemented and tested | Converged structure view and last-accepted checkpoint view are report-certified and generation checked. | +| Owning capture | Stub only | Signatures exist; calls throw. | +| Serialization | Stub only | Free overloads exist; calls throw. | +| Safe geometry estimator | Implemented in dirty tree | Exact at supplied points for affine-in-displacement maps; collective and diagnostic. | +| Newton geometry integration | Implemented in dirty tree and focused-test verified | Caps first trial before expensive candidate preparation. | +| Physical convergence | Not established | Recorded residual decrease is slow and steps collapse. | +| Performance quality | Not established | Context, accepted preparation, and Jacobian actions are expensive in Debug. | +| Continuation/restart | Future work | Architecture does not exclude it, but no persisted restart or continuation controller exists. | + +# Known architectural limitations and issues + +## The context is intentionally more than a POD + +The user requested a context or problem-like owner and described it as a POD. The implementation is a user-facing context but not a trivial aggregate. Its stable-address and one-active-solver invariants justify the stronger class. A future cleanup should preserve the semantic simplicity of “one user-owned lifetime root” even if internal storage is refactored. + +## Solver construction still moves configuration values + +The undesired ownership transfer from user to solver was removed for the large problem state: the solver borrows the context. The small Newton configuration and observer are still stored by value in the solver, so move-only observers are supported. This is ordinary configuration ownership, not a transfer of the physical problem or solution. + +## Only one nonlinear method and one built-in linear backend exist + +The interfaces are generic, but implementation breadth is deliberately narrow. Only Newton and MFEM FGMRES are built in. This matches the MVP decision. It should not be represented as demonstrated plug-and-play support for MINRES, arbitrary nonlinear methods, or basis recycling. + +## Metric extensibility is partial + +The metric value is customizable at compile time. Acceptance, convergence threshold construction, and the concept of sufficient decrease remain hardwired. A future metric that needs a directional derivative, blockwise trust criterion, constraint filter, or multi-objective acceptance decision will need a slightly richer policy boundary. + +## View invalidation is strict + +The next evaluation invalidates every earlier structure/checkpoint view from that context, even if an earlier report object remains alive. This is honest for mutable context-owned storage but may surprise users. Self-contained `capture()` is the intended escape hatch and is not implemented yet. + +## No complete persistence schema + +A self-contained structure must decide how to store distributed fields, manifest metadata, mesh and refinement identity, finite-element order, units and normalization metadata, model specifications, generated scalar values, rotation, communicator-independent global numbering, and versioning. Saving only the raw root vector is insufficient for reliable offline investigation or restart. + +## Current result is not a scientific validation record + +The report stores numerical solver totals, not virial error, mass error, angular-momentum error, surface residual, mesh quality distribution, or refinement comparison. Those diagnostics should be computed through separate physics observers or post-processing once the nonlinear path becomes reliable. + +## Distributed observer caveat + +Observers run on every rank. They must not perform rank-divergent collectives or throw on one rank while peers enter unrelated collectives. The helper coordinates ordinary callback failure, but no generic library can repair arbitrary inconsistent user callback control flow. + +## Allocation behavior is not fully characterized + +Most persistent solver workspaces are context-owned, but the geometry estimator currently creates local true-to-local vectors, element vectors, matrices, and mapping workspace objects on each call. This is acceptable under the clarified requirement but is a later optimization candidate if profiles show meaningful cost. + +# Known numerical and performance issues + +## Physical convergence is poor + +The observed method is reducing the residual, but not at a useful rate. The step collapse reaches $`6.1\times10^{-5}`$ by iteration 5. Even if geometry-preflight cuts wall time, the same sequence would require far too many accepted steps unless direction quality or parameterization improves. + +## Warm-start and forcing interaction + +The 3 percent linear tolerance permits a prior scaled correction to be accepted without new Krylov work. This may be a reasonable inexact Newton solve if the direction still produces adequate nonlinear progress, but the observed geometry-limited steps suggest otherwise. The code needs experiments that independently vary: + +- cold versus warm initial correction; + +- fixed tolerances such as $`3\times10^{-2}`$, $`10^{-2}`$, and tighter values; + +- an adaptive Eisenstat-Walker-style forcing term; + +- a minimum required improvement over the initial linear residual; and + +- optional restart of the warm start after a severely damped nonlinear step. + +Do not select one of these policies solely from intuition. Record Jacobian calls, true residual, accepted step, residual reduction, and total time. + +## Context construction costs 86.7 seconds + +The top-level timer is too coarse to identify the source. Add subphase timings around seed projection, initial prepared-operator work, normalization construction, physical-preconditioner preparation, normalized wrapper construction, geometry-rule-plan construction, and FGMRES binding. Geometry-plan construction was added after the recorded number, so its incremental cost must also be measured. + +## Accepted trial preparation costs about 48 seconds + +Removing rejected trials does not remove this floor. The prepared root currently rebuilds the mapped and physics-dependent caches needed by gravity, displacement forces, closure, hydrostatic balance, mass, and generated constraints. Profile each stage. Candidate failure ordering already prepares the displacement residual early after gravity so mapped-force failures occur before unrelated closure and hydrostatic rows, but successful candidates still pay for everything. + +## Jacobian application costs about 5.86 seconds + +The first linear solve spent approximately 123 seconds in 21 Jacobian applications. Block-level timing is not yet printed. Likely candidates for measurement include repeated true/local transfers, repeated element geometry evaluation, multiple quadrature traversals over the same mapped data, temporary allocations, and Debug-only checks. Optimization should follow measurements and preserve analytic/finite-difference tolerances. + +## Debug timings are development evidence + +The user requires Debug builds during development to avoid very long build times and preserve diagnostics. These timings are useful for identifying dominant algorithms. They are not production performance claims. A later controlled Release or profiling build may quantify absolute performance, but should not replace the Debug correctness workflow without agreement. + +## Geometry is only one globalization boundary + +The new cap handles orientation preservation. If the next failure source becomes EOS admissibility or density/enthalpy domain constraints, an analogous cheap preflight may be useful. That should be driven by measured failure frequency and should reuse explicit free functions rather than embedding more exception-driven logic in the Newton loop. + +# Blockers and decision points + +## Immediate blockers + +The following block a credible MVP result: + +1. The new geometry preflight has not been benchmarked on the actual sandbox sequence. + +2. The full residual has not been validated at a known nonrotating solution under refinement. + +3. The zero-iteration warm-start behavior has not been compared against a cold start or adaptive forcing. + +4. Context setup, successful candidate preparation, and Jacobian actions lack block-level profiles. + +5. The nonlinear solve has not converged even the target nonrotating reference. + +Persistence is not required to run the next experiments, but it blocks reliable long-run checkpointing and restart. + +## Choices that do not need to be reopened now + +Unless new evidence contradicts them, retain: + +- the user-owned context and borrowing solver; + +- evaluation reports with structure/checkpoint views; + +- free-function serialization; + +- self-contained capture as the only owning capture mode; + +- explicit Newton with backtracking as the reference nonlinear engine; + +- MFEM FGMRES as the first built-in linear backend; + +- compile-time metric selection; + +- observer-based growing diagnostics; and + +- expected values for ordinary candidate rejection. + +## Choices that should be evidence driven + +The next work should gather data before selecting: + +- fixed versus adaptive inexact-Newton forcing; + +- when to retain or reset the correction warm start; + +- whether the current surface parameterization is the primary cause of geometry limitation; + +- which Jacobian block or mapped-data path to optimize first; + +- whether a positive determinant conditioning floor is useful; and + +- when the benefit of true Krylov recycling justifies backend complexity. + +# Recommended next work, in order + +## Step 1: run the post-preflight sandbox once + +Build in Debug, run formatting before the build, and capture the full observer output. Record: + +- context setup time; + +- preflight sample count and time per iteration; + +- requested maximum step, boundary, and safe step; + +- limiting rank and element; + +- first-trial disposition; + +- any remaining invalid-mapping stage; + +- accepted preparation time; + +- FGMRES initial and true residual ratios; + +- Jacobian and preconditioner calls; and + +- residual reduction per accepted step. + +Stop early if the first geometry-capped trial is still rejected by invalid mapped geometry. Diagnose coverage before spending hours on a full sequence. + +## Step 2: enrich rejection diagnostics + +Expose enough of the fixed-size preparation rejection in `AfterLineSearchTrial` to distinguish: + +- inverted determinant; + +- singular/non-finite inverse mapping; + +- compactification-specific invalidity; + +- EOS-domain error code; and + +- non-finite physics arithmetic. + +Prefer enums and optional scalar data over allocated strings. Keep the existing human-readable source string for sandbox ergonomics if desired. + +## Step 3: validate the residual on the analytic nonrotating reference + +For an $`n=1`$ Lane-Emden solution with consistent radius, mass, polytropic constant, and central density: + +1. project the analytic state into each finite-element block; + +2. prepare the full inferred root; + +3. report normalized and physical norms by residual block; + +4. verify fixed-mass and fixed-central-density rows separately; + +5. compare the surface residual and minimum determinant; + +6. repeat over mesh refinement and polynomial order; and + +7. require convergence trends without loosening existing physical or analytic tolerances. + +This is the direct answer to the unresolved question “is the residual small for a known good solution?” At present, the answer for the full root is not yet established. + +## Step 4: isolate warm-start and forcing behavior + +Run a small controlled matrix with identical initial state and geometry: + +| Case | Linear initial guess | Forcing policy | +|:-----|:------------------------|:---------------------------------------------| +| Case | Linear initial guess | Forcing policy | +| A | zero each Newton step | current 0.03 fixed tolerance | +| B | scaled prior correction | current 0.03 fixed tolerance | +| C | zero each Newton step | tighter fixed tolerance | +| D | scaled prior correction | tighter fixed tolerance | +| E | scaled prior correction | adaptive forcing based on nonlinear progress | + +Compare total time to a fixed residual reduction, not time per iteration alone. A run with more Krylov work can be cheaper overall if it produces materially larger accepted Newton steps. + +## Step 5: add subphase profiling + +Instrumentation should remain low-cost and observer-friendly. Measure: + +- context construction phases; + +- each prepared-operator stage for accepted and rejected candidates; + +- each major Jacobian block action; + +- true/local gather-scatter time; + +- mapped-geometry evaluation time; + +- EOS and quadrature kernel time; and + +- allocation counts if a suitable low-overhead mechanism is available. + +Avoid preprocessor-only instrumentation if a zero-cost empty observer or compile-time policy can provide the same release behavior. + +## Step 6: optimize the measured hot path + +Likely interventions, to be chosen only after profiling, include: + +- reusing per-element mapped geometry across operator blocks at the same prepared state; + +- fusing quadrature traversals that consume identical rules and fields; + +- retaining true-to-local representations through a complete Jacobian action; + +- moving remaining per-application temporary storage into the context or prepared operator; + +- avoiding duplicate determinant/inverse evaluations; and + +- strengthening the physical structure preconditioner if fewer Jacobian applications outweigh more inverse work. + +Every optimization must keep analytic and finite-difference tolerances unchanged. + +## Step 7: converge the nonrotating reference + +Require: + +- nonlinear convergence under the declared normalized norm; + +- direct final residual agreement; + +- blockwise residual control; + +- mass and central-density constraint accuracy; + +- orientation-preserving geometry with useful margin; + +- reproducibility from the same seed; and + +- a basic refinement trend. + +Only then move to nonzero angular momentum. + +## Step 8: continuation in angular momentum + +Use the converged nonrotating structure as the initial state for small fixed-angular-momentum increments. This requires either implementing capture/restart or adding a scoped context-to-context seed path that does not weaken ownership. Record angular velocity, moment of inertia, virial defect, mass and angular-momentum errors, surface shape, minimum determinant, nonlinear steps, and Krylov work. + +Do not add a public `solver.restart()` API merely for this first experiment. The current architecture should first establish the checkpoint representation and continuation semantics. + +## Step 9: persistence + +Design a versioned, communicator-independent schema, then implement: + +- self-contained structure capture; + +- self-contained checkpoint capture; + +- free serialization overloads for owner and view; + +- deserialization into an inspection value; + +- compatibility checks against model/discretization metadata; and + +- a controlled path to project or restore a checkpoint into a new context. + +Serialization should not persist opaque MFEM pointers or assume identical MPI rank count. + +# File map for takeover + +## Committed solver architecture + +| File | Role | +|:---|:---| +| File | Role | +| `libmeanfield/interface/solver/linear_backend.cppm` | Generic backend concepts, linear controls/report, MFEM FGMRES configuration and prepared implementation. | +| `libmeanfield/interface/solver/newton.cppm` | Newton/backtracking options, metric policy, dispositions, observer events and helpers. | +| `libmeanfield/interface/solver/stellar_context.cppm` | Authoritative context ownership, construction overloads, prepared runtime, and explicit Newton loop. | +| `libmeanfield/interface/solver/stellar_equilibrium_types.cppm` | Public failure report and fixed-size evaluation diagnostics. | +| `libmeanfield/interface/solver/stellar_structure.cppm` | Report certification, views, opaque owning types, and persistence stubs. | +| `libmeanfield/interface/solver/stellar_equilibrium.cppm` | Solver module aggregation. | +| `libmeanfield/interface/operators/stellar_equilibrium_problem.cppm` | Owned discretized problem and solver-facing preparation/residual/Jacobian forwarding. | +| `libmeanfield/interface/preconditioning/stellar_recipe.cppm` | Default preconditioner recipe introduced for solver assembly. | +| `sandbox.cpp` | User API exercise and physical performance diagnostics. | + +## Geometry-preflight working-tree files + +| File | Role | +|:---|:---| +| File | Role | +| `libmeanfield/interface/deformation/safe_newton_step.cppm` | Public rule, options, certificate, and free-function declaration. | +| `libmeanfield/impl/deformation/safe_newton_step.cpp` | Collective determinant-polynomial estimator and root search. | +| `libmeanfield/interface/mapping/compactification/compactification.cppm` | Affine-in-displacement capability contract. | +| `libmeanfield/interface/mapping/compactification/kelvin.cppm` | Kelvin capability declaration. | +| `libmeanfield/interface/solver/stellar_context.cppm` | Cached rule plan, normalized-to-physical direction conversion, cap invocation, and diagnostics. | +| `libmeanfield/interface/solver/newton.cppm` | Geometry settings and observer certificate fields. | +| `libmeanfield/interface/solver/stellar_equilibrium_types.cppm` | Evaluation-level geometry totals and last certificate. | +| `libmeanfield/interface/mean_field.cppm` | Public export of the estimator. | +| `CMakeLists.txt` | New source, interface, serial test, and MPI-test registration. | + +The prepared operator interfaces listed below expose their exact mapped quadrature rules: + +- `libmeanfield/interface/operators/prepared_hdiv_mass.cppm`; + +- `libmeanfield/interface/operators/prepared_gravity_source.cppm`; + +- `libmeanfield/interface/operators/prepared_barotropic_closure_operator.cppm`; + +- `libmeanfield/interface/operators/prepared_hydrostatic_equilibrium_operator.cppm`; + +- `libmeanfield/interface/operators/prepared_pressure_force.cppm`; + +- `libmeanfield/interface/operators/prepared_gravity_displacement_force.cppm`; + +- `libmeanfield/interface/operators/prepared_rotation_displacement_force.cppm`; + +- `libmeanfield/interface/operators/prepared_mass_normalization.cppm`; and + +- `libmeanfield/interface/operators/prepared_angular_momentum.cppm`. + +The barotropic, mass-normalization, and angular-momentum prepared runtimes now retain stable integration-rule pointers set by their implementations. These pointers refer to MFEM’s stable rule registry and are borrowed by the context sampling plan. + +## Direction forwarding files + +The new physical volume-direction path touches: + +- `libmeanfield/interface/operators/prepared_stellar_equilibrium.cppm`; + +- `libmeanfield/impl/operators/prepared_stellar_equilibrium.cpp`; + +- `libmeanfield/interface/operators/prepared_variadic_stellar_equilibrium.cppm`; and + +- `libmeanfield/interface/operators/stellar_equilibrium_problem.cppm`. + +## Primary tests + +| File | Focus | +|:---|:---| +| File | Focus | +| `tests/solver/stellar_equilibrium_architecture.cpp` | Public compile-time and ownership contracts. | +| `tests/solver/stellar_equilibrium_architecture_internal.cpp` | Internal storage lifetime and generated-rotation report behavior. | +| `tests/solver/stellar_equilibrium_runtime.cpp` | FGMRES, Newton, observers, rollback, warm starts, geometry wiring, and production integration. | +| `tests/deformation/safe_newton_step.cpp` | Polynomial-boundary and collective estimator behavior in serial/MPI test targets. | +| `tests/user-api/stellar_equilibrium.cpp` | Simple, complete, intermediate, and advanced public API examples. | + +# Working-tree preservation notes + +1. Do not reset to `HEAD`; the geometry estimator and its tests are untracked. + +2. Do not assume every modified line belongs to one agent. User-authored domain-mapper comments and sandbox changes coexist with the geometry work. + +3. Do not delete untracked build trees, CSV experiments, notes, archives, or scripts without explicit scope confirmation. + +4. Before a source build, run `./format` as requested by the user. + +5. Prefer Debug builds for development. + +6. Do not run the approximately two-hour full suite unless the regression risk justifies it; report focused coverage honestly. + +7. Never loosen physical, analytic, or finite-difference tolerances to make solver changes pass. + +8. The user designated `76818f2f828ca12606a495a0b84f5d6ce4b78dc6` as the conversation checkpoint even though current `HEAD` is later. + +# Invariants future work should preserve + +1. **One authoritative owner.** The context owns the physical problem and numerical lifetime; solvers borrow it. + +2. **Report-certified access.** A structure view exists only after convergence; a failed solve exposes only a checkpoint view. + +3. **No pack-specific solver.** The solver consumes the inferred normalized problem and preconditioner contracts, not named constraint combinations. + +4. **Linear/nonlinear separation.** Linear backend choice and nonlinear parameters remain independently selectable. + +5. **Expected failures are data.** Ordinary line-search rejection does not use exceptions. + +6. **Exceptions restore accepted state.** Infrastructure and callback failures cannot leave a candidate silently installed. + +7. **Direct linear residual.** Backend recurrence convergence is never the only reported correctness criterion. + +8. **Stable prepared addresses.** Backend and preconditioner borrows cannot be invalidated by object moves. + +9. **Observer cost is optional.** Growing history and detailed diagnostics remain outside the default fixed-size report. + +10. **Physical/normalized distinction.** Geometry is evaluated from a correctly denormalized physical direction. + +11. **Capability honesty.** Exact polynomial preflight applies only to maps and deformations that advertise the required affine/linear contracts. + +12. **Quadrature coverage.** Every operator that can reject mapped geometry must advertise the points it evaluates or share a stronger common geometry plan. + +13. **No tolerance erosion.** Optimization and globalization work cannot weaken existing physics tests. + +14. **Persistence is self-contained.** A captured structure or checkpoint must not depend on a live solver, context, communicator, or MFEM pointer. + +15. **Scientific claims require independent evidence.** Solver convergence, block residuals, invariants, virial error, and refinement are separate checks. + +# Takeover checklist + +A developer resuming this work should: + +1. confirm repository root, checkpoint, current `HEAD`, and dirty-tree status; + +2. read the previous variadic handoff for the inherited model/compiler architecture; + +3. read `linear_backend.cppm`, `newton.cppm`, `stellar_structure.cppm`, and `stellar_context.cppm` in that order; + +4. read `prepared_stellar_equilibrium.cppm/.cpp` and the displacement-force rejection path; + +5. read the complete safe-step interface, implementation, and tests; + +6. inspect the current sandbox configuration and preserve user edits; + +7. run formatting before any Debug build; + +8. run the focused geometry and Newton tests after relevant changes; + +9. benchmark only enough of the real sandbox to verify first-trial geometry behavior; + +10. do not run a long sequence if the capped first trial still fails for invalid mapping; + +11. perform the known-solution block-residual study before claiming solver error is the only problem; + +12. perform the warm/cold and forcing-policy comparison before removing or expanding warm-start behavior; + +13. profile by block before optimizing Jacobian or accepted candidate preparation; + +14. preserve fixed-size failure records and observer-based histories; and + +15. commit the geometry work separately from unrelated user notes and artifacts when the user requests a checkpoint. + +# Glossary + +| Term | Meaning in the current solver | +|:---|:---| +| Term | Meaning in the current solver | +| Accepted state | The committed normalized/physical root whose prepared operator, residual, and preconditioner are mutually current. | +| Candidate or trial state | A temporary line-search state that may be rejected without changing the accepted state. | +| Checkpoint view | Read-only, generation-checked access to the last accepted state, available after success or failure. | +| Context | The user-owned, stable lifetime root for the problem, state, prepared operator, preconditioner, backend, and workspaces. | +| Correction warm start | The previous accepted Newton correction scaled by $`1-\alpha`$ and supplied as the next FGMRES initial guess. | +| FGMRES restart | Starting a new Krylov cycle after the configured basis length; not nonlinear checkpoint restart. | +| Geometry preflight | Mapping-only calculation of the first sampled determinant boundary along a volume-displacement direction. | +| Inexact Newton forcing | The permitted linear residual relative to the Newton right-hand side. The sandbox currently uses 0.03. | +| Metric | Compile-time policy converting a normalized residual into residual norm and scalar merit. | +| Prepared backend | An owning linear runtime bound to stable operator, preconditioner, communicator, and internal workspaces. | +| Structure view | Read-only, generation-checked access certified only by a converged report. | +| True residual | Directly recomputed $`b-Ax`$, independent of the Krylov recurrence estimate. | + +# Recorded raw sandbox sequence + +The essential supplied output is reproduced here so later work can compare like for like. Whitespace is normalized, but numerical values are unchanged. + +``` +Finite-element setup: 0.156279 s +Solver context setup: 86.6662 s +Newton 0: |F| = 0.0214928 + trial 1: step = 1, outcome = inadmissible state, + source = generated geometry, min(det J_map) = -10.0826, + prepare = 1.48201 s + trial 2: step = 0.0625, outcome = accepted, |F| = 0.0201477, + min(det J_map) = 0.309289, prepare = 48.3029 s + step = 0.0625, trials = 2, iteration = 194.789 s, + line search = 53.6456 s, trial preparation = 49.7849 s, + accepted refresh = 3.85453 s + FGMRES: converged, iterations = 19, restarts = 0, + initial/|b| = 1, true/|b| = 0.0267531, J calls = 21, + P^-1 calls = 19, solve = 135.281 s, J time = 123.003 s, + P^-1 time = 18.1196 s + +Newton 1: |F| = 0.0201477 + trial 1: step = 0.125, outcome = inadmissible state, + source = generated geometry, min(det J_map) = -0.986494, + prepare = 1.49634 s + trial 2: step = 0.015625, outcome = inadmissible state, + source = gravity displacement force, prepare = 32.8684 s + trial 3: step = 0.0078125, outcome = accepted, |F| = 0.0199897, + min(det J_map) = 0.22833, prepare = 48.2396 s + step = 0.0078125, trials = 3, iteration = 98.1586 s, + line search = 86.4674 s, trial preparation = 82.6044 s, + accepted refresh = 3.8553 s + FGMRES: converged, iterations = 0, restarts = 0, + initial/|b| = 0.0296713, true/|b| = 0.0296713, + J calls = 2, P^-1 calls = 0, solve = 5.85094 s, + J time = 11.6888 s + +Newton 2: |F| = 0.0199897 + trials: 0.015625 rejected by gravity displacement force; + 0.0078125 rejected by gravity displacement force; + 0.00390625 accepted with |F| = 0.0199115 + iteration = 136.953 s, line search = 118.448 s, + trial preparation = 114.522 s, accepted refresh = 3.91821 s + FGMRES: converged, iterations = 1, true/|b| = 0.0266376, + J calls = 3, P^-1 calls = 1, solve = 12.6573 s, + J time = 17.5629 s, P^-1 time = 0.939231 s + +Newton 3: |F| = 0.0199115 + trials: 0.0078125, 0.00390625, 0.00195312, and 0.000976562 + rejected by gravity displacement force; + 0.000488281 accepted with |F| = 0.0199017 + iteration = 197.776 s, line search = 185.926 s, + trial preparation = 182.025 s, accepted refresh = 3.88939 s + FGMRES: converged, iterations = 0, true/|b| = 0.0267994, + J calls = 2, solve = 5.92722 s, J time = 11.8481 s + +Newton 4: |F| = 0.0199017 + trials: 0.000976562 and 0.000488281 rejected by + gravity displacement force; + 0.000244141 accepted with |F| = 0.0198968 + iteration = 130.261 s, line search = 118.532 s, + trial preparation = 114.693 s, accepted refresh = 3.83085 s + FGMRES: converged, iterations = 0, true/|b| = 0.0268207, + J calls = 2, solve = 5.85722 s, J time = 11.7267 s + +Newton 5: |F| = 0.0198968 + trials: 0.000488281, 0.000244141, and 0.00012207 rejected by + gravity displacement force; + 0.0000610352 accepted with |F| = 0.0198956 + iteration = 162.629 s, line search = 150.949 s, + trial preparation = 147.105 s, accepted refresh = 3.83446 s + FGMRES: converged, iterations = 0, true/|b| = 0.0268315, + J calls = 2, solve = 5.84238 s, J time = 11.6781 s + +Newton 6: |F| = 0.0198956 +``` + +# Suggested benchmark record + +For each post-preflight iteration, save one row with: + +
+ +| | +|:---| +| iteration, residual, linear_initial_relative, linear_true_relative, | +| linear_iterations, J_calls, J_seconds, P_calls, P_seconds, | +| preflight_seconds, preflight_samples, boundary_step, safe_step, | +| limiting_rank, limiting_element, trials, rejection_stages, | +| accepted_step, accepted_prepare_seconds, residual_after, iteration_seconds | + +
+ +This is sufficient to test the predicted speedup and the warm-start hypothesis without storing full vectors. + +# Final handoff statement + +MeanField has advanced from a solver-ready inferred operator stack to an actual reference Newton-Krylov orchestration with explicit ownership, typed configuration, robust result semantics, and unusually detailed low-cost diagnostics. The user-facing design now matches the central intent: the context is the authoritative owner; the solver borrows it; evaluation produces a report; and the report grants an honest borrowed structure or checkpoint view. + +The first physical run exposed two separate problems rather than one. First, the line search wasted most of its time discovering mapped-geometry limits at expensive downstream quadrature points. The new exact sampled determinant preflight is a principled response and is wired into Newton before candidate preparation. Second, the accepted Newton directions and broader operator stack remain too expensive and too weakly convergent. In particular, repeated zero-iteration FGMRES acceptance of a carried direction under the 3 percent forcing tolerance is a concrete mechanism that may help explain step collapse. + +The next agent should resist both extremes: do not mistake the geometry preflight for a convergence fix, and do not discard it merely because the underlying solve is still slow. Benchmark it, verify its coverage, then use the time and residual records to separate geometry globalization, linear forcing, preconditioner quality, Jacobian cost, and discretization correctness. Establish the full residual at a known nonrotating solution before tuning toward rapid rotation. Preserve the dirty tree, the checkpoint semantics, and the user’s no-tolerance-loosening rule throughout. diff --git a/SURFACE_DEFORMATION_PRESCRIPTION_DESIGN.md b/SURFACE_DEFORMATION_PRESCRIPTION_DESIGN.md new file mode 100644 index 0000000..b028ec6 --- /dev/null +++ b/SURFACE_DEFORMATION_PRESCRIPTION_DESIGN.md @@ -0,0 +1,1365 @@ +# Surface Deformation Prescription and Domain Extension Design + +**Repository:** `/Users/tboudreaux/Programming/4DSTAR/MeanField` +**Design date:** 2026-08-30 +**Status:** design only; no production code changes are authorized by this document +**First implementation target:** `NodalRadialSurface` with one linear stellar-interior extension and one linear vacuum extension + +## 1. Executive decision + +The coupled equilibrium system should cease treating every stellar-interior and vacuum displacement degree of freedom as an independent nonlinear unknown. The physical geometric unknown for the present problem is the shape of the pressure-zero stellar surface. Interior and vacuum mesh coordinates should be generated deterministically from that surface. + +The target nonlinear state is therefore + +\[ +x = [\rho,\; s,\; g,\; \Phi,\; h,\; C], +\] + +where \(s\) contains the parameters of a selected **surface deformation prescription**. For the first implementation, \(s\) contains one radial displacement coefficient per scalar stellar-surface node. + +The full volume displacement remains an important internal field: + +\[ +d = \mathcal D(s). +\] + +It is still consumed by the existing domain mapper and all geometry-dependent physics operators, but it is no longer stored as an independent block in the root nonlinear vector. + +The map \(\mathcal D\) is a composition of three independently extensible parts: + +\[ +s +\xrightarrow{\mathcal P} +d_\Gamma +\xrightarrow{(\mathcal E_\star,\mathcal E_{\rm vac})} +d. +\] + +- \(\mathcal P\) is the selected surface deformation prescription. +- \(d_\Gamma\) is a vector displacement trace on the stellar surface. +- \(\mathcal E_\star\) extends that trace through the stellar interior. +- \(\mathcal E_{\rm vac}\) extends the same trace through the vacuum and fixes the reference infinity boundary. + +The first implementation should use linear, reference-domain maps for all three operations. This keeps the exact Newton derivative simple, makes transpose application inexpensive, and avoids placing an interior or vacuum mesh solve inside every Krylov matrix-vector product. + +The physical surface condition remains independently specified as constant pressure, presently \(P=0\). A surface deformation prescription describes how the surface location is parameterized; it does not describe which thermodynamic condition defines the surface. + +## 2. Goals + +This phase must provide: + +1. A public concept named `SurfaceDeformationPrescription`. +2. A first concrete prescription named `NodalRadialSurface`. +3. Independently selectable stellar-interior and vacuum deformation extensions. +4. One initial implementation for each extension domain. +5. A composed surface-to-volume deformation map with exact forward and transpose actions. +6. A reduced coupled block system whose geometry block contains surface parameters rather than volume displacement coefficients. +7. Preservation of the existing element-level gravity, closure, hydrostatic, mass, pressure-force, gravity-force, and rotational-force kernels. +8. Compile-time contract checks for concrete prescriptions and extension policies. +9. A runtime bridge so the prepared coupled operator can consume different concrete prescriptions without virtual dispatch in quadrature loops. +10. Tests that establish topology, geometry, derivative, transpose, block-routing, physical, and parallel invariants. +11. A migration path that keeps each implementation phase compilable and independently testable. + +## 3. Non-goals + +This phase will not: + +- implement a spherical-harmonic prescription; +- implement nonlinear elasticity; +- support non-star-shaped stellar surfaces; +- support topology changes or multiple disconnected stellar surfaces; +- change the physical constant-pressure surface condition; +- change the EOS architecture; +- implement the nonlinear solver or its final preconditioner; +- optimize for extreme rotation before the first reduced system is verified; +- remove the full displacement finite-element space from `FEM`; +- rewrite existing element kernels to understand surface parameters; +- introduce Lagrange multipliers for mesh or surface enforcement. + +## 4. Terminology and naming + +Clear separation of the following terms is required. + +### 4.1 Surface condition + +A **surface condition** specifies the physical relation that must hold on the stellar boundary. The current condition is constant pressure, normally + +\[ +P=0. +\] + +For the polytropic EOS this is converted by the EOS into the appropriate enthalpy-carrier relation. This remains the responsibility of the existing surface-condition and EOS machinery. + +Recommended code terminology: + +- SurfaceConditionPrescription +- `ConstantPressureSurface` +- `CompiledPressureSurfaceConstraint` + +The existing `SurfacePrescription` concept in `models/stellar_model.cppm` refers to this physical condition. Because the new geometry system introduces a second legitimate kind of surface prescription, it should be renamed to `SurfaceConditionPrescription` during the semantic-cleanup phase. Likewise, the `surfacePrescription()` accessor should become `surfaceConditionPrescription()`. + +### 4.2 Surface deformation prescription + +A **surface deformation prescription** specifies the finite-dimensional coordinates used to represent the surface location. + +Examples include: + +- `NodalRadialSurface`; +- a future `SphericalHarmonicSurface`; +- a future `AxisymmetricLegendreSurface`; +- a future `NodalNormalSurface`. + +It maps parameter coefficients to a vector displacement trace on the stellar surface. It does not know about pressure, enthalpy, the EOS, or the force law. + +### 4.3 Domain deformation extension + +A **domain deformation extension** determines the nonphysical placement of mesh coordinates away from the stellar surface. + +The stellar-interior and vacuum extensions are separate policies because their regularity, anchoring, and mesh-quality requirements differ. + +Recommended code terminology: + +- `InteriorDeformationExtension` +- `VacuumDeformationExtension` +- `PowerLawRadialInteriorExtension` +- `FixedInfinityRadialVacuumExtension` + +### 4.4 Prepared domain deformation + +The **prepared domain deformation** is the compiled, discretization-specific composition of a surface prescription and the two extensions. It is the only object the root coupled operator needs to understand. + +Recommended name: + +- `PreparedDomainDeformation` + +## 5. Why this change is needed + +The gravity-completed gauge experiments performed on 2026-08-30 showed: + +- a non-rigid tangential displacement response of approximately \(1.03\times10^{-7}\) after gravity completion; +- rigid rotational responses of approximately \(1.32\times10^{-6}\); +- a vacuum-only displacement response of approximately \(3.81\times10^{-6}\); +- gravity-completion reductions of approximately \(6.65\times10^4\) for the tangential probe and \(1.20\times10^5\) for the vacuum probe. + +The raw mesh displacements appeared strongly constrained when gravity was held fixed. Once the gravitational field was permitted to change consistently with the geometry, the same directions became nearly null. + +This establishes that the current displacement block contains at least three conceptually different classes of variation: + +1. normal surface motion that changes the physical stellar shape; +2. tangential or interior coordinate relabeling; +3. vacuum mesh-coordinate motion. + +Only the first class should be an independent physical shape unknown. The other two require deterministic gauge choices. + +The existing center row replacement removes rigid translation at the computational origin but does not control tangential, rotational, or vacuum coordinate freedoms. The near-null families are likely high-dimensional, so deflating a short list of vectors is not a complete solution. + +## 6. Current implementation state + +### 6.1 Current root state and residual + +The root block form in `libmeanfield/interface/utils/blocks.cppm` is + +\[ +x_{\rm current}=[\rho,d,g,\Phi,h,C], +\] + +\[ +R_{\rm current}=[R_g,R_\Phi,R_\rho,R_d,R_h,R_M]. +\] + +The current Jacobian coupling is: + +| Residual | \(\rho\) | \(d\) | \(g\) | \(\Phi\) | \(h\) | \(C\) | +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| \(R_g\) | | yes | yes | yes | | | +| \(R_\Phi\) | yes | yes | yes | | | | +| \(R_\rho\) | yes | yes | | | yes | | +| \(R_d\) | yes | yes | yes | | yes | | +| \(R_h\) | | yes | | yes | yes | yes | +| \(R_M\) | yes | yes | | | | | + +The displacement value and residual blocks have the same full-volume H1 vector size, making the root operator square. + +### 6.2 Current displacement field + +`field::Displacement` is a vector H1 field of order three supported on `Core`, `Envelope`, and `Vacuum`. Its reduced `FieldDofMap` therefore ordinarily contains all displacement true DOFs. + +The `FEM` object owns: + +- the displacement finite-element collection; +- the volume displacement finite-element space; +- a displacement grid function. + +These objects remain necessary after the reduction because the domain mapper and existing kernels consume a full volume displacement field. + +### 6.3 Current coupled orchestration + +`PreparedStellarEquilibriumOperator` presently: + +1. extracts the complete displacement block from the root state; +2. packs it into the gravity state; +3. supplies it to closure and hydrostatic preparation; +4. obtains the mechanical displacement residual from `PreparedDisplacementResidualOperator`; +5. replaces three force rows with the centering constraint; +6. routes complete displacement directions to every geometry-dependent Jacobian action. + +This top-level orchestration is the principal integration point for the change. + +### 6.4 Existing reusable infrastructure + +The repository already provides: + +- semantic discovery of the `StellarSurface` through `DomainSchema`; +- `FieldBoundaryDofMap` for boundary DOFs in reduced field ordering; +- a full-volume vector H1 displacement space; +- stateless mapped geometry and first variations; +- complete displacement-column Jacobian actions for all current child operators; +- a registered `Displacement::Form::MeshExtension` quadrature form; +- dependency identity/revision tracking; +- matrix-free prepared residual and Jacobian composition. + +The registered mesh-extension form is useful for future harmonic or elastic auxiliary extensions. It is not necessary for the first analytic linear extension. + +## 7. Target mathematical architecture + +Let \(q\in\mathbb R^{N_q}\) be the surface deformation parameters. Use \(q\), rather than \(s\), in operator equations below to distinguish parameter coefficients from the geometric surface itself. + +The surface prescription produces a vector trace: + +\[ +d_\Gamma=\mathcal P(q), +\qquad +d_\Gamma\in V_\Gamma^d. +\] + +The two extension policies produce the volume displacement: + +\[ +d_\star=\mathcal E_\star(d_\Gamma), +\qquad +d_{\rm vac}=\mathcal E_{\rm vac}(d_\Gamma). +\] + +The combined continuous H1 displacement is + +\[ +d=\mathcal D(q) +=\operatorname{merge}(d_\star,d_{\rm vac}). +\] + +The merge must assign the shared stellar-surface true DOFs exactly once and must produce identical values from both extension policies on that trace. + +For the initial implementation, all maps are linear on the fixed reference mesh: + +\[ +d_\Gamma=Pq, +\qquad +d=Ed_\Gamma, +\qquad +d=LPq\equiv Lq, +\] + +where the composed lift is denoted simply by \(L=EP\). + +The reduced mechanical residual is obtained from virtual work: + +\[ +R_q=L^T R_d. +\] + +This is not equivalent to selecting force residual entries that happen to lie on the surface. The transpose accumulates the work of every interior and vacuum displacement response induced by each surface parameter. + +## 8. Surface deformation prescription contract + +### 8.1 Compile-time contract + +A concrete prescription should satisfy a `SurfaceDeformationPrescription` concept. The exact spelling may evolve during implementation, but the concept must require the following semantic operations: + +- validation independent of the mesh where possible; +- compilation against a concrete surface trace discretization and reference geometry; +- a parameter count after compilation; +- evaluation from parameters to surface vector displacement; +- derivative action; +- derivative-transpose action; +- a runtime descriptor of invariant capabilities. + +The hot loops must remain in concrete compiled implementations. Type erasure, if used, should dispatch once per complete vector operation, not once per surface DOF or quadrature point. + +### 8.2 Runtime contract + +The root prepared operator should be able to consume different compiled prescriptions through a common runtime interface. Consumers need reliable answers to questions such as: + +- parameter dimension; +- spatial dimension; +- whether the prescription is linear on the reference geometry; +- whether it produces radial-only, normal-only, or general vector surface motion; +- whether exact derivative-transpose application is available; +- whether the prescription requires a star-shaped reference surface; +- whether the prescription itself removes translation or orientation modes. + +The runtime contract should expose capabilities, not a closed enumeration of every concrete prescription type. Adding `SphericalHarmonicSurface` should not require editing a central `switch` statement. + +### 8.3 Nonlinear future prescriptions + +A future prescription may use shape-dependent normals or another nonlinear parameterization. The general deformation map is then \(d=\mathcal D(q)\), with Jacobian \(D\mathcal D(q)\). + +The physical shape residual is + +\[ +R_q=D\mathcal D(q)^T R_d. +\] + +Its exact Newton action contains two terms: + +\[ +\delta R_q +=D\mathcal D(q)^T\delta R_d ++D\!\left[D\mathcal D(q)^T\right][\delta q]R_d. +\] + +The second, curvature-like term vanishes for every linear reference-domain prescription and extension planned in the initial implementation. The public contract should nevertheless reserve a clearly named operation for it rather than allowing a future nonlinear implementation to silently return an inexact Newton Jacobian. + +## 9. First prescription: `NodalRadialSurface` + +### 9.1 Unknowns + +`NodalRadialSurface` uses one scalar parameter per scalar H1 trace DOF on `StellarSurface`: + +\[ +q=(q_1,\ldots,q_{N_q}). +\] + +At a surface trace node with fixed reference position \(X_i\), reference center \(X_c\), and radial unit vector + +\[ +e_{r,i}=\frac{X_i-X_c}{\lVert X_i-X_c\rVert}, +\] + +the prescribed vector surface displacement is + +\[ +d_{\Gamma,i}=q_i e_{r,i}. +\] + +The new surface point is + +\[ +x_{\Gamma,i}=X_i+q_i e_{r,i}. +\] + +The parameter is a radial displacement with units of length. It is not a radius multiplier and not an arbitrary Cartesian displacement component. + +### 9.2 Reference assumptions + +The first implementation requires: + +- a selected reference center; +- a star-shaped reference stellar surface relative to that center; +- strictly positive reference surface radius in every represented direction; +- one radial intersection of each represented ray with the stellar surface; +- a conforming surface trace; +- the current vector H1 displacement ordering and compatible scalar trace order. + +Construction must fail clearly if these assumptions are not satisfied. + +### 9.3 Surface trace discretization + +The surface parameter space should be an actual scalar trace coordinate system, not a list of every Cartesian component in `FieldBoundaryDofMap`. + +The preferred infrastructure is: + +- a scalar H1 finite-element space using the same scalar collection/order as the displacement field; +- a `SurfaceFieldDofMap` or boundary-supported `FieldDofMap` containing only true DOFs on `StellarSurface`; +- deterministic parallel reduced ordering; +- a vector surface trace workspace used internally by the compiled prescription. + +This requires extending field-support machinery to represent boundary-supported fields. It should not reuse the enthalpy trace as the geometry coordinate system merely because both happen to be scalar H1 traces. + +### 9.4 Linear operator representation + +The compiled nodal-radial prescription is a linear operator + +\[ +P:\mathbb R^{N_q}\rightarrow\mathbb R^{N_{\Gamma,d}}. +\] + +For each surface scalar coefficient, \(P\) writes the coefficient into the three vector components weighted by the fixed reference radial direction. Its transpose computes radial virtual work: + +\[ +(P^T f_\Gamma)_i=e_{r,i}\cdot f_{\Gamma,i}. +\] + +The implementation should provide exact `Mult` and `MultTranspose` operations. An explicit sparse parallel operator is preferred because it makes the transpose identity auditable and applications cheap. + +### 9.5 Gauge consequences + +The prescription cannot represent tangential surface relabeling. Rigid rotation of a spherical reference surface is therefore absent from the shape coordinates. + +The extension described below fixes the reference center, so exact rigid translation of the complete mesh is also absent. A nodal radial pattern with dominant \(\ell=1\) content can still move the surface approximately like a translation while deforming the anchored interior. The solver must monitor center of mass, and a future surface-space translation projection or center-of-mass condition may still be desirable. + +## 10. Extensible stellar-interior deformation + +### 10.1 Policy contract + +An `InteriorDeformationExtension` must: + +- accept a complete vector displacement trace on `StellarSurface`; +- produce volume displacement values on stellar-supported displacement DOFs; +- reproduce the supplied trace exactly; +- enforce a documented center behavior; +- provide derivative and derivative-transpose actions; +- report whether it is linear and whether it requires a radial foliation; +- avoid writing vacuum-only DOFs; +- support parallel true-DOF ownership. + +The interface should operate on vector surface traces so it can be reused by radial, normal, spherical-harmonic, or future general-vector surface prescriptions. + +### 10.2 First implementation: `PowerLawRadialInteriorExtension` + +The initial interior extension uses fixed reference rays. For a reference point + +\[ +X=X_c+r\omega, +\] + +let \(R_\Gamma^0(\omega)\) be the reference surface radius and let \(d_\Gamma(\omega)\) be the surface displacement interpolated where that ray meets the reference surface. Define + +\[ +d_\star(X) +=\left(\frac{r}{R_\Gamma^0(\omega)}\right)^p +d_\Gamma(\omega). +\] + +The first configured power should be + +\[ +p=2. +\] + +This choice: + +- reproduces the supplied displacement at the stellar surface; +- fixes the center exactly; +- makes the displacement vanish at least quadratically toward the center; +- is linear in the surface trace; +- is inexpensive to apply; +- agrees with the radial interior extension pattern already exercised in the domain-mapper tests. + +The power should be a validated construction option, but the first production model should use two rather than expose an untested menu to end users. + +### 10.3 Radial correspondence construction + +For every volume displacement interpolation node, the compiled extension must know how to evaluate the surface trace at the node's reference direction. This should be precomputed once from the reference mesh: + +1. compute the reference direction from the selected center; +2. intersect the ray with the reference stellar surface; +3. locate the corresponding surface element; +4. evaluate its trace basis at the intersection; +5. store the interpolation row and radial weight. + +The resulting extension is a sparse linear operator. The center is handled as an explicit zero row because its direction is undefined. + +The construction must validate that every noncentral stellar node obtains exactly one valid surface correspondence. + +### 10.4 Future interior policies + +The same contract should later permit: + +- harmonic extension; +- linear-elastic extension; +- pseudoelastic extension with radius-dependent stiffness; +- modal analytic extension for spherical harmonics; +- transfinite interpolation; +- mesh-quality-optimized nonlinear extension. + +If an extension requires an elliptic solve, it should not automatically be eliminated into the reduced surface operator. See Section 17. + +## 11. Extensible vacuum deformation + +### 11.1 Policy contract + +A `VacuumDeformationExtension` must: + +- accept the same surface vector displacement trace as the interior policy; +- reproduce it exactly at `StellarSurface`; +- produce volume displacement values on vacuum-supported displacement DOFs; +- satisfy a documented condition at `InfinitySurface`; +- provide derivative and derivative-transpose actions; +- be compatible with the exterior compactification map; +- avoid writing stellar-interior-only DOFs except for the shared surface trace; +- support parallel true-DOF ownership. + +### 11.2 First implementation: `FixedInfinityRadialVacuumExtension` + +For a reference vacuum point on the ray \(X=X_c+r\omega\), let \(R_\Gamma^0(\omega)\) be the stellar-surface radius and let \(R_\infty^0(\omega)\) be the reference radius of the finite boundary that is mapped to physical infinity. Define + +\[ +d_{\rm vac}(X) +=\frac{R_\infty^0(\omega)-r} + {R_\infty^0(\omega)-R_\Gamma^0(\omega)} +d_\Gamma(\omega). +\] + +This extension: + +- equals the supplied surface displacement at the stellar boundary; +- vanishes at the reference infinity boundary; +- is linear in the surface trace; +- is compatible with retaining a fixed compactification boundary; +- agrees with the exterior radial decay pattern already exercised in the domain-mapper tests. + +As in the interior, surface correspondence and interpolation rows should be precomputed once. + +### 11.3 Future vacuum policies + +The same contract should later permit: + +- harmonic vacuum extension; +- compactification-coordinate-weighted extension; +- elastic or Winslow-type mesh smoothing; +- analytic modal continuation; +- a moving finite outer boundary, if a future compactification requires it. + +## 12. Composed prepared deformation operator + +The prepared geometry component should own or reference three compiled operators: + +\[ +P:q\mapsto d_\Gamma, +\] + +\[ +E_\star:d_\Gamma\mapsto d_\star, +\] + +\[ +E_{\rm vac}:d_\Gamma\mapsto d_{\rm vac}. +\] + +It should expose the composed map + +\[ +L:q\mapsto d. +\] + +Required operations for the initial linear implementation are: + +- `BuildVolumeDisplacement(q, d)`; +- `ApplyJacobian(qDirection, dDirection)`; +- `ApplyJacobianTranspose(dDual, qDual)`; +- access to parameter, surface-trace, and full displacement sizes; +- access to descriptors and reference-geometry validation reports; +- preparation statistics and dependency stamps where rebuilding can occur. + +The composed implementation need not form the matrix product \(EP\). It can apply + +\[ +q\xrightarrow{P}d_\Gamma\xrightarrow{E}d +\] + +and + +\[ +f_d\xrightarrow{E^T}f_\Gamma\xrightarrow{P^T}f_q. +\] + +Keeping the factors separate preserves extensibility and avoids unnecessary fill. + +The merge between interior and vacuum extensions must have explicit ownership rules: + +- shared stellar-surface DOFs are assigned once; +- stellar-only DOFs come from the interior extension; +- vacuum-only DOFs come from the vacuum extension; +- infinity-surface DOFs satisfy the vacuum policy; +- no DOF is silently left uninitialized or written inconsistently. + +## 13. Revised coupled block system + +### 13.1 Value and residual blocks + +The root value blocks become + +```text +[rho, surface_deformation, gravity_gradient, + gravity_potential, enthalpy, bernoulli_constant] +``` + +and the root residual blocks become + +```text +[gravity_gradient_relation, poisson, + barotropic_closure, surface_shape_equilibrium, + hydrostatic_equilibrium, mass_normalization] +``` + +In mathematical notation: + +\[ +x_{\rm new}=[\rho,q,g,\Phi,h,C], +\] + +\[ +R_{\rm new}=[R_g,R_\Phi,R_\rho,R_q,R_h,R_M]. +\] + +The root operator remains square because the full displacement value and residual blocks are both replaced by parameter-sized surface deformation blocks. + +### 13.2 Compile-time coupling metadata + +The new Jacobian form is: + +| Residual | \(\rho\) | \(q\) | \(g\) | \(\Phi\) | \(h\) | \(C\) | +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| \(R_g\) | | yes | yes | yes | | | +| \(R_\Phi\) | yes | yes | yes | | | | +| \(R_\rho\) | yes | yes | | | yes | | +| \(R_q\) | yes | yes | yes | | yes | | +| \(R_h\) | | yes | | yes | yes | yes | +| \(R_M\) | yes | yes | | | | | + +The graph is physically unchanged. Every former displacement column becomes a surface-deformation column through the lift \(L\), and the former mechanical residual row becomes a surface-shape row through \(L^T\). + +Recommended compile-time block names are: + +```cpp +surface_deformation::radial_parameters::value +surface_deformation::shape_equilibrium::residual +``` + +The value-block type should not be named after `NodalRadialSurface`, because a future spherical-harmonic prescription must use the same root block role. A more representation-neutral final spelling such as + +```cpp +surface_deformation::parameters::value +``` + +is preferable if the nested block system permits it cleanly. + +### 13.3 Child gravity layout + +The internal gravity operator currently packs + +```text +[rho, d, g, Phi] +``` + +and should continue to do so. It is an internal child layout, not the root nonlinear layout. `PreparedStellarEquilibriumOperator` will construct \(d=Lq\) before packing the gravity state. + +This avoids forcing the gravity implementation to know how the surface is represented. + +### 13.4 Mechanical row pullback + +`PreparedDisplacementResidualOperator` should continue to assemble the full-volume mechanical dual vector + +\[ +R_d=R_d^{\rm pressure}+R_d^{\rm gravity}+R_d^{\rm rotation}. +\] + +The root composer then constructs + +\[ +R_q=L^T R_d. +\] + +The same rule applies to Jacobian actions. For a complete reduced direction + +\[ +\delta x=[\delta\rho,\delta q,\delta g,\delta\Phi,\delta h,\delta C], +\] + +first construct + +\[ +\delta d=L\delta q. +\] + +Then use the existing child actions to obtain + +\[ +\delta R_d +=J_{d\rho}\delta\rho ++J_{dd}\delta d ++J_{dg}\delta g ++J_{dh}\delta h, +\] + +and return + +\[ +\delta R_q=L^T\delta R_d. +\] + +For the initial linear deformation map, no second-derivative term is present. + +### 13.5 Pressure surface row + +The constant-pressure condition remains a row replacement in the hydrostatic/enthalpy residual block. It is evaluated on the same topological stellar boundary after the geometry has been generated from \(q\). + +The surface deformation parameter count need not equal the number of enthalpy surface rows. The geometric and thermodynamic surface spaces have different roles and may have different finite-element orders in the future. + +### 13.6 Centering row + +The current `PreparedCenteringConstraint` replaces three full displacement-force rows. Those rows no longer exist in the root layout and the constraint cannot be retained unchanged. + +For the first radial extension, the volume map fixes the reference center exactly. This removes exact rigid translation from the admissible geometry family. The old center row replacement should therefore be removed from the reduced root operator, while center-of-mass displacement remains a required diagnostic. + +A later surface-space gauge may explicitly remove three translation-like modes or impose center of mass. That decision should be based on the conditioning of the reduced system rather than carried over mechanically from the full displacement formulation. + +## 14. Residual and Jacobian execution flow + +### 14.1 Preparation at a nonlinear state + +At each root `Prepare` call: + +1. extract \(\rho,q,g,\Phi,h,C\) from the reduced block vector; +2. apply the prepared deformation map to build the full displacement \(d=Lq\); +3. validate that the mapped geometry is finite and orientation-preserving; +4. pack \(\rho,d,g,\Phi\) into the existing gravity child state; +5. prepare gravity, closure, hydrostatic, mechanical, and mass children at the same generated geometry; +6. prepare the pressure surface constraint from the carrier field; +7. build the full mechanical residual \(R_d\); +8. apply \(L^T\) to obtain \(R_q\); +9. concatenate the six reduced residual blocks. + +The generated displacement must be cached for the complete frozen Newton linearization. + +### 14.2 Matrix-free Jacobian application + +For each root `Mult` call: + +1. extract \(\delta\rho,\delta q,\delta g,\delta\Phi,\delta h,\delta C\); +2. compute \(\delta d=L\delta q\); +3. call all existing child Jacobian actions with \(\delta d\); +4. obtain the full mechanical action \(\delta R_d\); +5. compute \(\delta R_q=L^T\delta R_d\); +6. concatenate the reduced action blocks. + +No prescription or extension should reprepare during `Mult`. + +### 14.3 Performance requirement + +For the first linear implementation, one reduced root Jacobian application should add only: + +- one surface-prescription forward application; +- one domain-extension forward application; +- one domain-extension transpose application; +- one surface-prescription transpose application. + +These operations should be sparse or matrix-free linear maps with no iterative solves and no per-application mesh searches. + +## 15. Dependency and lifetime design + +### 15.1 Root dependencies + +The public `StellarEquilibriumDependencies` should replace its independent `displacement` stamp with a `surfaceDeformation` stamp. + +The prepared domain deformation owns a derived generated-geometry revision. When the surface parameters change, it rebuilds the cached displacement and advances that geometry revision. Child operators continue receiving a displacement dependency stamp constructed from the generated geometry identity and revision. + +This preserves the existing child contracts while making the root dependency semantically correct. + +### 15.2 Discretization changes + +The compiled ray correspondences and sparse lift/restriction maps depend on: + +- mesh identity and sequence; +- displacement finite-element space identity and sequence; +- surface trace space identity and sequence; +- reference center; +- domain schema and boundary attributes; +- reference compactification boundary. + +Any change to these inputs invalidates the compiled deformation object. Adaptive mesh changes should require reconstruction initially; incremental update is a deferred feature. + +### 15.3 Model ownership + +`StellarModel` should own: + +- the structure prescription; +- the physical surface-condition prescription; +- the surface deformation prescription; +- the interior deformation-extension prescription; +- the vacuum deformation-extension prescription. + +These objects are configuration-level policies and should retain stable addresses across model moves, following the current structure/surface ownership pattern. + +Compilation against `FEM` and the reference geometry should occur when constructing the prepared coupled operator, because the user-facing model does not own a discretization. + +### 15.4 User-facing construction + +The intended construction should read approximately as: + +```cpp +models::StellarModel model{ + structure::PolytropicStructure{equationOfState, targetMass}, + surface::ConstantPressureSurface{eos::PressureValue{0.0}}, + deformation::NodalRadialSurface{referenceCenter}, + deformation::PowerLawRadialInteriorExtension{2}, + deformation::FixedInfinityRadialVacuumExtension{} +}; +``` + +Exact constructor organization may use a named aggregate to avoid a long positional argument list. Astronomer-facing names should communicate physical meaning without requiring knowledge of MFEM. + +## 16. Compile-time and runtime extensibility + +The architecture should follow the same broad principle adopted for the EOS: + +- concrete types satisfy compile-time concepts; +- compilation produces optimized concrete vector operations; +- a small runtime bridge permits heterogeneous models to reach common consumers; +- runtime dispatch occurs once per whole vector operation, outside quadrature loops. + +Separate concepts should exist for: + +- `SurfaceDeformationPrescription`; +- `InteriorDeformationExtension`; +- `VacuumDeformationExtension`. + +The concepts should reject incomplete implementations at compile time. Runtime validation should reject incompatible combinations such as: + +- a radial prescription on a non-star-shaped reference surface; +- mismatched spatial dimensions; +- an extension that cannot reproduce the selected surface trace space; +- a vacuum extension that does not define infinity-boundary behavior; +- missing derivative-transpose support; +- a nonlinear prescription that does not provide its pullback-derivative term. + +## 17. Analytic versus elliptic extensions + +The reduced surface-only block is the correct first implementation because the selected extensions are explicit linear maps. + +Future harmonic or elastic extensions require a separate architectural decision. If + +\[ +K_{ii}d_i=-K_{i\Gamma}d_\Gamma, +\] + +then evaluating the eliminated map + +\[ +d_i=-K_{ii}^{-1}K_{i\Gamma}d_\Gamma +\] + +requires a linear solve. Its transpose requires an adjoint solve. Hiding both inside every root Jacobian application may be expensive and may obscure preconditioning. + +For such policies, two valid realizations should be supported eventually: + +1. **Eliminated extension:** use a reusable factorization or strongly preconditioned solve inside the deformation map when the cost is demonstrably acceptable. +2. **Auxiliary mesh block:** retain interior/vacuum displacement as auxiliary unknowns and expose the harmonic or elastic mesh equation as a sparse block. + +The second realization still treats the surface as the only physical geometric unknown. The additional volume coefficients are deterministic mesh variables, not physical stellar coordinates. + +The extension policy should therefore describe the mathematical extension, while a separate realization choice determines whether it is explicitly eliminated or retained as an auxiliary block. + +No auxiliary block is needed for `PowerLawRadialInteriorExtension` or `FixedInfinityRadialVacuumExtension`. + +## 18. Expected interaction with the preconditioner + +The reduced geometry block is + +\[ +J_{qq}=L^TJ_{dd}L +\] + +for the first linear deformation map. Other geometry couplings are similarly lifted or pulled back: + +\[ +J_{gq}=J_{gd}L, +\qquad +J_{\Phi q}=J_{\Phi d}L, +\qquad +J_{qg}=L^TJ_{dg}, +\] + +and so forth. + +The surface block is much smaller than the current full displacement block and excludes the measured tangential and vacuum gauge families by construction. This should improve conditioning before any sophisticated preconditioner is introduced. + +The first preconditioner need not form \(L^TJ_{dd}L\) exactly. Possible approximations include: + +- a diagonal or lumped surface response; +- a surface mass plus surface Laplacian operator; +- a low-order radial stiffness approximation; +- a spherical-harmonic diagonal approximation once that prescription exists; +- a geometry-gravity Schur approximation. + +The block metadata must describe the reduced surface coupling from the start so later preconditioner code does not depend on obsolete full-displacement sizes. + +## 19. Code cross section + +### 19.1 High-impact files + +| Area | Current file | Required change | +|---|---|---| +| Root block form | `libmeanfield/interface/utils/blocks.cppm` | Replace root displacement value/residual roles with surface-deformation parameter/equilibrium roles; preserve internal gravity and pressure-force forms where they still describe full displacement | +| Root interface | `libmeanfield/interface/operators/prepared_stellar_equilibrium.cppm` | Own/expose prepared deformation, revise dependencies/report/accessors, remove root centering operator | +| Root implementation | `libmeanfield/impl/operators/prepared_stellar_equilibrium.cpp` | Build full displacement from surface parameters, lift directions, pull back mechanical residual/actions, revise layouts and gravity packing | +| Stellar model | `libmeanfield/interface/models/stellar_model.cppm` | Own deformation and extension prescriptions; disambiguate physical surface-condition naming | +| FEM | `libmeanfield/interface/fem.cppm`, `libmeanfield/impl/fem.cpp` | Add scalar surface-trace discretization or the scalar space from which its trace map is built | +| Build registration | `CMakeLists.txt` | Register new interface, implementation, and test translation units | + +### 19.2 New focused components + +Likely new modules include: + +```text +libmeanfield/interface/deformation/surface_prescription.cppm +libmeanfield/interface/deformation/nodal_radial_surface.cppm +libmeanfield/interface/deformation/interior_extension.cppm +libmeanfield/interface/deformation/vacuum_extension.cppm +libmeanfield/interface/deformation/radial_extensions.cppm +libmeanfield/interface/deformation/prepared_domain_deformation.cppm + +libmeanfield/impl/deformation/nodal_radial_surface.cpp +libmeanfield/impl/deformation/radial_extensions.cpp +libmeanfield/impl/deformation/prepared_domain_deformation.cpp +``` + +The final directory and module names should remain compact. `deformation` is preferable to placing geometric parameterization under the existing physical `surface` namespace. + +### 19.3 Moderate-impact infrastructure + +| Area | Required change | +|---|---| +| Field registry | Register a scalar surface-deformation quantity or explicitly document why a dedicated trace map is not a registered volume field | +| Field-DOF support | Add boundary-supported scalar true-DOF resolution with deterministic MPI ordering | +| Test tags | Add semantic tags such as `surface_deformation`, `interior_extension`, `vacuum_extension`, and `deformation_pullback` to `test_helpers` | +| Seed/state helpers | Construct a zero surface state for spherical seeds and project requested radial surface perturbations | +| Dependency helpers | Translate root surface-deformation revisions into generated displacement revisions for child operators | + +### 19.4 Components expected to remain unchanged + +The following production kernels and prepared child operators should not require semantic changes: + +- `DomainMapper` point/volume/face/variation kernels; +- mapped H(div) gravity mass and source kernels; +- gravity divergence and boundary operators; +- barotropic closure kernels; +- hydrostatic equilibrium kernels; +- pressure displacement-force kernels; +- gravity displacement-force kernels; +- rotational displacement-force kernels; +- mass-normalization kernels; +- EOS pressure-surface conversion; +- prepared pressure-surface row enforcement. + +They will continue receiving full displacement states and directions from their existing prepared contexts. + +### 19.5 Tests with substantial migration impact + +The largest changes will occur in: + +- `tests/operators/prepared_stellar_equilibrium.cpp`; +- coupled-state helpers in `tests/test_helpers.cppm`; +- `experiments/stellar_null_space.cppm`; +- `experiments/coupled_gauge_modes.cpp`; +- other experiments that address the root displacement block directly; +- block-layout tests in `tests/utils/blocks.cpp`; +- stellar-model ownership and type-contract tests. + +Individual child-operator tests should remain full-displacement tests and should not be rewritten merely because the root coordinates changed. + +## 20. Existing tests that must change + +### 20.1 Block-layout tests + +Tests must assert: + +- the new value and residual ordering; +- the surface block size equals the selected prescription's parameter size; +- the root operator remains square; +- every geometry-dependent residual advertises the surface-parameter coupling; +- internal gravity forms still advertise full displacement coupling; +- invalid or duplicate block types fail at compile time as before. + +### 20.2 Prepared stellar-equilibrium composition tests + +Tests that currently compare the root Jacobian with restricted child Jacobians must change from direct displacement restriction to the exact reduced relation + +\[ +J_{\rm reduced}=Q^TJ_{\rm full}Q, +\] + +where \(Q\) is identity on nongeometry blocks and \(L\) on the geometry block. + +In particular, the mechanical row comparison must use \(L^T\), not surface-row extraction. + +### 20.3 Complete centered-difference tests + +Finite differences must perturb surface parameters, regenerate the volume displacement for the plus and minus states, and compare against the matrix-free reduced Jacobian action. + +The test must include simultaneous variations in surface parameters and other coupled fields so routing errors cannot cancel in isolated columns. + +### 20.4 Centering tests + +Tests asserting replacement of center force rows should be retired from the reduced root operator. They should be replaced with tests that verify: + +- the interior extension fixes the center; +- rigid translation is not representable by the composed map; +- center of mass is reported for representative reduced states; +- translation-like nodal radial patterns remain finite and do not create an exact algebraic null direction. + +### 20.5 Null-space experiments + +The tangential and vacuum-only root directions should no longer be constructible. The existing experiment should be retained as historical evidence or adapted to compare the old full-displacement operator with the reduced lift range. + +New reduced experiments should probe: + +- uniform radial scale/homology direction; +- low-order \(\ell=1\)-like nodal radial patterns; +- quadrupolar/oblate surface deformation; +- high-frequency nodal radial deformation; +- conditioning as surface resolution changes. + +Rigid rotation of a spherical surface should be absent from the radial parameter space rather than merely producing a small Jacobian response. + +## 21. New tests + +All test names and tags must describe physical or computer-science meaning, not implementation phases. + +### 21.1 Compile-time contract tests + +Add tests that: + +- accept `NodalRadialSurface` as a `SurfaceDeformationPrescription`; +- reject types missing validation, compilation, forward, derivative, or transpose operations; +- accept both initial extension policies under their respective concepts; +- reject an interior policy used as a vacuum policy and vice versa; +- verify const-correct references and noncopyable/movable ownership decisions; +- verify that physical surface-condition and geometric deformation types cannot be interchanged. + +Suggested semantic tags: + +```text +[surface_deformation][type_contract][unit] +[interior_extension][type_contract][unit] +[vacuum_extension][type_contract][unit] +``` + +### 21.2 Surface trace topology tests + +Add tests that: + +- identify exactly the scalar H1 trace DOFs on `StellarSurface`; +- exclude interior-only and vacuum-only scalar DOFs; +- preserve shared parallel surface DOFs; +- produce sorted, unique, deterministic reduced ordering; +- distinguish `StellarSurface` from `InfinitySurface` through `DomainSchema` rather than raw attributes; +- work with alternate schema attribute bindings; +- fail clearly for nonconforming spaces until explicitly supported. + +### 21.3 `NodalRadialSurface` tests + +Add tests that: + +- map each unit parameter to radial vector displacement at the corresponding surface node; +- produce no tangential component relative to the reference ray; +- reproduce uniform radial expansion; +- reproduce a known quadrupolar radial pattern; +- preserve physical units and sign; +- reject a surface node at the reference center; +- reject a non-star-shaped or multiply intersected reference surface; +- match centered differences; +- satisfy the adjoint identity + + \[ + \langle P\delta q,f_\Gamma\rangle + =\langle\delta q,P^Tf_\Gamma\rangle. + \] + +### 21.4 Interior extension tests + +Add tests that verify: + +- exact reproduction of arbitrary vector trace data at `StellarSurface`; +- exact zero displacement at the center; +- quadratic radial weight at known radii for the initial power two; +- no writes to vacuum-only DOFs; +- continuous shared interface values; +- linearity; +- centered-difference agreement; +- transpose identity; +- successful construction on the production spherical reference mesh; +- failure when a radial correspondence cannot be established. + +### 21.5 Vacuum extension tests + +Add tests that verify: + +- exact reproduction of arbitrary vector trace data at `StellarSurface`; +- exact zero displacement at `InfinitySurface`; +- the expected linear reference-radius weight; +- no writes to stellar-interior-only DOFs; +- continuous shared interface values; +- compatibility with the compactified mapper; +- linearity; +- centered-difference agreement; +- transpose identity; +- failure for a missing or incompatible infinity boundary. + +### 21.6 Composed deformation tests + +Add tests that verify: + +- every full displacement true DOF is assigned exactly once under the merge rules; +- interior and vacuum traces agree exactly; +- `BuildVolumeDisplacement` maps zero surface parameters to zero displacement; +- the composed map equals separate prescription and extension applications; +- the composed transpose reverses the operation order correctly; +- the adjoint identity + + \[ + \langle L\delta q,f_d\rangle + =\langle\delta q,L^Tf_d\rangle + \] + + holds to a tolerance appropriate for parallel floating-point reductions; +- mapped Jacobian determinants remain positive for bounded representative surface amplitudes; +- invalid amplitudes that invert elements are detected before physics assembly. + +### 21.7 Reduced root-operator tests + +Add tests that verify: + +- the root geometry state has parameter size rather than displacement size; +- child preparation receives exactly \(d=Lq\); +- every former displacement-column action equals the corresponding full action applied to \(L\delta q\); +- the reduced mechanical residual equals \(L^TR_d\); +- the reduced complete Jacobian equals the lifted/restricted full child Jacobian; +- simultaneous centered differences match every coupled block; +- pressure-surface row replacement remains exact; +- mass normalization sees the generated geometry; +- selective invalidation rebuilds geometry only when surface parameters or discretization change; +- repeated `Mult` calls do not rebuild the deformation map or reprepare children. + +### 21.8 Physical and convergence tests + +Add tests or experiments that verify: + +- the spherical Lane-Emden seed is unchanged at zero deformation; +- a uniform positive surface parameter produces expansion everywhere with the specified weights; +- a quadrupolar radial deformation produces the intended oblate/prolate sign pattern; +- the reduced analytic equilibrium accuracy remains at the established projection floor; +- the result converges under surface and volume refinement; +- changing only the nonphysical extension policy in a future comparison does not change the converged physical surface beyond discretization error. + +### 21.9 Performance tests + +Record, without fragile wall-clock regression thresholds: + +- surface parameter count; +- volume displacement count; +- nonzeros or work estimates in \(P\), \(E\), and their transpose paths; +- number of geometry rebuilds per nonlinear preparation; +- number of lift and pullback applications per Jacobian action; +- time fractions for lift, physics action, and pullback in experiments. + +The initial implementation must perform no ray search, sparse assembly, or iterative solve during a Krylov `Mult`. + +## 22. Migration plan + +Each implementation phase must run the repository `./format` script before compilation. Tests and tags must use semantic physical or software meanings, never phase numbers. + +### Phase A: terminology and contracts + +- Rename the existing physical `SurfacePrescription` concept and accessors to explicitly mention `SurfaceCondition`. +- Add compile-time concepts and descriptors for surface deformation, interior extension, and vacuum extension. +- Add negative compile-time contract tests. +- Make no root block change yet. + +### Phase B: scalar surface trace coordinates + +- Add boundary-supported scalar DOF resolution. +- Construct the stellar-surface scalar trace map with deterministic parallel ordering. +- Add topology, schema, ordering, and MPI tests. +- Keep the current root displacement system unchanged. + +### Phase C: `NodalRadialSurface` + +- Compile fixed reference radial directions. +- Implement parameter-to-vector-trace forward and transpose actions. +- Add analytic, finite-difference, and adjoint tests. +- Keep the current root displacement system unchanged. + +### Phase D: radial interior and vacuum extensions + +- Build and validate reference radial correspondences. +- Implement `PowerLawRadialInteriorExtension` with power two. +- Implement `FixedInfinityRadialVacuumExtension`. +- Implement exact transpose paths. +- Add support, boundary, weight, continuity, determinant, and adjoint tests. +- Keep the current root displacement system unchanged. + +### Phase E: composed prepared domain deformation + +- Compose prescription, interior extension, vacuum extension, and merge ownership. +- Add cached full displacement and direction workspaces. +- Add dependency and statistics reporting. +- Test the complete lift and pullback independently of stellar physics. + +### Phase F: stellar-model ownership + +- Extend `StellarModel` to own the three geometry policy objects. +- Compile them against `FEM` during prepared-operator construction. +- Preserve stable addresses and move semantics. +- Add model ownership and runtime-view tests. + +### Phase G: root block migration + +- Replace the root displacement block with the surface-deformation parameter block. +- Generate full displacement during `Prepare`. +- Lift surface directions during `Mult`. +- Pull back the full mechanical residual and Jacobian action. +- Replace the root displacement dependency with surface deformation plus derived geometry revision. +- Remove the root centering-row replacement. +- Keep internal child displacement layouts unchanged. + +This should be treated as one coherent migration rather than leaving two production root forms indefinitely. + +### Phase H: coupled-test and experiment migration + +- Update root state builders and block-layout tests. +- Update exact restricted-Jacobian and centered-difference tests. +- Replace obsolete root tangential/vacuum probes with reduced-space reachability and conditioning probes. +- Re-run analytic equilibrium and rotating-response coverage. + +### Phase I: solver-readiness audit + +- Measure reduced block sizes and action costs. +- Recheck gauge directions and homology. +- verify minimum mapped determinant under representative oblate deformations; +- define the first surface-block preconditioner approximation; +- confirm that the reduced operator is square and suitable for nonlinear solver wiring. + +## 23. Risk register + +### 23.1 Incorrect residual restriction + +**Risk:** selecting force entries on the surface instead of applying \(L^T\) loses interior virtual work and produces an inconsistent reduced Jacobian. + +**Mitigation:** require exact adjoint identities and compare the reduced root action with an independently lifted/pulled-back full action. + +### 23.2 Missing nonlinear pullback term + +**Risk:** a future shape-dependent prescription supplies \(D\mathcal D(q)\) but omits the derivative of its transpose, producing an inexact Newton operator. + +**Mitigation:** reserve and test the pullback-derivative contract now; mark the initial policies explicitly linear. + +### 23.3 Radial correspondence failure + +**Risk:** rays fail to intersect the surface uniquely, particularly after mesh changes or for non-star-shaped reference geometry. + +**Mitigation:** compile correspondences on the reference mesh, validate uniqueness, and fail construction with geometric diagnostics. + +### 23.4 Mesh inversion + +**Risk:** high-amplitude or high-frequency nodal deformation produces negative mapped determinants. + +**Mitigation:** validate determinants during nonlinear preparation, globalize nonlinear steps, report the minimum determinant, and later add smoothing or a stronger extension policy. + +### 23.5 Extension-dependent discrete equilibrium + +**Risk:** different interior/vacuum extensions yield measurably different discrete surface shapes because the reduced virtual-work space changes. + +**Mitigation:** retain full physical field equations, use consistent pullback, test refinement convergence, and later compare at least two extension policies. + +### 23.6 Translation and orientation + +**Risk:** anchoring the center removes exact translation but leaves weak translation-like or orientation-like surface patterns. + +**Mitigation:** monitor center of mass, repeat reduced null-space probes, and add a surface-space gauge only if evidence requires it. + +### 23.7 Parallel trace ownership + +**Risk:** shared surface DOFs are duplicated, omitted, or inconsistently ordered across ranks. + +**Mitigation:** reuse FieldDof synchronization principles and add explicit multi-rank topology and adjoint tests. + +### 23.8 Compiler and runtime overhead + +**Risk:** extensibility introduces virtual calls or allocation in per-element or per-quadrature loops. + +**Mitigation:** compile prescriptions into prepared vector operators, dispatch once per complete operation, preallocate workspaces, and prohibit ray searches or assembly during `Mult`. + +### 23.9 Over-restricting the physical shape family + +**Risk:** a radial surface representation cannot describe a later non-star-shaped or materially multivalued surface. + +**Mitigation:** keep the root block semantic (`surface deformation parameters`) independent of the concrete prescription and make the prescription replaceable at model construction. + +### 23.10 Test churn masking regressions + +**Risk:** broad root-vector changes cause established physics tests to be weakened unnecessarily. + +**Mitigation:** leave child-operator full-displacement tests intact and change only tests whose subject is the root coordinate system or root composition. + +## 24. Acceptance criteria for the phase + +The phase is complete only when all of the following hold: + +1. The root nonlinear state contains surface deformation parameters and no independent volume displacement block. +2. `NodalRadialSurface` satisfies the compile-time and runtime prescription contracts. +3. The first interior and vacuum extension policies satisfy their separate contracts. +4. The composed map supplies exact forward, derivative, and transpose operations. +5. No ray search, matrix assembly, allocation proportional to mesh size, or iterative extension solve occurs during a root Jacobian application. +6. The reduced mechanical residual is \(L^TR_d\), not a selected subset of force rows. +7. All existing child physics kernels remain reusable with full generated displacement vectors. +8. The physical constant-pressure surface condition remains EOS-mediated and independent of geometry representation. +9. Adjoint identities pass for the prescription, both extensions, and the complete map. +10. Complete reduced Jacobian actions match centered differences. +11. The spherical equilibrium accuracy remains at the established discrete projection floor. +12. Representative radial and quadrupolar deformations preserve positive mapped determinants within documented amplitude bounds. +13. Tangential and vacuum-only mesh directions are absent from the root nonlinear coordinate space. +14. Test tags and names are semantic and registered through `test_helpers`. +15. The full project test suite passes after migration. + +## 25. Deferred prescriptions and extensions + +### 25.1 `SphericalHarmonicSurface` + +A future spherical-harmonic prescription can use + +\[ +R(\theta,\phi) +=R_0(\theta,\phi) ++\sum_{\ell,m}a_{\ell m}Y_{\ell m}(\theta,\phi). +\] + +Its parameter vector consists of \(a_{\ell m}\), but it compiles to the same vector surface-trace contract. It may encode axisymmetry, reflection symmetry, translation removal, and angular truncation directly through its selected modes. + +No root block or physics operator should need to change when it is added. + +### 25.2 Modal interior and vacuum extensions + +Spherical harmonics make analytic mode-dependent extensions possible. Regular interior radial factors and compactification-compatible exterior factors could replace the nodal ray interpolation while preserving the same prepared domain-deformation contract. + +### 25.3 Elastic extensions + +An elastic extension can use the already registered mesh-extension form and may be realized either as an eliminated map or an auxiliary sparse block. That choice should be based on measured solve cost and conditioning, not fixed by the prescription interface. + +## 26. Final architectural boundary + +The durable boundary should be: + +```text +physical surface condition + ConstantPressureSurface + EOS + | + | independent of + v +surface deformation prescription + q -> vector displacement on StellarSurface + | + v +interior and vacuum extension policies + surface displacement -> full volume displacement + | + v +existing mapped physics operators + full displacement -> residuals and Jacobian actions + | + v +transpose pullback + full mechanical dual -> surface-parameter residual +``` + +This boundary permits future researchers to change how a stellar surface is represented and how the computational mesh follows it without changing the EOS, the physical surface condition, or the verified element-level physics. diff --git a/docs/preconditioning_campaign_2026-09-03.md b/docs/preconditioning_campaign_2026-09-03.md new file mode 100644 index 0000000..8b5f960 --- /dev/null +++ b/docs/preconditioning_campaign_2026-09-03.md @@ -0,0 +1,96 @@ +# Stellar-equilibrium preconditioning campaign — 2026-09-03 + +## Scope + +This campaign established and tested a compile-time composable right-preconditioner for the current bordered stellar-equilibrium Jacobian. The reference problem is the zero-rotation, `n = 3` polytrope with an isobaric surface, fixed total mass, and fixed central density, discretized on `sandbox.smesh` and linearized at the projected Lane–Emden seed. + +The complete operator remains matrix-free. Sparse or dense matrices are assembled only for individual surrogate blocks. + +## Recommended policy before the nonlinear-solver phase + +The best-supported complete-system policy retains: + +- an uncalibrated surface Riesz diagonal; +- surface-then-material triangular factorization; +- independent material/surface and gravity subsystems; +- gravity approximate LDU; +- an exact dense Schur correction for the small specification border. + +The automatic gravity policy now uses an order-5 fixed matrix-free Chebyshev inverse for the mapped H(div) mass operator and three fixed BoomerAMG cycles for the potential Schur surrogate. The low-level composition API can still select order 4 for a slightly cheaper application, diagonal mass inversion, and any valid fixed-cycle configuration. + +Surface scalar calibration, the surface H1 mass-plus-stiffness surrogate, material approximate LDU, and cross-subsystem stellar factorizations remain explicit opt-in experiments. None has earned promotion to the default. + +## What the measurements showed + +### Material–surface subsystem + +The uncalibrated surface-then-material factorization reached a true relative residual of approximately `6.42e-6` in 40 iterations on the manufactured subsystem problem. Operator-action scalar calibration improved that isolated residual to approximately `1.38e-6`, and material LDU with the same calibration reached approximately `1.11e-6`. + +That isolated improvement did not transfer to the complete operator. The surface Jacobian spans roughly three orders of magnitude across tested surface modes, so a single scalar cannot be a uniformly accurate inverse. A right-preconditioned-action fit was added because it optimizes the operation actually used by `J M^-1`; it reduced the isolated balanced recovery defect from approximately `1.010` to `0.848` and was stable from one to sixteen probes. It is retained as an experiment, not a default. + +The H1 mass-plus-stiffness surface surrogate also remains experimental. Its fit residual and extreme gains showed that this two-term local differential model does not reliably represent the restricted surface Jacobian on the current discretization. + +### Gravity subsystem + +With a diagonal H(div) mass inverse and one fixed BoomerAMG cycle, gravity approximate LDU was decisively better than block-diagonal or one-sided triangular factorizations. It reached `1.59e-8` after 100 iterations, versus approximately `1e-6` for the triangular policies and `2.84e-3` for block diagonal. + +The new backend sweep separated two approximation errors: + +- the mapped H(div) mass inverse; +- the scalar potential-Schur inverse supplied by fixed-cycle BoomerAMG. + +Two AMG cycles reduced the measured Schur recovery defect from `0.169` to `0.083` and made the diagonal-mass gravity solve converge in 87 iterations. A fixed matrix-free Chebyshev polynomial applied to the exact partially assembled H(div) mass operator improved the coupled gravity iteration count much more strongly. Representative converged results were: + +| H(div) mass inverse | AMG cycles | iterations | solve time (s) | true relative residual | +|---|---:|---:|---:|---:| +| diagonal | 2 | 87 | 5.43 | `8.51e-9` | +| Chebyshev order 3 | 2 | 47 | 5.82 | `8.27e-9` | +| Chebyshev order 4 | 2 | 44 | 6.80 | `8.49e-9` | +| Chebyshev order 4 | 3 | 43 | 7.36 | `7.23e-9` | +| Chebyshev order 5 | 3 | 42 | 8.48 | `8.00e-9` | + +For the isolated gravity solve, order 3–4 is the useful quality/cost region: order 5 buys only one or two additional iterations at materially higher preconditioner cost. The complete-system comparison below nevertheless shows that order 5 earns its small end-to-end cost there. MFEM 4.9.1 supports Chebyshev orders only in `[1, 5]`; the backend now validates that range and turns an otherwise fatal MFEM abort into an ordinary configuration error. + +### Complete bordered stellar operator + +On the block-balanced manufactured correction, the original complete default reached a true relative residual of `3.47e-4` in 36 iterations. Identity reached only `0.783`. Every previously tested calibrated or more strongly coupled composition was worse (`0.0107`–`0.0413`) and substantially more expensive. + +Holding every other block and factorization fixed, two AMG cycles alone produced only `3.46e-4`. Chebyshev order 3 with two AMG cycles improved this to `2.24e-4`; Chebyshev order 4 with three AMG cycles reached `1.43e-4`; and the final order-5/AMG-3 challenger reached `1.19e-4`. Relative to order 4, order 5 reduced the fixed-budget residual by another 16.7% and the correction error from `2.844` to `2.219`, while increasing the matched total solve time only from `181.16` s to `182.00` s. Relative to the diagonal-mass control, it reduced the fixed-budget residual by 65.6%. This evidence supports promoting Chebyshev-5/AMG-3 as the automatic gravity default, while retaining order 4 as the nearby quality/cost option. + +For that default result, 93.8% of the final squared residual was in the gravity-gradient equation and 6.2% was in Poisson balance. Material, surface, and specification-border residuals were negligible globally. This motivated the H(div) Chebyshev work rather than further surface tuning. + +The short Arnoldi projection is useful as a diagnostic, but not as a policy oracle. In the complete system the identity operator had a much smaller 12-vector projected condition proxy than the successful default even though its FGMRES result was dramatically worse. True residual reduction and Jacobian applications therefore remain the primary decision metrics. + +### Physical Newton right-hand side + +The physical experiment solves `J delta = -R(u)` at the same projected Lane–Emden state and evaluates seven damped nonlinear trial states for every correction. All trial states in the first comparison were finite, nonnegative in density, and orientation preserving. + +The physical right-hand side exposed a solver-level issue that the manufactured problem does not: all candidates stagnated at the 20-vector restart. The current default reduced the true linear residual only to `0.999800` in 48 iterations; uncalibrated material-then-gravity reached `0.999092` but cost more than three times as much per solve. The right-hand side is dominated by barotropic closure (`3.51e-5`), while its gravity rows are around `1e-11`; the unscaled Euclidean Krylov norm and restart therefore do not provide a physically balanced convergence measure. This is evidence for explicit equation scaling and restart studies in the solver phase, not evidence that the complete preconditioner should adopt the expensive cross factorization. + +## Correctness and lifecycle guarantees added + +- Fixed and adaptive backend application modes remain distinct; only stationary-linear configurations are Arnoldi admissible. +- The matrix-free Chebyshev backend owns its diagonal and essential-DOF list, uses a deterministic power estimate, is fixed-order linear, and reconstructs itself on geometry refresh. +- Chebyshev configuration validates polynomial order, power-iteration count, a strictly positive random seed, a power-method tolerance in `(0, 1)`, size, and strict positivity of the assembled mass diagonal. +- Gravity composition is generic over a compile-time compatible mass backend, so the new backend propagates through the stellar-structure and specification-border layers without a runtime type registry. +- Prepared composite blocks explicitly prohibit unsafe copying and moving when their factorization stores pointers into their own members. +- Unit tests cover backend compatibility, linearity, positivity, statistics, invalid configuration, and composite ownership. Integration tests cover geometry staleness and explicit refresh without repreparing the physical geometry. +- The focused gravity integration suite passes with both one and two MPI ranks. It applies the Chebyshev-backed block after an ordinary geometry refresh, then forces a discretization revision that reconstructs the underlying matrix-free operator, verifies that stale application and stale inverse access are rejected, refreshes the owner, and verifies finite repeatable post-rebind actions. Full numerical quality comparisons in this campaign remain single-rank. + +## Reproducible artifacts + +- `preconditioning_p9_surface_calibration_objectives.csv` +- `preconditioning_p10_full_stellar.csv` +- `preconditioning_p10_physical_newton.csv` +- `preconditioning_p4_gravity_chebyshev_sweep_v2.csv` +- `preconditioning_p10_gravity_backend_finalists.csv` +- `preconditioning_p10_gravity_backend_finalists_v2.csv` + +The experiment sources are `experiments/material_surface_preconditioning.cpp`, `experiments/gravity_preconditioning.cpp`, and `experiments/full_stellar_preconditioning.cpp`. + +## Remaining work + +1. In the solver phase, introduce physically meaningful equation/value scaling and compare restarted with unrestarted or larger-restart FGMRES on the physical Newton right-hand side. +2. Validate the selected policy across mesh refinement, nonzero rotation, deformed geometry, and more than one MPI rank. +3. Revisit surface preconditioning only with a richer surrogate or multilevel representation; do not promote a scalar or H1 fit solely from isolated measurements. +4. Treat adaptive inner solves as FGMRES-only and exclude them from fixed-operator Arnoldi studies. diff --git a/docs/profiling_and_mpi.md b/docs/profiling_and_mpi.md new file mode 100644 index 0000000..d692e15 --- /dev/null +++ b/docs/profiling_and_mpi.md @@ -0,0 +1,126 @@ +# Profiling and MPI validation + +This guide describes the profiling and distributed-test tools added after +checkpoint `25510008ddf9810eaf9821149734d526c76b087a`. + +## Build modes + +The profiling macros compile out by default. For a profiling build, enable them +and leave IPO off so profile attribution is easier to interpret: + +```bash +./format +cmake -S . -B cmake-build-profile-homebrew-llvm -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DMEAN_FIELD_ENABLE_PROFILING=ON \ + -DMEAN_FIELD_ENABLE_IPO=OFF +./format +cmake --build cmake-build-profile-homebrew-llvm --target tests mpi_tests -j 6 +``` + +For production timings, use profiling off and IPO on: + +```bash +./format +cmake -S . -B cmake-build-release-homebrew -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DMEAN_FIELD_ENABLE_PROFILING=OFF \ + -DMEAN_FIELD_ENABLE_IPO=ON +./format +cmake --build cmake-build-release-homebrew --target tests mpi_tests experiments -j 6 +``` + +Always run `./format` immediately before a command that can compile. + +## Bounded test runner + +`tools/profile_tests.py` accepts a Catch2 filter, rank counts, repetitions, and +a per-run timeout. It writes one log per run and updates the summary CSV after +every repetition. + +```bash +python3 tools/profile_tests.py \ + --executable cmake-build-profile-homebrew-llvm/tests \ + --ranks 1 --repeat 1 --timeout 360 \ + --output-directory /private/tmp/meanfield-profile \ + "Gravity Field Virial Consistency Across Volume Preserving Deformation" \ + -- --reporter console +``` + +MPI sweep: + +```bash +python3 tools/profile_tests.py \ + --executable cmake-build-release-homebrew/tests \ + --ranks 1,2,4 --repeat 3 --timeout 360 \ + --output-directory /private/tmp/meanfield-rank-sweep \ + "Gravity Field Virial Consistency Across Volume Preserving Deformation" +``` + +Arguments after `--` go to Catch2. Runner options may appear before or after the +filter. On timeout, the runner sends `SIGTERM` to the entire process group, +waits for `--kill-grace`, and then sends `SIGKILL` if necessary. This prevents +MPI workers from surviving a timed-out launcher. + +The CSV records rank count, repetition, status, exit code, wall time, timeout, +log path, and exact command. Profile-region output is in the corresponding log. + +## Profile columns + +- `Samples`: recorded observations after warmups. +- `Warmups`: discarded warmup observations. +- `Work/rank`: minimum-to-maximum work-unit count across ranks. +- `Avg max ms`: maximum per-rank average duration. +- `Min ms` and `Max ms`: global extrema across all samples and ranks. +- `Total max s`: maximum accumulated duration on any rank. + +MPI aggregation first unions label sets across ranks. A region may therefore be +visited on only one rank without causing collective-order divergence. + +## MPI regression suite + +The dedicated `mpi_tests` executable covers distributed semantics without +replicating the complete serial suite on every process. Registered CTest jobs +use two and four ranks: + +```bash +ctest --test-dir cmake-build-release-homebrew \ + --label-regex mpi --output-on-failure --timeout 180 +``` + +Or build and run through the convenience target (remember that the target can +compile, so format first): + +```bash +./format +cmake --build cmake-build-release-homebrew --target check_mpi +``` + +The suite checks communicator splitting, global element coverage, mapped H(div) +mass symmetry and linearity, divergence adjointness, a complete distributed +gravity analysis/solve, and profiling with rank-local labels. Catch2 uses a +fixed synchronized seed so all ranks enter tests and collectives in the same +order. Only rank zero emits the report. + +The current measurements use multiple MPI ranks on one host. The suite is +appropriate for a multi-node launcher, but inter-host fabric behavior still +needs validation on actual multi-node hardware. + +## Known high-level limitation + +The reduced gravity and virial paths run on multiple ranks. The complete +spectral equilibrium experiment does not yet do so because logical radial +surface interpolation explicitly requires one rank. Supporting that path needs +distributed ownership and transpose-consistent communication for interpolation +rows whose surface element may be remote. This is a separate architectural +feature, not a failure of the corrected gravity kernels. + +## Current reference results + +- Prepared spectral Jacobian, one rank: 3.964707 s. +- Gravity virial: 205.843 s (one rank), 118.926 s (two), 65.210 s (four). +- Centrifugal h refinement: 254.984 s, historically 525.601 s. +- Release MPI CTest jobs: 18.12 s (two ranks), 10.11 s (four ranks). +- Full release suite: 410 cases and 5,086,850 assertions passed in 2,807.831 s. + +No tolerance or resolution was changed to obtain these results. diff --git a/docs/stellar_physics_extension_guide.md b/docs/stellar_physics_extension_guide.md new file mode 100644 index 0000000..22106d5 --- /dev/null +++ b/docs/stellar_physics_extension_guide.md @@ -0,0 +1,700 @@ +# Extending stellar-model physics + +MeanField treats a stellar model as a compile-time collection of independent physics specifications. An EOS, a surface condition, an integral constraint, and a phase condition each describe only their own contribution. The library then infers the model type, generated scalar blocks, residual blocks, Jacobian edges, normalization, and manifest entries from that collection. + +The important rule is: **add one declaration and, where needed, one implementation per physics specification—never a specialization for a whole combination of constraints.** A new integral must compose with every already-supported EOS, surface, and other constraint without naming those combinations. + +Every downstream layer folds the same canonical specification pack. The symbolic compiler appends generated blocks and Jacobian edges, the manifest and normalization layers project metadata for those exact blocks, the prepared equilibrium operator owns one statically typed runtime slot per specification, and the default preconditioner folds the corresponding border actions. “Variadic” therefore means one inferred C++ type and one inferred block system for each pack, not a runtime list and not a library of hand-written pack combinations. + +## The small physics-facing contract + +A specification is an ordinary physics class with: + +- a `Parameters` aggregate; +- a constructor from `Parameters`; and +- one nested `ModelDefinition` made from a role-specific alias. + +The declaration is the single source used by `SpecificationTraits` and `SpecificationContribution`; an extension author does not specialize either trait. + +### EOS + +The EOS still uses the existing relation vocabulary. `ModelDefinition` only tells the stellar-model compiler which role the object has. + +```cpp +class LinearBarotrope final { +public: + struct Parameters final { + double densityPerEnthalpy; + }; + + using ModelDefinition = mean_field::eos::ConstitutiveLaw< + LinearBarotrope, + "LinearBarotrope">; + + using Relations = mean_field::eos::RelationCatalog< + mean_field::eos::DensityFromSpecificEnthalpy>; + + explicit constexpr LinearBarotrope(Parameters p) noexcept + : m_densityPerEnthalpy(p.densityPerEnthalpy) { + } + + [[nodiscard]] constexpr mean_field::dimensions::DensityValue evaluate( + mean_field::eos::DensityFromSpecificEnthalpy, + mean_field::dimensions::SpecificEnthalpyValue h + ) const noexcept { + return mean_field::dimensions::DensityValue{m_densityPerEnthalpy * h.value()}; + } + + [[nodiscard]] constexpr auto partialDerivative( + mean_field::eos::DensityFromSpecificEnthalpy, + mean_field::eos::WithRespectTo, + mean_field::dimensions::SpecificEnthalpyValue + ) const noexcept { + return mean_field::eos::PartialDerivative< + mean_field::dimensions::quantity::Density, + mean_field::dimensions::quantity::SpecificEnthalpy>{m_densityPerEnthalpy}; + } + +private: + double m_densityPerEnthalpy; +}; + +static_assert(mean_field::eos::EquationOfStateModel); +static_assert(mean_field::models::ModelSpecification); +``` + +The explicit quantity type is what prevents accidental pressure/density/enthalpy interchange. + +### Surface condition + +A surface condition declares the physical quantity it fixes. Describing a new condition is intentionally separate from implementing its finite-element carrier equation. + +```cpp +class IsothermalSurface final { +public: + struct Parameters final { + mean_field::dimensions::TemperatureValue Tsurface; + }; + + using ModelDefinition = mean_field::surface::BoundaryCondition< + IsothermalSurface, + "IsothermalSurface">; + using PhysicalQuantity = mean_field::dimensions::quantity::Temperature; + using TargetValue = mean_field::dimensions::TemperatureValue; + + explicit constexpr IsothermalSurface(Parameters p) noexcept + : m_target(p.Tsurface) { + } + + [[nodiscard]] constexpr TargetValue targetTemperature() const noexcept { + return m_target; + } + +private: + TargetValue m_target; +}; +``` + +### Integral constraint + +An integral says which stellar state variables it reads (`stellar::Reads`), which equations its generated coordinate changes (`stellar::Changes`), how its generated value and residual are normalized, and how they appear in diagnostics. The ordinary declaration uses physics names; the equilibrium compiler translates them to numerical blocks. + +```cpp +class PreparedFixedBaryonMass; + +class FixedBaryonMass final { +public: + struct Parameters final { + mean_field::dimensions::MassValue Mbaryon; + }; + + using ScalarDescription = mean_field::stellar::ScalarConstraint< + mean_field::dimensions::quantity::Mass, + mean_field::dimensions::quantity::SpecificEnergy, + mean_field::dimensions::quantity::Mass, + "fixed_baryon_mass.multiplier", "C_b", + "fixed_baryon_mass.residual", "R_Mb">; + using TargetValue = typename ScalarDescription::TargetValue; + + using ModelDefinition = mean_field::integral::FixedScalarWithMultiplier< + FixedBaryonMass, + "FixedBaryonMass", + mean_field::stellar::Reads< + mean_field::stellar::state::Density, + mean_field::stellar::state::SurfaceShape>, + mean_field::stellar::Changes< + mean_field::stellar::equation::HydrostaticBalance>, + ScalarDescription>; + + using EquilibriumPhysics = + mean_field::operators::LocalSpecificationEquilibriumPhysics< + PreparedFixedBaryonMass>; + + explicit FixedBaryonMass(Parameters p) : m_target(p.Mbaryon) { + } + + // This conventional accessor lets the generic manifest read the target. + [[nodiscard]] TargetValue target() const noexcept { + return m_target; + } + +private: + TargetValue m_target; +}; +``` + +`FixedIntegralWithPhysicalCoordinate` has the same declaration shape, but its generated scalar is a physical state coordinate rather than a Lagrange multiplier. `FixedAngularMomentum`, whose generated coordinate is angular velocity, is the current example. + +`stellar::ScalarConstraint` names three independent physical quantities: the +target, the generated Newton coordinate, and the appended constraint residual. +The wrapper derives numerical scale laws and diagnostic unit labels from those +strong quantity types, so a total energy cannot accidentally be normalized as +a specific energy merely because two free-form strings agree. The quantities +need not be the same: `FixedCentralDensity` has a density target and a +specific-enthalpy phase residual. The wrapper contains no assembly logic. The +lower-level `FixedIntegralWithMultiplier` spelling remains available for +unusual compatibility declarations; opting either its normalization or +manifest into typed dimensional metadata requires the complete typed pair. The +current generated-constraint +compiler supports exactly one scalar value and one scalar residual per +generated specification; a distributed or multi-component generated quantity +requires an explicit extension of that compiler boundary and is not obtained by +switching to the lower-level spelling. + +If the target and appended residual have different quantity types, the +specification also supplies the physical reference used to report and scale +that residual: + +```cpp +[[nodiscard]] mean_field::dimensions::SpecificEnergyValue +residualReference() const noexcept { + return m_enthalpyReference; +} +``` + +The return type must be exactly the `QuantityValue` for +`ScalarDescription::ConstraintResidualQuantity`. Omitting this accessor, or +returning the target's quantity instead, makes +`CompilableRootManifestFor` false. Thus a density target cannot +silently provide the scale for an energy-valued equation. The built-in +`FixedCentralDensity` phase condition is the deliberate special case: its +specific-enthalpy reference is derived from the selected EOS when the +constraint is compiled. + +When an integral residual also depends on the coordinate generated by that same specification, use `stellar::state::OwnGeneratedCoordinate`. The compiler resolves that relative name to the specification's exact generated type. Prefer the exported `mean_field::stellar::{state,equation}` names and the `stellar::Reads`, `stellar::Changes`, `stellar::ScalarConstraint`, and `stellar::Derivative` wrappers in ordinary physics code. These wrappers are aliases over the compiler's canonical types: they improve readability without introducing a parallel implementation or weakening any compile-time check. + +### Phase condition + +A phase condition uses the same vocabulary but generates a solver-border coordinate. It fixes a degeneracy; it is not automatically a new physical invariant. + +```cpp +class FixedCentralEnthalpy final { +public: + struct Parameters final { + mean_field::dimensions::SpecificEnthalpyValue hcenter; + }; + + using ScalarDescription = mean_field::stellar::ScalarConstraint< + mean_field::dimensions::quantity::SpecificEnthalpy, + mean_field::dimensions::quantity::SpecificEnthalpy, + mean_field::dimensions::quantity::SpecificEnthalpy, + "fixed_central_enthalpy.border", "lambda_hc", + "fixed_central_enthalpy.residual", "R_hc">; + using TargetValue = typename ScalarDescription::TargetValue; + + using ModelDefinition = mean_field::constraint::ScalarPhaseCondition< + FixedCentralEnthalpy, + "FixedCentralEnthalpy", + mean_field::stellar::Reads< + mean_field::stellar::state::SpecificEnthalpy>, + mean_field::stellar::Changes< + mean_field::stellar::equation::HydrostaticBalance>, + ScalarDescription>; + + explicit FixedCentralEnthalpy(Parameters p) : m_target(p.hcenter) { + } + + [[nodiscard]] auto target() const noexcept { + return m_target; + } + +private: + TargetValue m_target; +}; +``` + +Generated arity is inferred by the role alias: a constitutive law or boundary condition generates no scalar pair, while the three scalar constraint aliases each generate one balanced scalar value/residual pair. This zero-or-one-pair rule is the deliberate current generated-constraint boundary, not a claim that arbitrary-rank new fields are already supported. A malformed normalization or manifest remains inspectable but fails its corresponding completeness query. + +### Exact residual and Jacobian providers + +A declaration determines the complete provider topology. The nested `EquilibriumPhysics` alias above connects that declaration to its numerical formulas. + +Most prepared physics classes keep the simple `Physics(const Specification&)` +constructor. A specification which declares both `state::Density` and +`state::SurfaceShape` in `Reads` may instead request the current physical +density-volume integral through +`stellar::DensityVolumeIntegralContext`: + +- `integrateDensity(rho)` evaluates the distributed, mapped-volume finite-element + integral and returns `dimensions::MassValue`; +- `linearizeDensityIntegral(deltaRho)` returns its exact density action; and +- `linearizeSurfaceShapeIntegral(deltaShape)` returns its exact geometry action. + +Both declarations are mandatory because `integral rho dV` depends on the +deformed volume. A density-only declaration is rejected before the context type +can be formed, so it cannot hide a missing geometry column in the inferred +Jacobian. A future fixed/reference-geometry integral would be a separate, +explicitly named service rather than a weakened form of this one. +The context is a small, copyable, non-owning handle. It exposes neither the +physical core nor FEM objects, uses no heap type erasure, and delegates global +reduction and reusable scratch storage to the physical core. Its numerical +methods are valid in `PrepareAfterPhysical` and subsequent residual, Jacobian, +or border-action work, after the core has prepared the current state; a physics +constructor should only retain the handle. This service is deliberately scoped +to the current core's density-volume integral rather than being an untyped +general-purpose backend escape hatch. + +`PreparedFixedBaryonMass` is still an ordinary concrete class. It does not need +a model template, block list, offset table, or MFEM access. A representative +context-using shape is: + +```cpp +class PreparedFixedBaryonMass final { +public: + using Report = BaryonMassPreparationReport; + using IntegralContext = + mean_field::stellar::DensityVolumeIntegralContext; + + PreparedFixedBaryonMass( + const FixedBaryonMass &specification, + IntegralContext integrals + ) : m_target(specification.target()), m_integrals(integrals) { + } + + template + [[nodiscard]] Report PrepareAfterPhysical(const State &state) { + // Only names from stellar::Reads plus this specification's generated + // coordinate are visible here. + const auto density = state.density(); + const auto multiplier = state.generatedCoordinate(); + m_currentMass = m_integrals.integrateDensity(density); + m_massResidual = m_currentMass.value() - m_target.value(); + prepareHydrostaticTerm(multiplier); + m_prepared = true; + return {}; + } + + template + [[nodiscard]] auto AddResidual( + mean_field::stellar::equation::OwnConstraint, + Row &row + ) const { + return row.add(m_massResidual); + } + + template + [[nodiscard]] auto AddResidual( + mean_field::stellar::equation::HydrostaticBalance, + Row &row + ) const { + return row.add(m_hydrostaticTerm); + } + + template + [[nodiscard]] auto AddJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::OwnConstraint, + mean_field::stellar::state::Density>, + const Direction &direction, + Row &row + ) const { + return row.add( + m_integrals.linearizeDensityIntegral(direction.density()).value()); + } + + template + [[nodiscard]] auto AddJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::OwnConstraint, + mean_field::stellar::state::SurfaceShape>, + const Direction &direction, + Row &row + ) const { + return row.add(m_integrals + .linearizeSurfaceShapeIntegral(direction.surfaceShape()).value()); + } + + // Reads is conservative for all changed equations. This particular + // derivative is mathematically absent, so say that explicitly. + template + [[nodiscard]] mean_field::stellar::StructuralZero AddJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::HydrostaticBalance, + mean_field::stellar::state::Density>, + const Direction &, + Row & + ) const noexcept { + return mean_field::stellar::zeroDerivative; + } + + template + [[nodiscard]] mean_field::stellar::StructuralZero AddJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::HydrostaticBalance, + mean_field::stellar::state::SurfaceShape>, + const Direction &, + Row & + ) const noexcept { + return mean_field::stellar::zeroDerivative; + } + + template + [[nodiscard]] auto AddJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::HydrostaticBalance, + mean_field::stellar::state::OwnGeneratedCoordinate>, + const Direction &direction, + Row &row + ) const { + return row.add(applyMultiplierDerivative( + direction.generatedCoordinate())); + } + + [[nodiscard]] bool IsPrepared() const noexcept { + return m_prepared; + } + +private: + // Physics data and helper functions; no solver block bookkeeping. + FixedBaryonMass::TargetValue m_target; + IntegralContext m_integrals; + mean_field::dimensions::MassValue m_currentMass{0.0}; + double m_massResidual{0.0}; + double m_hydrostaticTerm{0.0}; + bool m_prepared{false}; +}; +``` + +The hydrostatic helper formula above is a placeholder for the extension's actual physics; the integral and its two directional actions are the real typed service contract. The compiler derives these calls from `Reads` and `Changes`: + +- it requests one `AddResidual(EquationTag, Row&)` provider for the specification's own constraint equation and for each distinct equation in `Changes`; +- it requests `OwnConstraint` derivatives with respect to every state in `Reads`; and +- it requests each `Changes` equation's derivatives with respect to every state in `Reads` plus `OwnGeneratedCoordinate`. + +The compiler, not the physics class, enumerates and invokes this set. Every Jacobian call receives a direction containing exactly one declared state and a row that can modify exactly one declared equation. Named accessors such as `density()`, `specificEnthalpy()`, and `generatedCoordinate()` are available only when that source is the selected derivative. `value()` is the backend-independent accessor for a one-source extension tag that does not yet have a named convenience accessor. + +Every provider returns one of two statements: + +- return the token from `row.add(scalar_or_vector)` or, for a residual entry, `row.addEntry(index, value)`; or +- return `stellar::structuralZero` for an intentionally zero residual contribution, and conventionally `stellar::zeroDerivative` for an intentionally zero derivative. + +This is stronger than a broad callback that is merely callable. Omitting one inferred overload makes `CompleteStellarEquilibriumPhysicsProvider` false at compile time. Within a callback, adding twice is rejected at runtime. Returning `ContributionAdded` without one addition, or returning `StructuralZero` after an addition, is also rejected immediately. Separate specifications may still add to the same assembled equation; additive composition across specification slots is intentional. + +The exact row API exposes no assignment, mutable root vector, block offset, or MFEM alias synchronization. Consequently one specification cannot redirect a derivative to another legal row or erase the physical core or another specification's contribution. The lower-level `SpecificationEquilibriumPhysics` spelling remains available when a concrete implementation genuinely depends on the complete `Model` type, but it uses this same exact adapter and guarantees. + +For the current isobaric surface, the pressure condition owns selected rows of the specific-enthalpy equation. Exact residual and Jacobian rows automatically suppress contributions on those replaced rows. A constraint computes its ordinary full hydrostatic contribution; it does not reproduce the surface-row mask. + +## Composition and model access + +Construction order does not affect the inferred type: + +```cpp +auto model = mean_field::model::StellarModel( + LinearBarotrope({.densityPerEnthalpy = 2.0}), + IsothermalSurface({.Tsurface = mean_field::dimensions::TemperatureValue{1.0e6}}), + FixedBaryonMass({.Mbaryon = mean_field::dimensions::MassValue{1.0}}), + FixedCentralEnthalpy({.hcenter = mean_field::dimensions::SpecificEnthalpyValue{0.25}}) +); + +using Model = std::remove_cvref_t; + +static_assert(Model::template specificationRoleCount< + mean_field::models::SpecificationRole::invariant> == 1); + +const auto &eos = model.equationOfState(); +const auto &surface = model.surfaceCondition(); +const auto &mass = model.specification(); +``` + +`equationOfState()` is always available because a valid model has exactly one constitutive law. `surfaceCondition()` and `specificationForRole()` are available only when that role is unique. Roles such as integral constraints may occur many times; use `SpecificationsForRole` at compile time and `specification()` at runtime. + +Stable names are identities within a role. Two independently developed invariants therefore choose descriptive stable names; they do not coordinate global numeric ordinals. Reusing a stable name in the same role is rejected at compile time, while the same readable name may intentionally appear in two different roles. Manifest lookup always compares the full `(role, stable name)` identity. + +### Coupling constraints to one another + +Global constraints are not required to be mutually independent. A specification can refer to another specification in the same model using physics-level owner tags: + +```cpp +class CoupledVirialCondition final { +public: + using ModelDefinition = mean_field::integral::FixedScalarWithMultiplier< + CoupledVirialCondition, + "CoupledVirialCondition", + mean_field::stellar::Reads< + mean_field::stellar::state::Density, + mean_field::stellar::state::GeneratedCoordinateOf>, + mean_field::stellar::Changes< + mean_field::stellar::equation::HydrostaticBalance, + mean_field::stellar::equation::ConstraintOf>, + VirialScalarDescription>; + + // Parameters, target(), and the two local physics packages follow the + // same pattern as an uncoupled scalar constraint. +}; +``` + +`GeneratedCoordinateOf` means “read the scalar coordinate owned by this exact specification type.” `ConstraintOf` means “my generated coordinate changes that specification's scalar equation.” `OwnGeneratedCoordinate` and `OwnConstraint` are the corresponding relative names for the declaring specification. These declarations generate ordinary `stellar::Derivative` calls, so cross-constraint formulas use the same exact residual/Jacobian and preconditioner protocols as every other edge. For a cross-generated direction, the equilibrium view's generic one-source accessor is `value()`; a border-preconditioner direction may use `values()` or `generatedCoordinate()`. + +The compiler resolves owner references after collecting the complete canonical specification pack, so declaration order is irrelevant. A referenced owner that is absent, does not generate the requested coordinate/residual, or cannot be mapped into the final form makes symbolic compilation fail. It is never converted into a runtime lookup or silently omitted. + +## Capability ladder + +Description is deliberately easier than executable solver support. Each rung is +an ordinary compile-time capability query, so an unsupported physics component +is rejected at the public overload boundary instead of producing an error deep +inside a constructor or solver body. + +For a model declaration, the first two rungs are: + +```cpp +static_assert(mean_field::models::SelfDescribingModelSpecification); +static_assert(mean_field::models::ModelSpecification); + +static_assert(mean_field::operators::StellarEquilibriumSpecificationCompilable); +static_assert(mean_field::operators::StellarEquilibriumSymbolicallyCompilable); + +static_assert(mean_field::models::CompleteGeneratedScalarDimensionsFor); +static_assert(mean_field::models::CompleteGeneratedNormalizationFor); +static_assert(mean_field::normalization::CompleteGeneratedPhysicalRieszNormalizationFor); +static_assert(mean_field::models::CompleteGeneratedManifestFor); + +using Form = mean_field::operators::CompiledStellarEquilibriumForm; +// This example's new surface has no manifest/runtime adapter yet. +static_assert(!mean_field::operators::CompilableRootManifestFor); + +// These become true after the per-specification runtime, EOS core, and surface +// implementations are added. They are intentionally false for this example. +static_assert(!mean_field::operators::StellarEquilibriumPhysicsAvailableFor< + FixedBaryonMass, Model>); +static_assert(!mean_field::operators::hasCompleteStellarEquilibriumRuntime); +static_assert(!mean_field::operators::hasStellarEquilibriumCoreRuntime); +``` + +`Model::hasCompleteEquilibriumDeclaration` reports only that the model's physics declarations are structurally complete and balanced. It deliberately makes no claim about backend support. The clearly named query for block and Jacobian compilation is `StellarEquilibriumSymbolicallyCompilable`; `StellarEquilibriumSystemCompilable` remains its compatibility spelling. Only `CompiledStellarEquilibriumSystem` exposes an equilibrium-system compilation classification. This distinction lets an astronomer write and inspect a new physical declaration even when one of its physics names has not yet been mapped to a numerical block. + +Once a model has an executable core, surface runtime, manifest, and all of its +per-specification residual/Jacobian actions, it satisfies +`equilibrium::StellarEquilibriumModel` and can be discretized into a `Problem`. +The remaining solver-facing capabilities are then checked on that inferred +problem type: + +```cpp +// The selected discretization prescription has a valid preparation path. +static_assert(mean_field::normalization::NormalizableStellarEquilibriumProblem); + +using PhysicalRieszDiscretization = mean_field::equilibrium::StellarDiscretizationFor< + mean_field::normalization::PhysicalRieszDiagonal<>>; +using PhysicalRieszProblem = mean_field::equilibrium::StellarEquilibriumProblem< + Model, PhysicalRieszDiscretization>; + +// These stronger queries are specifically for the Physical Riesz path. +static_assert(mean_field::normalization::PhysicalRieszStellarEquilibriumProblem< + PhysicalRieszProblem>); +static_assert(mean_field::normalization::NormalizableStellarEquilibriumProblem< + PhysicalRieszProblem>); +static_assert(mean_field::normalization::PhysicalRieszStellarEquilibriumCore< + typename PhysicalRieszProblem::PhysicalCoreType>); + +// The EOS-specific material/surface surrogate supports this exact core. +static_assert(mean_field::preconditioning::MaterialSurfacePreconditionerProblem); +static_assert(mean_field::preconditioning::StellarStructurePreconditionerProblem); + +// Every generated border has an action, and the structure block is supported. +static_assert(mean_field::preconditioning::CompleteSpecificationBorderActionsFor); +static_assert(mean_field::preconditioning::DefaultStellarPreconditionerAvailableFor); +``` + +The complete ladder is: + +1. **Model description:** the object is constructible and has one valid, self-owned definition. +2. **Blocks and Jacobian:** its block lists are valid, generated rows and columns balance, and every declared edge endpoint exists in the final model. The compiler performs two passes, so one constraint may name a block generated by another constraint without depending on declaration order. +3. **Executable equilibrium root:** `StellarEquilibriumCoreRuntime` selects the concrete physical `CoreType` for the constitutive law. Surface compilation/runtime, root-manifest construction, and every specification's nested `EquilibriumPhysics` package (or core-authorized library backend entry) are checked separately. For an ordinary nested package, `CompleteStellarEquilibriumPhysicsProvider` proves that every compiler-enumerated residual row and derivative has an exact provider. Each call receives one read-only source and one additive-only row; the current isobaric row-replacement mask is applied by that row. Passing these checks makes one `PreparedVariadicStellarEquilibriumOperator`; it does not select a hand-written class for the particular constraint pack. +4. **Normalization:** `NormalizableStellarEquilibriumProblem` means the prescription stored in the discretization has a valid `prepareNormalization(problem)` operation. `Unnormalized` is the identity path and therefore remains available to a lean alternate core. A Physical Riesz discretization additionally requires `PhysicalRieszStellarEquilibriumCore`, a structural contract for the density, enthalpy, gravity-gradient, gravity-potential, and surface-deformation maps used to assemble the physical Gram diagonals. It also requires complete per-specification physical-scale contributions. A new core can opt in by exposing that physical interface; it does not inherit from or name the Polytrope core. +5. **Material and stellar-structure preconditioner:** `MaterialSurfacePreconditionerProblem` checks that the EOS-specific material/surface backend is registered for the problem's exact selected core type. `StellarStructurePreconditionerProblem` is the corresponding public capability for the default coupled material/surface-plus-gravity structure block; it currently inherits the same support boundary. +6. **Generated border and full default preconditioner:** `CompleteSpecificationBorderPhysicsProvider` proves the exact border-incident derivatives for one physics action. `CompleteSpecificationBorderActionsFor` folds that requirement over the model's specifications. `DefaultStellarPreconditionerAvailableFor` is the final conjunction of a discretized equilibrium problem, complete per-specification border actions, and a supported stellar-structure block. `makePreconditioner(problem)` is available only at this rung. + +No rung is specialized for a complete EOS/surface/constraint combination. The +model type determines the packs, the compiler folds their declarations, and +each numerical layer folds the corresponding per-component hooks. If a query +fails, implement the missing hook for the individual EOS, surface, integral, or +phase condition; do not weaken the query and do not add a model-pack +specialization. + +### Adding another normalization family + +A normalization prescription is a compile-time discretization choice, not a +model constraint. Adding one does not add cases to `discretize`, the stellar +operator, or the solver-facing adapter. Its public +`NormalizationCompilation` specialization owns both the +complete coordinate plan and the early runtime-compatibility declaration for +the selected physical core and specification pack. The library independently +validates that the advertised plan covers every inferred value and residual +block exactly once. + +Most new policies use only the public problem/manifest interface when they +prepare their diagonal factors. They can reuse +`RuntimePreparedNormalizationCompilation` rather than spelling core or +specification concepts: + +```cpp +namespace my_normalization { +class UniformDiagonal final + : public mean_field::normalization::NormalizationPrescriptionTag { +public: + UniformDiagonal(double stateFactor, double residualFactor); + + [[nodiscard]] double stateFactor() const noexcept; + [[nodiscard]] double residualFactor() const noexcept; +}; +} // namespace my_normalization + +namespace mean_field::normalization { +template + requires mean_field::utils::blocks::block_form_is_valid_v
+struct NormalizationCompilation + : RuntimePreparedNormalizationCompilation< + my_normalization::UniformDiagonal, + Form> { }; +} // namespace mean_field::normalization +``` + +The policy's namespace then provides +`prepareStellarNormalization(const UniformDiagonal&, const Problem&) -> +DiagonalNormalization`. It is found from the exact policy argument carried by +`Problem::NormalizationPrescriptionType`; no central registry or built-in-policy +switch is edited. The generated plan labels every block with +`RuntimePreparedCoordinate` rather than falsely claiming an +identity or Physical Riesz formula, and the returned diagonal is dimension-checked +against the complete inferred problem. `discretize` rejects a policy +whose compilation omits `runtimeAvailableFor`, and +also rejects a third-party runtime policy whose plan does not name that exact +policy on every coordinate. Thus merely providing a preparation function cannot +bypass or contradict the compile-time plan. The same `discretize` boundary +rejects a policy whose preparation operation is absent or returns the wrong +type, before a solver-facing problem is constructed. A policy that requires private facilities +of a particular physical core writes its own `runtimeAvailableFor` predicate in the same compilation specialization; Physical +Riesz is the reference example. Ordinary EOS, surface, and constraint authors +do not use this extension point—their dimensional scalar wrappers continue to +generate the appropriate per-block normalization metadata automatically. + +## Radial seeds and border preconditioning + +The optional numerical hooks also compose per specification. A specification that intentionally leaves a spherical seed unchanged can say so without touching the seed compiler: + +```cpp +using RadialProjection = mean_field::seed::projection::Use< + mean_field::seed::projection::NoStateChange>; +``` + +A specification with a generated coordinate supplies a similarly wrapped physics rule. The rule receives `RadialProjectionScales`, `RadialProjectionState`, and only its own generated coordinate. It does not calculate block offsets or inspect the other constraints. `RadialProfileProjectableModel` is true only when every specification has a complete rule and exactly one rule supplies the radial mass scale. Thus a newly introduced EOS, surface condition, or constraint cannot be advertised as projectable and then fail deep inside the function body. + +For an ordinary generated constraint, no second Jacobian implementation is +needed for border preconditioning. The default adapter projects the same exact +`AddJacobianAction` providers from `EquilibriumPhysics` onto the subset whose +row or source is a generated border. This is why the `FixedBaryonMass` +declaration above needs only its `EquilibriumPhysics` alias: the residual, +Jacobian, and default border preconditioner have one source of numerical +formulas. + +An advanced implementation may deliberately provide a different border +approximation. In that case it opts in explicitly: + +```cpp +using SpecificationBorderPhysics = + mean_field::preconditioning::LocalSpecificationBorderPhysics; +``` + +The action uses the same astronomy-facing `stellar::Derivative` vocabulary as the equilibrium Jacobian. For example, a phase condition declared with `Reads` and `Changes` has two border-incident derivatives: + +```cpp +class MyPreparedBorderAction { +public: + explicit MyPreparedBorderAction(const PreparedMyPhaseCondition &physics); + + template + [[nodiscard]] auto ApplyJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::OwnConstraint, + mean_field::stellar::state::SpecificEnthalpy>, + const Direction &direction, + Row &row + ) const { + return row.add( + evaluatePhaseDerivative(direction.specificEnthalpy())); + } + + template + [[nodiscard]] auto ApplyJacobianAction( + mean_field::stellar::Derivative< + mean_field::stellar::equation::HydrostaticBalance, + mean_field::stellar::state::OwnGeneratedCoordinate>, + const Direction &direction, + Row &row + ) const { + return row.add( + evaluateMultiplierAction(direction.generatedCoordinate()(0))); + } +}; +``` + +The framework derives the full derivative list from `Reads` and `Changes`, then partitions it into structure-to-border, border-to-structure, and border-to-border edges. It invokes only the border-incident edges. Cross-constraint edges are partitioned in exactly the same way, including edges between two generated borders. + +A purely structure-to-structure derivative is checked separately. The default +structure preconditioner accepts it only when the selected physical core names +that exact `(specification, coupling)` as a backend capability or the nested +equilibrium provider returns `stellar::StructuralZero`. A custom nonzero term +on even an otherwise familiar base edge therefore makes +`DefaultStellarPreconditionerAvailableFor` false instead of being +silently dropped. + +Each direction contains exactly one source and each row can update exactly one destination. For this example, asking the first direction for `density()` or the second row to write a gravity-potential equation is ill-formed. An exact-zero border derivative returns `stellar::zeroDerivative`. Missing an incident overload makes `CompleteSpecificationBorderPhysicsProvider` false at compile time; `PreparedSpecificationBorderPhysicsActionFor` additionally checks construction and move requirements. The single-use `row.add(...)` token has the same duplicate and result-consistency checks as equilibrium assembly. + +For an explicit approximation, the action constructor receives only the +prepared equilibrium physics for its own specification. It can snapshot +state-dependent coefficients without access to the enclosing problem, physical +core, or other constraints. `LocalSpecificationBorderPhysics` is the +common wrapper for a concrete local class. `SpecificationBorderPhysics` +is available when the action genuinely needs the inferred `Problem` type; both +names use the same adapter and neither reimplements pack traversal. + +The library handles problem binding, operation partitioning, offsets, alias synchronization, surface-row ownership, and aggregation. The preconditioner lifecycle includes the prepared-operator generation, so changing state-dependent contribution data invalidates and rebuilds the border Schur complement even when the caller reuses the same dependency stamps. + +These contracts establish structural completeness and safe composition; they do not establish preconditioner quality. The current default structure solve and generated-border Schur approximation are provisional numerical choices. A problem satisfying `DefaultStellarPreconditionerAvailableFor` has every required action and a registered backend, but it may still be poorly conditioned or ineffective for a new regime. Iteration counts, scaled spectra, parameter continuation behavior, and nonlinear convergence must be measured separately for each important physical family. + +## What works end to end today + +As of the current checkpoint: + +- arbitrary well-formed self-described specifications can form distinct canonical `StellarModel` types, subject to the model-pack role and balance contracts; +- generated scalar blocks, residual rows, and Jacobian edges are inferred from `stellar::Reads` and `stellar::Changes`, including named edges between constraints; +- the residual/Jacobian runtime and generated-border preconditioner enumerate those inferred rows and edges and require exact providers or explicit structural zeros; +- generated Physical Riesz declarations and generated manifest metadata are projected generically, while Physical Riesz field diagonals are available to any selected core satisfying `PhysicalRieszStellarEquilibriumCore`; +- the variadic outer layers currently wrap a fixed five-field barotropic physical core: density, surface deformation, gravity gradient, gravity potential, and specific enthalpy; +- each generated constraint currently adds exactly one scalar value block and one scalar residual block around that core; +- the executable equilibrium kernels are registered for `eos::Polytrope` with `surface::Isobaric`; +- the default material/surface and stellar-structure preconditioner kernels are likewise registered for that Polytrope/Isobaric backend and its exact physical core; +- per-specification runtime and prepared border actions exist for `FixedTotalMass`, `FixedAngularMomentum`, and `FixedCentralDensity`; and +- `makePreconditioner(problem)` is exposed only when `DefaultStellarPreconditionerAvailableFor` proves both the structure backend and every generated border action are present. + +The five-field statement is a real boundary, not merely a missing convenience alias. A scalar constraint over those existing fields can compose without changing the core. A genuinely new distributed field—magnetic induction, temperature, composition, or another PDE state—requires extending the shared physical-field vocabulary and mapping, the selected executable core and discretization, its residual/Jacobian implementation, normalization metadata, manifest, and the applicable structure preconditioner. Supplying a backend block type in `Reads` does not by itself make that field part of the physical core. This work is still per new field/equation family, not per complete combination of constraints. + +The nonbarotropic EOS and isothermal-surface mocks in the compile-contract tests are symbolic coverage. They prove that EOS choice, surface choice, and constraint packs produce distinct canonical types and correctly inferred block/Jacobian structures; they are not executable stellar models and do not claim a compatible five-field core or default preconditioner. Their rejection by `equilibrium::StellarEquilibriumModel` and `discretize` is intentional. + +A new EOS or surface can therefore be described and type-checked today, but `discretize` will reject it until its per-EOS core or per-surface compilation/runtime support (including carrier/manifest handling) is registered. A new integral or phase condition can obtain its scalar block/Jacobian pair, normalization, and ordinary `target()` manifest structure from `ModelDefinition`, but still needs its residual/Jacobian runtime. The ordinary constraint places that concrete implementation on itself as `EquilibriumPhysics = operators::LocalSpecificationEquilibriumPhysics`; its exact providers are reused automatically by the default generated-border preconditioner. Only a deliberately distinct border approximation adds `SpecificationBorderPhysics`. Neither path specializes a combination-level registry. + +The aggregate `StellarEquilibriumRuntimeContribution` registry is not an alternative astronomy-facing API. It can access the complete model, FEM objects, mapper, dependency stamps, and physical core, so an entry is selected only when the exact specification also appears in the selected concrete core type's `BackendSpecifications` list. A raw specialization—or a custom EOS that merely reuses the built-in core type—cannot add itself to that core's list and is therefore ignored. A custom core author may authorize its own backend entries in that single list; if an authorized entry and a nested `EquilibriumPhysics` package both exist for the same specification, the model is rejected as genuinely ambiguous. Ordinary EOS, surface, and constraint authors should use the restricted nested packages and never manage this list. + +An alternate executable core may choose the identity `Unnormalized` path without implementing Physical Riesz maps. Selecting `PhysicalRieszDiagonal` for that same core fails `PhysicalRieszStellarEquilibriumProblem` and `NormalizableStellarEquilibriumProblem` cleanly. Likewise, an executable problem whose EOS/core pair has no material-surface backend fails `MaterialSurfacePreconditionerProblem`, while a problem with one missing constraint action fails `CompleteSpecificationBorderActionsFor`; neither failure requires nor invites a combination-specific fallback. These are availability and structural-correctness claims, not evidence that the current preconditioner will deliver acceptable Krylov or nonlinear convergence. + +Those remaining hooks are per physics component. Adding `FixedMagneticEnergy` over existing state fields should require work for `FixedMagneticEnergy` itself. Adding a genuinely new magnetic field/equation additionally requires the shared field-family work listed above—not new implementations for `Polytrope + Isobaric + FixedTotalMass + FixedMagneticEnergy`, then another for every other constraint combination. A converged rotating solution and its nonlinear solver remain downstream goals; the compile-time architecture and provisional preconditioner do not, by themselves, establish that convergence result. diff --git a/experiments/SYSTEM_ISOLATION_PROFILE_HARNESS.md b/experiments/SYSTEM_ISOLATION_PROFILE_HARNESS.md new file mode 100644 index 0000000..8e6b19d --- /dev/null +++ b/experiments/SYSTEM_ISOLATION_PROFILE_HARNESS.md @@ -0,0 +1,698 @@ +# Final optimization batch: pressure, gravity source, preparation, and fixed divergence + +Build the targets explicitly; sandbox is excluded from the default build: + +```sh +cmake --build cmake-build-release-homebrew \ + --target system_isolation_profile_harness sandbox tests --parallel +``` + +Run from the repository root with a new output directory: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system final-optimizations --check full --implementation both \ + --samples 5 --iterations 10 --warmup 2 \ + --prepare-samples 3 --prepare-iterations 1 \ + --output final_optimizations_01 +``` + +This group measures: + +- `pressure-displacement`: tensor evaluation of displacement gradients and + tensor gradient transpose, compared with the previous optimized pointwise + application (`cached_pointwise_reference`). The general prepared action + and stateless kernel remain correctness references. +- `pressure-preparation`: batched displacement and enthalpy evaluation and + tensor assembly of the enthalpy derivative matrix, compared with the + previous blocked matrix assembly and pointwise displacement evaluation. +- `gravity-source-displacement`: tensor density/displacement evaluation and + potential-test transpose, compared with the previous pointwise application. +- `hydrostatic-preparation`: batched displacement and scalar evaluation and + tensor assembly of the algebraic matrices, compared with the previous + pointwise/dense preparation. +- `gravity-schur-preparation`: reuse of the fixed divergence matrix during + Schur assembly, compared with assembling divergence on every call. This + timing excludes mass-diagonal preparation and AMG setup, which are unchanged. + +Preparation timings change state/revisions before each measured call and +verify that pressure/hydrostatic preparation actually rebuilds. Schur timings +cycle through mass diagonals prepared from three displacement states. +Cold hydrostatic preparation and cold divergence-cache assembly are reported +separately. These medians measure the incremental benefit of this batch; +they do not reuse the slower baselines from earlier batches. + +Full harness checks compare prepared and stateless derivatives, residuals, +algebraic actions, enthalpy diagonals, multiple states and directions, zero +source directions, and finite differences. Hydrostatic checks also reject +folded geometry and recover afterward. Schur checks compare cached and +fresh assembly, change and restore the mass diagonal, check exact inverse +scaling, clear the cache, and invalidate it with `Mesh::NodesUpdated()`. +New unit coverage checks the scalar gradient transpose against dense +reference gradients and adjoint products for multiple H1/L2 orders/bases, +preparation-mode switches, rejection/recovery, and production gravity-block +cache reuse/invalidation. + +The divergence cache belongs to each prepared gravity block. It retains one +assembled sparse matrix for the block's lifetime. Every refresh still forms +a fresh weighted transpose and Schur matrix using the current mass diagonal. +A discretization revision clears the cache; mesh, node, FE-space sequences, +and quadrature-policy identity are also checked collectively before reuse. +Raw computational-coordinate edits must call `Mesh::NodesUpdated()`. +A standalone cache must be cleared after changing a quadrature policy in +place. Physical displacement changes do not invalidate fixed divergence. + +The tensor paths retain the existing mapper validation, quadrature, DOF +transformations, and fallbacks for unsupported elements/rules. Metadata +reports `pressure_tensor_elements`, `source_tensor_variation_elements`, and +`hydro_preparation_tensor_elements`, plus divergence assembly counts. A zero +tensor count indicates fallback, not measured tensor acceleration. + +The new individual selectors are `gravity-source-displacement`, +`hydrostatic-preparation`, and `gravity-schur-preparation`. +`pressure-displacement` includes its preparation benchmark. This batch is +selected together with `final-optimizations`. + +Return `summary.txt`, `metadata.txt`, `timings.csv`, and `checks.csv`. +After the checks pass, run the rebuilt sandbox with the same preflight +setting as the previous run, and capture its Newton timings and profiles. +Compare the residual, accepted step, trial count, determinant minimum, Krylov +iterations, and true linear residual along with the wall times. The previous +reference was 24.174 s for Newton 0, 8.00964 s trial preparation, 2.14648 s +accepted refresh, and 13.6571 s linear solve (26 J and 24 preconditioner calls). + +These source and test changes have not been compiled or executed by the agent. + +--- + +# Application optimization batch: closure, rotation, primal mass, and trial preparation + +Build both `system_isolation_profile_harness` and `sandbox` explicitly in +`cmake-build-release-homebrew`; sandbox is excluded from the default build. + +Run from the repository root: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system application-optimizations --check full --implementation both \ + --samples 5 --iterations 10 --warmup 2 \ + --prepare-samples 3 --prepare-iterations 1 \ + --output application_optimizations_01 +``` + +The group measures these changes against the implementations immediately before +this batch: + +- `barotropic-closure`, `barotropic-enthalpy`, and `barotropic-displacement`: + tensor interpolation and transpose versus the cached pointwise path. The + dedicated enthalpy and displacement baselines retain their dedicated entry + points, so these ratios measure tensor acceleration without counting the + earlier removal of zero-block work again. +- `rotation-force`: tensor density/displacement interpolation and force + transpose versus the cached pointwise complete action. +- `hdiv-mass`: the new CPU application versus retained MFEM partial assembly + (`mfem_pa_reference`), with identical prepared coefficients, quadrature, + restrictions, orientation handling, and true-DOF transfers. The new integrator + contracts only its marked domain, uses scratch sized to the actual order, + and specializes the current RT2/nine-point-per-axis case. Other supported + CPU orders use the same algorithm with runtime dimensions. Other backends + retain MFEM's application. Mass-only runs use primal preparation. +- `geometry-inspection`: the production trial-geometry validator with cached + computational-mesh inverse Jacobians and tensor gradients, versus the original + `GridFunction::GetVectorGradient` loop. Every volume element, inspection + point, and determinant check is retained. The first cold inspection is + reported separately; paired medians measure reuse with changing displacement. +- `gravity-source-preparation`: reuses the mesh weight already computed during + mapping evaluation, versus recomputing `ElementTransformation::Weight()`. + Both paths rebuild on every measured call; the harness checks preparation + counters and compares forward, transpose, and displacement actions. + +The scalar tensor plans now include nodal H1 and L2 hexes. Basis reconstruction, +native DOF permutations, and Cartesian quadrature ordering are verified before +a plan is enabled. Unsupported elements/rules retain the dense implementation. +Metadata reports `closure_tensor_elements`, `rotation_tensor_elements`, +`fast_mass_elements`, and `geometry_inspection_tensor_elements` so a fallback +cannot be mistaken for acceleration. + +Full checks include general prepared and independent stateless comparisons, +existing finite differences, mass symmetry/linearity/positive energy/zero input, +changing geometries, fold rejection, and non-finite geometry rejection. The +unit-test additions also cover both global vector orderings, curved-mesh cache +invalidation, explicit cache clearing, tetrahedron fallback, and L2 basis +variants. These tests are supplied for the user to compile and run. + +The inspector stores nine doubles (72 bytes) per eligible inspection point, +plus shared reference tables and reusable element scratch. It refreshes fixed +geometry when the mesh, nodes, or finite-element-space sequence changes. +Raw coordinate edits must call `Mesh::NodesUpdated()`, as with MFEM's geometry +cache, or clear a standalone inspector with `ClearCache()`. Displacement is +read anew for every inspection. Its mutable scratch is intended for sequential +calls, consistent with the prepared operators. + +Use a new output directory and return `summary.txt`, `metadata.txt`, +`timings.csv`, and `checks.csv`. Application timings use `--samples` and +`--iterations`; inspection/source preparation timings use `--prepare-samples` +and `--prepare-iterations`. This group omits unrelated force-preparation +benchmarks. Individual systems remain selectable. After checks pass, rebuild +sandbox too and compare its printed Newton timings and convergence against the +previous run. + +--- + +# Runtime optimization batch: tensor applications, geometry cache, normalization + +Build both `system_isolation_profile_harness` and `sandbox` explicitly in +`cmake-build-release-homebrew`; sandbox is excluded from the default build. + +Run from the repository root: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system runtime-optimizations --check full --implementation both \ + --samples 5 --iterations 10 --warmup 2 \ + --prepare-samples 3 --prepare-iterations 1 \ + --output runtime_optimizations_01 +``` + +This group checks and measures: + +- `normalization-diagonals`: H(div), potential, density, enthalpy, and surface + reference-mass diagonals, comparing volume partial assembly and direct surface + diagonal integration to full true-DOF matrix assembly. Both diagonal norms + are recorded, and the surface diagonal must have positive entries. These use + the preparation sample/iteration counts. +- `geometry-preparation`: complete H(div) preparation with the fixed-mesh + geometry cache enabled versus disabled, changing displacement between paired + samples. Both use the same optimized operator. Mass and displacement actions + are checked outside the timer. The initial cold-cache preparation is reported + separately; median timings measure reuse. Cache hit/miss/point counts are saved. +- `hdiv-displacement`: tensor interpolation/transpose versus the **previous + optimized pointwise** implementation (`cached_pointwise_reference`). +- `gravity-force`: tensor application versus the **previous optimized pointwise** + application. General prepared paths and finite differences remain independent + checks. The older gravity preparation comparison is omitted from this group. + +The metadata includes `tensor_variation_elements` and +`gravity_tensor_elements`. The current hex mesh should use tensor paths on all +eligible elements. General geometries and quadrature retain the existing paths; +these counts distinguish real acceleration from a correct fallback. + +Individual systems remain selectable, including the two new setup systems. +Use a new output directory for every run. Upload `summary.txt`, `metadata.txt`, +`timings.csv`, and `checks.csv`. Preparation timings use `--prepare-samples` +and `--prepare-iterations`; application timings use `--samples` and +`--iterations`. + +## Preflight option + +```sh +./cmake-build-release-homebrew/sandbox --skip-preflight +``` + +Library option: `NewtonOptions.backtracking.geometryPreflight = false`. +The default remains `true`; sandbox also accepts `--preflight`. Skipping avoids +both the predictive geometry scan and its lazy rule construction. Trial mapping +validation, finite-value checks, backtracking, and accepted-state restoration +remain active. Skipped scans have zero reported time and an empty optional +geometry estimate; `geometryLimitedIterations` counts only predictive limits. +A skipped scan can trade its time for extra rejected trials on a large step. + +## Geometry and normalization contracts + +The mapper caches only fixed computational-mesh positions, Jacobians, inverse +Jacobians, and weights. It recomputes displacement and compactification mapping +data on each call. Cache keys use element and actual reference coordinates, so +copied or changed quadrature rules cannot reuse the wrong point; quadrature +weights are applied by each caller. Mesh and node sequence changes invalidate +the entries. After raw coordinate changes, call `Mesh::NodesUpdated()`, as for +MFEM's geometric-factor cache, or `DomainMapper::ClearGeometryCache()`. Clear +the cache before destroying a mesh that the mapper will outlive. Cache entries +consume memory proportional to the distinct element/quadrature points visited, +and the mapper cache, like the element transformations it uses, is not thread-safe. + +Normalization retains full matrix assembly for nonconforming, variable-order, +and unsupported element spaces. In particular, it does not substitute the +approximate partial-assembly diagonal for `diag(P^T A P)` on hanging-node meshes. +Supported conforming hex volume spaces use MFEM partial assembly followed by +exact true-DOF diagonal assembly. The surface path integrates only diagonal +entries over marked boundary elements, using the same quadrature and physical +shape functions as full assembly. This includes the internal star/vacuum +interface, which MFEM's exterior boundary partial-assembly restriction omits. +The direct surface path supports conforming scalar H1 spaces without general +DOF transformations; other spaces retain full assembly. + +The new tests in existing test sources cover scalar/RT tensor transposes and +adjoint products, integrated RT bases, retained-table lifetime, general fallback, +geometry cache invalidation, changed displacement/compactification, exact +normalization diagonals, nonconforming fallback, and Newton acceptance/fold +rejection with preflight skipped. Compilation and execution are left to the user. + +--- + +# System isolation profile harness + +This executable checks and times individual operators without constructing a +stellar model, preconditioner, or Newton solver. Available systems are: + +- `hdiv-displacement`: optimized versus retained prepared H(div) derivative. +- `hdiv-mass`: new CPU primal application versus retained MFEM partial assembly. +- `mass-normalization`: cached scalar mass row versus retained quadrature action. +- `angular-momentum`: cached scalar angular-momentum row versus retained quadrature action. +- `hydrostatic-displacement`: tensor evaluation versus the previous cached pointwise derivative; checks also compare the general mapping variation. +- `stellar-derivatives`: mass normalization, angular momentum, and hydrostatic displacement. +- `barotropic-closure`: complete density/enthalpy/displacement closure action. +- `barotropic-enthalpy`: tensor versus cached dense dedicated enthalpy coupling. +- `barotropic-displacement`: tensor versus cached pointwise dedicated displacement coupling. +- `pressure-displacement`: direct pressure displacement contraction versus the old matrix products. +- `pressure-enthalpy`: local-matrix application after blocked versus original matrix preparation. +- `gravity-force`: complete density/displacement/gravity-gradient force derivative. +- `rotation-force`: complete density/displacement force derivative at fixed rotation. +- `linear-derivatives`: the seven closure/force comparisons above, plus pressure/gravity/rotation preparation timings. +- `preparation-optimizations`: pressure displacement and enthalpy, gravity force, rotation force, their preparation comparisons, then hydrostatic displacement. +- `geometry-inspection`: cached fixed geometry and tensor gradients versus the original geometry scan. +- `gravity-source-preparation`: cached mesh weights versus repeated weight evaluation. +- `application-optimizations`: the closure, rotation, primal mass, inspection, and source comparisons described above. +- `all`: all application and preparation comparisons, including geometry inspection and normalization diagonals. + A failure stops the affected group before its benchmarks. + +The default remains `hdiv-displacement`. Scalar systems prepare a shared gravity +context to supply their frozen state, but do not solve for gravity. Hydrostatic +only runs do not construct that shared context. + +## Preparation and hydrostatic optimization comparison + +The current changes fuse gravity/rotation residual assembly with element-cache +preparation, block pressure enthalpy-matrix assembly, and use tensor contractions +for supported hydrostatic displacement elements. From the repository root, +after the user builds the release targets: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system preparation-optimizations --check full --implementation both \ + --samples 5 --iterations 10 --warmup 2 \ + --prepare-samples 3 --prepare-iterations 1 \ + --output preparation_optimizations_01 +``` + +Return the four output files. The new preparation medians are +`pressure-preparation.*`, `gravity-force-preparation.*`, and +`rotation-force-preparation.*`. Every timed pair gets fresh dependency revisions +with the same frozen physical state. Order alternates between samples. +Preparation counters must increase once per call; an accidental no-op is an error. +Shared gravity-context preparation and residual copying/norms are outside these +timers. These are warm re-preparation timings; they include geometry rebuilding +and do not measure first-construction cost or a complete Newton iteration. +Initial and unchanged-state preparation remain separately reported. + +Preparation uses its own sample/iteration counts to avoid accidentally repeating +multi-second work with the application iteration count. Set `--prepare-samples 0` +to skip preparation timings. Both implementations remain available through +`--implementation`; correctness checks can still prepare references in an +optimized-only timing run. Independent reference operators require additional +memory in the harness, not in the production solver. + +Checks compare prepared residuals and pressure enthalpy actions against the +original preparation at five states, as well as the existing derivative and +finite-difference checks. Hydrostatic timings use +`cached_pointwise_reference`, isolating this change from the earlier fast-path +optimization. Metadata reports `hydrostatic-displacement.tensor_elements`. +On the standard mesh this should equal the stellar element count. + +Focused regression cases: + +```sh +./cmake-build-release-homebrew/tests '*Tensor reference*,*Pressure*Force*,*Gravity Displacement Force*,*Rotational Displacement Force*,*Prepared Hydrostatic*' +``` + +For a nonlinear EOS check, repeat with `--polytropic-index 3` and a new output +directory. No stellar seed or Newton convergence is required. + +## First comparison of closure and force derivatives + +Explicitly build the harness target: it is excluded from the default build. +From `/Users/tboudreaux/Programming/4DSTAR/MeanField`, use: + +```sh +cmake --build cmake-build-release-homebrew \ + --target system_isolation_profile_harness tests sandbox --parallel 2 +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system linear-derivatives --check full --implementation both \ + --samples 5 --iterations 10 --warmup 2 \ + --output linear_derivatives_01 +``` + +Return `summary.txt`, `checks.csv`, `metadata.txt`, and `timings.csv`. +Use a new output directory for every run. Individual system names above let you +isolate a failure or time one operator without constructing the others. +The group defaults to a polytrope with n=1 and K=1. To exercise a nonlinear EOS +without creating a stellar seed, add `--polytropic-index 3`; this option applies +only to the closure/force group and does not change the FEM mesh. + +The complete closure comparison measures the contraction change. The two +coupling comparisons include savings from omitting absent columns, exactly as +the preconditioner now does. Their reference still executes the old complete +action with explicit zeros. Pressure measures both displacement and enthalpy derivatives. +Gravity and rotation measure their combined derivatives; their individual +stateless column methods remain available as independent checks. + +Full validation covers undeformed and deformed geometry, a density/enthalpy-only +update, a gravity/rotation update, and a rotation-only update to zero angular +velocity. Nonzero rotation uses a tilted axis and an offset center. Each state +compares every requested direction and an exact zero direction. Column checks +compare old/new complete actions; gravity and rotation also compare their +independent stateless columns. Prepared gravity/rotation residuals are also +compared with independently assembled stateless residuals at every validation +state. Centered differences of primal residuals use two step sizes. Grouped runs check the complete closure FD; +individual coupling runs check that coupling's FD. Unchanged dependency +revisions must preserve preparation counts. + +The scaled comparison uses the same unit-floor metric as the stellar group. +An additional relative comparison is reported for non-negligible outputs, +with tolerance 2e-10 for references and 2e-6 for finite differences. Full +validation may take several minutes because it prepares many states; its time +is reported separately and excluded from application samples. +`--check reference` checks one state plus zero input and unchanged revisions; +`--check none` skips correctness checks. + +Initial, repeated, and unchanged preparation times are separate. The gravity +context is counted once, separately from gravity force preparation. +Application comparisons retain the earlier reference derivatives. The pressure +enthalpy comparison uses independently prepared local matrices, and the separate +preparation benchmarks compare original versus optimized preparation. +Gravity/rotation metadata reports fast-element counts; on the standard +3D mesh these should equal the stellar element count. Their fast paths require +VALUE/GRAD displacement with byNODES global ordering; gravity also requires an +H(div) reference table. Other configurations retain the previous implementation. +Closure and pressure use their direct contractions in 3D and retain their +previous general-dimensional calculations otherwise. + +Focused regression cases, including material/surface coupling tests: + +```sh +./cmake-build-release-homebrew/tests '*Barotropic Closure*,*Pressure*Force*,*Gravity Displacement Force*,*Rotational Displacement Force*,*Material*Surface*' +``` + +## First comparison of the scalar rows and hydrostatic derivative + +After compiling the updated release executable, run from the repository root: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system stellar-derivatives --check full --implementation both \ + --samples 5 --iterations 20 --warmup 2 \ + --output stellar_derivatives_01 +``` + +Return `summary.txt`, `checks.csv`, `metadata.txt`, and `timings.csv`. To isolate +one system, replace `stellar-derivatives` with `mass-normalization`, +`angular-momentum`, or `hydrostatic-displacement`. The scalar benchmarks apply +the complete row (density/displacement and, for angular momentum, Omega). +The hydrostatic benchmark applies only its displacement derivative. + +Full checks compare four states: undeformed, deformed, changed density, and +changed rotation including negative Omega. They use several directions, with +an element-scale perturbation in the second displacement direction. The +rotation axis is tilted and its center is displaced from the origin. Checks +cover separate scalar columns and zero inputs, the mass transpose identity, +lazy row construction, reuse on rotation/target-only updates, and invalidation +on geometry/density updates. Two centered differences perturb density, +displacement and Omega together for the scalar rows; the hydrostatic difference +holds the other fields and rotation fixed. Finite differences use the existing +primal residual preparation, which has not been replaced by the new row code. + +For these three systems, errors are +`norm(actual-reference) / max(1, norm(actual), norm(reference))`; the unit floor +also makes zero scalar columns testable. Reference tolerance is 2e-11 and finite +difference tolerance is 2e-6. Lifecycle checks use exact counts. `--check reference` +checks one state and all requested directions; `--check none` performs no +correctness checks. These runs do not establish stellar accuracy or MPI correctness. + +Preparation is reported separately for the shared gravity context and each +selected operator, on initial construction and on re-preparation. Scalar rows +are built lazily in production on first Jacobian use. The harness explicitly +times `PrepareJacobianRows()` before warmup, including in reference-only runs, +so that repeated-application medians do not hide that cost in one sample. +`explicit_row_prepare_seconds` records this first-use cost. When both paths are +timed, the summary also reports the break-even application count and the +reference/optimized ratio for 1, 10 and 25 calls including one row assembly. +A break-even value of -1 means no measured per-application saving. These ratios +exclude the common state/context preparation costs, which are reported separately. +Hydrostatic comparison uses the same augmented geometry cache for both paths; +it does not independently measure the previous preparation time. + +Metadata reports scalar row eligibility and the hydrostatic fast-element count. +On the standard mesh both scalar flags should be 1 and the hydrostatic count +should equal the stellar element count. The optimized scalar rows retain two +local vectors per operator; hydrostatic adds nine doubles (72 bytes) per eligible +quadrature point. Reference tables are shared across operators with matching +elements and quadrature rules. Old mapping caches remain for reference/fallback. + +The user-run `stellar_derivatives_01` comparison passed all checks and measured +885x and 1349x repeated scalar applications, and 1.94x hydrostatic displacement. +These exclude common preparation. The closure/force changes described below +have not been compiled or executed by the coding agent. + +## H(div) comparison + +From the repository root, run: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system hdiv-displacement \ + --check full \ + --implementation both \ + --samples 5 --iterations 3 --warmup 1 \ + --output hdiv_isolation_01 +``` + +The output directory must not exist. This uses `sandbox.smesh`, the production +quadrature policy and registered element orders, and one MPI rank on the CPU. +It performs no equilibrium solve. Directions and gravity coefficients are +deterministic; the geometry is a small nonuniform polynomial displacement with +shear and cross terms. `--deformation 0` selects the undeformed timing state; +the default is `0.7`, multiplying displacements whose coefficients are of order +0.01–0.04. This argument is not a fractional stellar radius or rotation rate. + +Full validation checks two geometries (zero and the selected deformation), all +configured directions against the retained prepared derivative, and the first +direction against the independent stateless kernel. It also checks the selected +geometry against centered differences of the primal mass operator at two step +sizes. Those differences hold the gravity coefficients fixed. A failed check +stops the run before timing and returns status 3. Execution errors return 2; +successful requested checks and timing return 0. With `--check none`, status 0 +means execution completed, not that correctness was checked. + +Return `summary.txt`, `checks.csv`, `metadata.txt`, and `timings.csv` for analysis. +There is no need to run the full stellar model for this first comparison. + +## Timing and interpretation + +- `metadata.txt`: mesh path, geometry parameter, build/compiler information, + problem sizes, domain element counts, quadrature count, and how many elements + use the new derivative path. For the standard 3D Kelvin case, the last count + should equal the total element count. +- `summary.txt`: finite-element setup, operator construction, initial and repeat + preparation, validation time, median application times and their ratio. +- `timings.csv`: every measured sample, application count, seconds per + application, and the last output norm. Warmup is excluded. A/B execution order + alternates between samples. Output norms and file writes are outside the + application timer. Each sample cycles through the same direction sequence. +- `checks.csv`: numerical errors, tolerances, and pass/fail status. The derivative + comparison tolerance is 2e-11; centered differences use 2e-6 to allow subtraction + error. A numerical failure is not permission to relax a tolerance. + +For `hdiv-displacement`, `prepared_reference` is the previous prepared application +algorithm, retained in the same executable and using the same prepared state, +quadrature and field maps. This is the relevant application speedup comparison. +Both paths include true/local vector transfers and element assembly. Preparation +has already constructed the new cache in either case: this comparison does not +measure the old preparation cost or old peak memory use. + +For `hdiv-mass`, `optimized` is the new CPU contraction and +`mfem_pa_reference` is the retained MFEM application using the same PA data. +This ratio measures the primal mass improvement from the latest batch. Full +checks still compare the independent stateless kernel, which reevaluates mapping. +Mass-only runs use primal preparation and report zero variation-cache elements. + +The new tangent cache adds 12 doubles per eligible quadrature point (96 bytes), +plus shared reference displacement tables. Existing frozen mapping data remains +available for the reference/fallback implementation. Initial preparation may +therefore increase; compare preparation plus the application's actual number of +repeated calls when judging the total benefit. The earlier `hdiv_isolation_01` +user run measured a 3.13x median application speedup on the standard mesh; this +is separate from the new scalar/hydrostatic optimizations. + +For a longer optimized-only profiling window after correctness passes: + +```sh +./cmake-build-release-homebrew/system_isolation_profile_harness \ + --system hdiv-displacement --implementation optimized --check none \ + --samples 10 --iterations 20 --warmup 3 \ + --output hdiv_optimized_profile_01 +``` + +Use a separate directory for each run. Avoid concurrent builds or other heavy +jobs while collecting comparisons. To inspect another resolution, use +`--refinements N`; to change the number of direction/flux pairs, use +`--directions N`. These are local operator tests, not demonstrations of final +stellar accuracy or distributed correctness. + +## Derivative implementation + +For computational-mesh coordinates X, the mapped H(div) mass tensor is + +\[ +M = J^T J / \det J, \qquad J = \partial x_{physical}/\partial X. +\] + +The fast path contracts shared reference displacement gradients before applying +the cached mesh inverse. In the stellar domain, delta J is grad(delta u). For +Kelvin compactification, the existing Kelvin radial-factor calculation supplies +the fixed scale and its gradient: + +\[ +\delta J = a\,\nabla_X\delta u + \delta u (\nabla_X a)^T. +\] + +Only `a * J_mesh^-1` and `grad_X(a)` are added to the point cache. The directional +mass action on a computational H(div) vector g is evaluated as + +\[ +\delta M g = \frac{\delta J^T(Jg) + J^T[\delta Jg - +\operatorname{tr}(J^{-1}\delta J)Jg]}{\det J}. +\] + +This avoids computing unused inverse-mapping, inverse-element, physical-position, +and quadrature-weight derivatives, and avoids constructing the full mass-tensor +derivative. The fixed computational-mesh Piola factors, signed DOFs, primal/dual +DOF transformations, quadrature weights and true/local transfers are retained. +There is no change to equations, integration order, or solver tolerances. + +The specialization requires three dimensions, an H(div) reference table, and +VALUE/GRAD displacement elements. Compactified elements additionally require +the concrete Kelvin map and a differentiable compactification element. Other +maps use the retained general implementation; merely declaring a map affine +does not select the Kelvin specialization. + +## Compile and regression checks + +Configure the release build with +`CMAKE_CATCH_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST`, so compiling test targets +does not execute them to enumerate cases. Discovery occurs when the user runs +CTest. Configure/build commands are: + +```sh +cmake -S . -B cmake-build-release-homebrew \ + -DCMAKE_CATCH_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST +cmake --build cmake-build-release-homebrew \ + --target system_isolation_profile_harness tests sandbox --parallel 2 +``` + +Focused regression cases for the user to run: + +```sh +./cmake-build-release-homebrew/tests '*Prepared Hdiv*,*Prepared Mapped Hdiv*,Kelvin Prepared Jacobian Factors*' +``` + +Focused scalar/hydrostatic regression cases for the user to run after building: + +```sh +./cmake-build-release-homebrew/tests '*Prepared Mass Normalization*,*Prepared Angular Momentum*,*Prepared Hydrostatic*' +``` + +These existing suites now also check retained-reference agreement, lazy row +construction, selective row invalidation, reuse across unrelated updates, and +angular-momentum rejection/recovery after rows have already been built. Existing +finite differences, transpose pairing and context lifecycle tests remain active. + +## Closure and force implementation + +All changes preserve quadrature, equations, field maps, signed element DOFs, +primal/dual transformations, and true/local transfers. There is no tolerance +based dropping of closure or vacuum terms. + +- Closure couplings have supported-coordinate entry points for enthalpy alone + and displacement alone. The preconditioner calls these rather than executing + a complete action with known zero columns. The complete action still includes + every column. Its geometric scalar remains + `weightedResidual * tr(K * du/dxi)`, evaluated with a small direct contraction. +- Pressure uses the existing physical test gradients G and computes H=du/dx. + Its displacement contribution is `p*w*(G*H-tr(H)*G)`. This avoids constructing + the inverse-Jacobian variation and the matrix of all gradient variations. +- Gravity evaluates the existing expression + `w_ref * [F*(delta_rho*g + rho*delta_g) + rho*(du/dxi)*(J_mesh^-1*g)]`. + The last term contracts the reference gradients with a vector directly. + Density interpolation and test-basis transpose are done per element; H(div) + reference interpolation keeps the existing mesh Piola factor. +- Rotation uses shared reference value/gradient tables, bulk interpolation and + transpose application, and a cached 3x3 centrifugal acceleration derivative. + The force variation is + `w * [(delta_rho + rho*delta_log_w)*a + rho*delta_a]`. + The acceleration derivative is rebuilt with the existing preparation lifecycle. + +The new paths add per-element scratch buffers reused between elements and +shared reference tables for rotation; they do not add large per-point dense +Jacobian blocks. Existing data and reference paths are retained. Reference +applications do not increment production column/complete application counters. + +## Scalar and hydrostatic derivatives + +For the supported stellar VALUE/GRAD displacement field, let `B_a` be the +reference basis, `K` the inverse deformed element Jacobian, and `w` the mapped +quadrature weight. An element displacement direction satisfies + +``` +delta x_c = sum_a B_a delta u_ac +delta log(w) = sum_acj K_jc (dB_a/dxi_j) delta u_ac +``` + +The mass row integrates `w delta rho + rho w delta log(w)`. The moment-of-inertia +row integrates `w r_perp^2 delta rho + rho w [r_perp^2 delta log(w) + +2 r_perp dot delta x]`. Angular momentum applies `Omega delta I + I delta Omega`. +The local coefficient vectors are assembled with signed element DOFs and dual +DOF transformations, then paired with prolonged true-DOF directions. The final +scalar retains the existing MPI sum. Keeping these as local duals avoids +double-counting shared true DOFs. Mass transpose uses the transpose prolongation. + +Hydrostatic displacement contracts +`w [(h + Phi - Psi - C) delta log(w) - grad(Psi) dot delta x]`, then applies the +existing enthalpy test-basis transpose and assembly. It does not construct a +full mapping variation or repeatedly evaluate reference shapes. Only 3D interior +VALUE/GRAD elements use its fast path. Scalar row caching supports interior +VALUE/GRAD elements (mass in dimensions 1-3; angular momentum is already 3D). +Unsupported configurations retain the original general application. Density or +geometry changes invalidate scalar rows; Omega/target-only updates reuse them. +The equations, quadrature rules and solver tolerances are unchanged. + +These include comparison with the stateless derivative, the general exterior-map +fallback, primal-to-linearization transitions, candidate rejection/recovery, +and Kelvin factor checks. The harness adds independent finite differences and +multiple direction pairs. Compilation alone does not validate their outcomes. + +## Preparation and tensor implementation details + +Gravity forms the residual quadrature force from the already computed +`rho * w_ref * F * g_ref`; rotation uses `rho * w * acceleration`. +Each then applies the cached displacement test-basis transpose. The optimized +preparation visits geometry once, retains element DOF transforms and true/local +assembly, and synchronizes candidate rejection before collective assembly. +`PrepareReference` retains the separate stateless residual pass. Switching +preparation implementations invalidates the relevant cached result even if +physical dependency revisions are unchanged. + +Pressure retains the same local enthalpy matrix and its application. It packs +`-P'(h) * w * grad(test)` once per element and accumulates 32-row by 8-column +matrix tiles with contiguous row access. The original per-quadrature assembly +remains available through `PrepareReference`. EOS, mapping and finite-result +checks remain active; no quadrature points or matrix entries are dropped. + +The scalar reference-table cache now owns small one-dimensional value/gradient +tables and a copied lexicographic-to-native DOF map for H1 hexahedra. +Cartesian quadrature ordering and the reconstruction of the existing full +values/gradients are checked once before publishing tensor support. Unsupported +elements/rules use the existing path. Evaluation contracts x, y and z in turn +and produces displacement values and reference gradients, then the hydrostatic +operator applies the same inverse-Jacobian contraction, physical coefficients, +test-basis transpose and assembly. Scratch is reused for one element; no dense +per-element displacement Jacobian is added. The immutable tables own all tensor +data, including when the FE, quadrature rule or cache that created them is gone. diff --git a/experiments/plateau_diagnostic.cpp b/experiments/plateau_diagnostic.cpp new file mode 100644 index 0000000..e3f1b14 --- /dev/null +++ b/experiments/plateau_diagnostic.cpp @@ -0,0 +1,429 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +import mean_field; +#include "plateau_enthalpy_step_diagnostics.hpp" +#include "plateau_physical_diagnostics.hpp" +#include "plateau_preconditioner_diagnostics.hpp" + +using namespace mean_field; +namespace { +using Clock = std::chrono::steady_clock; +struct Options { + double n = 1., angularMomentum = 0., atol = 0., rtol = 1e-8, linearTolerance = .03; + int maxNewton = 12, maxLinear = 200, restart = 40; + bool probes = false, noCentral = false; + std::string mesh = "sandbox.smesh"; + std::filesystem::path output, probeState; +}; +Options Parse(int argc, char **argv) { + Options o; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + auto v = [&]() { + if (++i >= argc) + throw std::invalid_argument("Missing value"); + return std::string(argv[i]); + }; + if (a == "--index") + o.n = std::stod(v()); + else if (a == "--angular-momentum") + o.angularMomentum = std::stod(v()); + else if (a == "--output") + o.output = v(); + else if (a == "--mesh") + o.mesh = v(); + else if (a == "--max-newton") + o.maxNewton = std::stoi(v()); + else if (a == "--max-linear") + o.maxLinear = std::stoi(v()); + else if (a == "--restart") + o.restart = std::stoi(v()); + else if (a == "--linear-tol") + o.linearTolerance = std::stod(v()); + else if (a == "--atol") + o.atol = std::stod(v()); + else if (a == "--rtol") + o.rtol = std::stod(v()); + else if (a == "--probe-state") + o.probeState = v(); + else if (a == "--probes") + o.probes = true; + else if (a == "--no-central") + o.noCentral = true; + else + throw std::invalid_argument("Unknown option: " + a); + } + if (o.output.empty() || o.n < 1 || o.n >= 5 || o.maxNewton < 0 || o.maxLinear < 1 || o.restart < 1) + throw std::invalid_argument("Invalid options"); + if (std::filesystem::exists(o.output)) + throw std::runtime_error("Output directory already exists"); + std::filesystem::create_directories(o.output); + return o; +} +std::ofstream File(const std::filesystem::path &p) { + std::ofstream s(p); + s.exceptions(std::ios::failbit | std::ios::badbit); + s << std::setprecision(17); + return s; +} +double Norm(std::span v, int offset, int size) { + long double sum = 0; + for (int i = offset; i < offset + size; ++i) + sum += (long double)v[i] * v[i]; + return std::sqrt(sum); +} +double Norm(const mfem::Vector &v, int offset, int size) { + return Norm(std::span{v.GetData(), (size_t)v.Size()}, offset, size); +} +struct Scales { + double K, rho, xi, mu; +}; +Scales Derive(double n) { + if (n == 1.) + return {2. * utils::G / std::numbers::pi, std::numbers::pi / 4., std::numbers::pi, std::numbers::pi}; + auto le = seed::integrateLaneEmden(n, 2000., 1e-3); + if (!le.firstZeroCoordinate) + throw std::runtime_error("No Lane Emden root"); + const double xi = *le.firstZeroCoordinate, mu = -xi * xi * le.thetaDerivative(le.thetaDerivative.Size() - 1); + const double rho = xi * xi * xi / (4 * std::numbers::pi * mu); + return {4 * std::numbers::pi * utils::G * std::pow(rho, 1. - 1. / n) / ((n + 1) * xi * xi), rho, xi, mu}; +} +void SaveVector(const std::filesystem::path &p, const mfem::Vector &v) { + auto s = File(p); + s << v.Size() << '\n'; + v.Print(s, 1); +} + +template +void FrozenProbes(State &s, const fem::FEM &f, const Options &o, const mfem::Vector *physicalBase = nullptr) { + auto rows = s.Problem().GetManifest().residualBlocks(); + mfem::Vector frozenState(s.acceptedNormalizedState); + if (physicalBase) + s.normalizedOperator->NormalizeState(*physicalBase, frozenState); + auto restoreFrozen = [&]() { + s.trialNormalizedState = frozenState; + auto p = s.PrepareTrial(); + if (!p) + throw std::runtime_error("Frozen diagnostic state is inadmissible"); + }; + restoreFrozen(); + s.physicalInverse->Refresh(); + const mfem::Vector base(s.trialNormalizedResidual); + if (base.Norml2() == 0.) + return; + auto results = File(o.output / "frozen_linear.csv"); + results << "restart,tolerance,iterations,converged,true_relative,seconds\n"; + auto histories = File(o.output / "frozen_history.csv"); + histories << "restart,tolerance,iteration,reported_norm,final\n"; + auto blocks = File(o.output / "frozen_blocks.csv"); + blocks << "restart,tolerance,block,F_norm,Jp_plus_F_norm,global_correction_norm\n"; + mfem::Vector best, baselineAction(base.Size()); + for (int restart : {40, 160}) { + mfem::FGMRESSolver linear(MPI_COMM_WORLD); + solver::ResidualHistoryMonitor monitor; + linear.SetPreconditioner(*s.normalizedInverse); + linear.SetOperator(*s.normalizedOperator); + linear.SetKDim(restart); + linear.SetRelTol(0.); + linear.SetAbsTol(o.linearTolerance * base.Norml2()); + linear.SetMaxIter(o.maxLinear); + linear.SetPrintLevel(-1); + linear.SetMonitor(monitor); + linear.iterative_mode = false; + mfem::Vector b(base), p(base.Size()), action(base.Size()); + b *= -1.; + p = 0.; + const auto start = Clock::now(); + linear.Mult(b, p); + double seconds = std::chrono::duration(Clock::now() - start).count(); + s.normalizedOperator->Mult(p, action); + action += base; + const double rel = action.Norml2() / base.Norml2(); + results << restart << ',' << o.linearTolerance << ',' << linear.GetNumIterations() << ',' + << linear.GetConverged() << ',' << rel << ',' << seconds << '\n'; + results.flush(); + for (const auto &h : monitor.GetHistory()) + histories << restart << ',' << o.linearTolerance << ',' << h.iteration << ',' << h.reportedNorm << ',' + << h.final << '\n'; + histories.flush(); + for (const auto &r : rows) + blocks << restart << ',' << o.linearTolerance << ',' << r.stableId << ',' << Norm(base, r.offset, r.size) + << ',' << Norm(action, r.offset, r.size) << ',' << p.Norml2() << '\n'; + blocks.flush(); + std::cout << "Frozen restart=" << restart << " true=" << rel << " seconds=" << seconds << std::endl; + if (restart == 40) + best = p; + } + s.normalizedCorrection = best; + s.normalizedOperator->Mult(best, baselineAction); + auto positivity = File(o.output / "enthalpy_step_bound.csv"); + positivity << "metric,value\n"; + for (const auto &[key, value] : experiment::plateau_diagnostics::MeasureEnthalpyStep(s, f, o.n, physicalBase)) + positivity << key << ',' << value << '\n'; + positivity.flush(); + SaveVector(o.output / "probe_normalized_correction.txt", best); + auto fd = File(o.output / "finite_differences.csv"); + fd << "epsilon,admissible,block,absolute_error,relative_to_Jp,action_norm\n"; + const auto safe = s.EstimateLargestSafeStepSize(1., .9); + if (!std::isfinite(safe.stepSize) || safe.stepSize <= 0.) + return; + auto trials = File(o.output / "frozen_trials.csv"); + trials << "alpha,admissible,rejection_reason,F_norm,merit_ratio\n"; + // Diagnostic trials are never committed. Each samples the same frozen Newton direction. + for (double a : {1., .5, .1, .01, .001, .0001, .00001}) { + const double epsilon = std::min(1., safe.stepSize) * a; + s.trialNormalizedState = frozenState; + s.trialNormalizedState.Add(epsilon, best); + const auto prep = s.PrepareTrial(); + if (!prep) { + trials << epsilon << ",0," << int(prep.error().reason) << ",nan,nan\n"; + fd << epsilon << ",0,all,nan,nan,nan\n"; + } else { + trials << epsilon << ",1,-1," << s.trialNormalizedResidual.Norml2() << ',' + << std::pow(s.trialNormalizedResidual.Norml2() / base.Norml2(), 2) << '\n'; + mfem::Vector error(s.trialNormalizedResidual); + error -= base; + error /= epsilon; + error -= baselineAction; + for (const auto &r : rows) { + double e = Norm(error, r.offset, r.size), j = Norm(baselineAction, r.offset, r.size); + fd << epsilon << ",1," << r.stableId << ',' << e << ',' << e / std::max(j, 1e-300) << ',' << j << '\n'; + } + } + trials.flush(); + fd.flush(); + restoreFrozen(); + } +} + +template int RunContext(Context &context, const Options &o, const Scales &scales) { + std::vector rows, values; + mfem::Vector residualFactors; + std::vector centralRows; + int centralOffset = -1; + auto metadata = File(o.output / "metadata.txt"); + metadata << "n=" << o.n << "\nK=" << scales.K << "\nrho_c=" << scales.rho << "\nJ=" << o.angularMomentum + << "\nM=1\nR_reference=1\nxi1=" << scales.xi << "\nmu1=" << scales.mu << "\ncentral_phase=" << !o.noCentral + << "\nrestart=" << o.restart << "\nlinear_tol=" << o.linearTolerance << "\nmax_linear=" << o.maxLinear + << "\nmax_newton=" << o.maxNewton << "\natol=" << o.atol << "\nrtol=" << o.rtol + << "\ncompiler=" << __VERSION__ << "\ncompiled=" << __DATE__ << ' ' << __TIME__ << '\n'; + metadata.flush(); + if (!o.probeState.empty()) { + if (o.maxNewton != 0) + throw std::invalid_argument("--probe-state requires --max-newton 0"); + metadata << "probe_state=" << o.probeState << '\n'; + metadata.flush(); + solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &s, const fem::FEM &f) { + std::ifstream input(o.probeState); + int count = 0; + input >> count; + if (count != s.AcceptedPhysicalState().Size()) + throw std::runtime_error("Snapshot size mismatch"); + mfem::Vector snapshot(count); + for (int i = 0; i < count; ++i) { + input >> snapshot(i); + if (!input || !std::isfinite(snapshot(i))) + throw std::runtime_error("Invalid snapshot"); + } + s.normalizedOperator->NormalizeState(snapshot, s.trialNormalizedState); + auto prep = s.PrepareTrial(); + if (!prep) + throw std::runtime_error("Snapshot is inadmissible"); + std::cout << "Snapshot F=" << s.trialNormalizedResidual.Norml2() << std::endl; + experiment::plateau_diagnostics::InspectPreconditioner(s, o.output); + FrozenProbes(s, f, o, &snapshot); + }); + return 0; + } + auto physicalMetrics = [&](auto &s, const fem::FEM &f, const std::string &label) { + std::cout << label << " physical diagnostics" << std::endl; + experiment::polytrope_validation::PhysicalState physical(s, f); + auto out = File(o.output / (label + "_physical.csv")); + out << "quadrature,metric,value\n"; + for (int q : {14, 18}) { + auto metrics = experiment::plateau_diagnostics::MeasurePhysical(physical, o.n, scales.K, q); + metrics["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm; + metrics["normalized_unbordered_residual"] = physical.normalizedUnborderedResidualNorm; + metrics["central_border"] = physical.centralBorder; + metrics["normalized_border_action"] = physical.normalizedCentralBorderActionNorm; + metrics["minimum_sampled_jacobian"] = s.acceptedMinimumJacobianDeterminant; + metrics["mass_absolute_error"] = std::abs(metrics["mass"] - 1.); + metrics["angular_momentum_absolute_error"] = std::abs(metrics["angular_momentum_z"] - o.angularMomentum); + std::cout << " q=" << q << " virial_h=" << metrics["enthalpy_virial_error"] << " M=" << metrics["mass"] + << std::endl; + for (auto &[k, v] : metrics) + out << q << ',' << k << ',' << v << '\n'; + out.flush(); + } + }; + solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &s, const fem::FEM &f) { + auto r = s.Problem().GetManifest().residualBlocks(), v = s.Problem().GetManifest().valueBlocks(); + rows.assign(r.begin(), r.end()); + values.assign(v.begin(), v.end()); + residualFactors = s.normalizedOperator->GetNormalization().ResidualFactors(); + for (const auto &b : values) + if (b.stableId == "fixed_central_density.border") + centralOffset = b.offset; + experiment::polytrope_validation::PhysicalState physical(s, f); + centralRows = physical.centralHydrostaticRows; + auto layout = File(o.output / "layout.csv"); + layout << "kind,block,offset,size,scale\n"; + for (auto &b : rows) + layout << "residual," << b.stableId << ',' << b.offset << ',' << b.size << ',' << b.scale << '\n'; + for (auto &b : values) + layout << "value," << b.stableId << ',' << b.offset << ',' << b.size << ',' << b.scale << '\n'; + physicalMetrics(s, f, "initial"); + }); + auto history = File(o.output / "newton.csv"); + history << "iteration,accepted,F,alpha,trials,linear_status,linear_iterations,linear_true,seconds,linear_seconds," + "prepare_seconds,refresh_seconds,geometry_limited,geometry_safe_step,central_border,unbordered_F\n"; + auto blocklog = File(o.output / "blocks.csv"); + blocklog << "iteration,stage,block,normalized_norm,physical_norm,max_abs_normalized\n"; + auto triallog = File(o.output / "trials.csv"); + triallog << "iteration,trial,alpha,disposition,source,F,min_jacobian\n"; + auto record = [&](int it, const char *stage, std::span residual) { + for (const auto &b : rows) { + long double ps = 0; + double mx = 0; + for (int i = b.offset; i < b.offset + b.size; ++i) { + const double p = residual[i] / residualFactors(i); + ps += (long double)p * p; + mx = std::max(mx, std::abs(residual[i])); + } + blocklog << it << ',' << stage << ',' << b.stableId << ',' << Norm(residual, b.offset, b.size) << ',' + << std::sqrt(ps) << ',' << mx << '\n'; + } + blocklog.flush(); + }; + auto observer = solver::nonlinear::makeObserver( + [&](const solver::nonlinear::BeforeIteration &e) { + record(e.iteration, "before", e.normalizedResidual); + std::cout << "Newton " << e.iteration << " F=" << e.residualNorm << std::endl; + }, + [&](const solver::nonlinear::AfterLineSearchTrial &e) { + triallog << e.iteration << ',' << e.trial << ',' << e.stepLength << ',' << int(e.disposition) << ',' + << e.rejectionSource << ',' << (e.metric ? e.metric->residualNorm : NAN) << ',' + << e.minimumJacobianDeterminant.value_or(NAN) << '\n'; + triallog.flush(); + }, + [&](const solver::nonlinear::AfterIteration &e) { + record(e.iteration, "after", e.normalizedResidual); + double border = centralOffset < 0 ? 0 : e.physicalState[centralOffset]; + mfem::Vector unbordered((int)e.normalizedResidual.size()); + for (int i = 0; i < unbordered.Size(); ++i) + unbordered(i) = e.normalizedResidual[i]; + for (int i : centralRows) + unbordered(i) -= residualFactors(i) * border; + history << e.iteration << ',' << e.stepAccepted << ',' << e.residualNorm << ',' << e.acceptedStepLength + << ',' << e.lineSearchTrials << ',' << (e.linearSolve ? int(e.linearSolve->status) : -1) << ',' + << (e.linearSolve ? e.linearSolve->iterations : 0) << ',' + << (e.linearSolve ? e.linearSolve->relativeTrueResidualNorm : NAN) << ',' << e.iterationSeconds + << ',' << (e.linearSolve ? e.linearSolve->solveSeconds : 0) << ',' << e.trialPreparationSeconds + << ',' << e.preconditionerRefreshSeconds << ',' + << (e.geometryPreflight ? e.geometryPreflight->limitedByGeometry : false) << ',' + << (e.geometryPreflight ? e.geometryPreflight->stepSize : NAN) << ',' << border << ',' + << unbordered.Norml2() << '\n'; + history.flush(); + std::cout << " alpha=" << e.acceptedStepLength << " F=" << e.residualNorm + << " linear=" << (e.linearSolve ? e.linearSolve->relativeTrueResidualNorm : NAN) + << " lambda=" << border << " unbordered=" << unbordered.Norml2() + << " seconds=" << e.iterationSeconds << std::endl; + }); + bool converged = false; + if (o.maxNewton > 0) { + auto report = [&]() { + auto solve = solver::make(context, + solver::nonlinear::Newton(solver::nonlinear::NewtonOptions{ + .relativeTolerance = o.rtol, + .absoluteTolerance = o.atol, + .maximumIterations = o.maxNewton, + .linearSolve = {.relativeTolerance = o.linearTolerance, + .absoluteTolerance = 0., + .maximumIterations = o.maxLinear}}), + observer); + return solve.evaluate(); + }(); + converged = report.converged(); + metadata << "converged=" << converged << "\naccepted_steps=" << report.completedNonlinearIterations() << '\n'; + if (!converged) + metadata << "failure=" << report.failure().message << '\n'; + metadata.flush(); + } + solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &s, const fem::FEM &f) { + SaveVector(o.output / "accepted_state.txt", s.AcceptedPhysicalState()); + physicalMetrics(s, f, "final"); + const auto &schur = s.physicalInverse->GetGroupedPreconditioner().GetFactorization().GetSchurComplement(); + auto sh = File(o.output / "border_schur.csv"); + for (int i = 0; i < schur.Height(); ++i) { + for (int j = 0; j < schur.Width(); ++j) + sh << (j ? "," : "") << schur(i, j); + sh << '\n'; + } + if (o.probes) { + experiment::plateau_diagnostics::InspectPreconditioner(s, o.output); + FrozenProbes(s, f, o); + } + }); + return converged ? 0 : 3; +} +template int Setup(Model model, fem::FEM f, const Options &o, const Scales &scales) { + auto d = equilibrium::makeStellarDiscretization( + std::move(f), normalization::PhysicalRieszDiagonal{dimensions::LengthValue{1.}, utils::G}); + auto context = solver::makeContext(std::move(model), std::move(d), preconditioning::makePreconditioner(), + solver::linear::FGMRES({.restartLength = o.restart, .printLevel = -1}), + seed::LaneEmden({.centralDensity = dimensions::DensityValue{scales.rho}})); + return RunContext(context, o, scales); +} +int Run(const Options &o) { + const auto scales = Derive(o.n); + std::cout << "n=" << o.n << " K=" << scales.K << " rho_c=" << scales.rho << " J=" << o.angularMomentum << std::endl; + utils::Args args; + args.mesh_file = o.mesh; + args.p.rtol = 1e-12; + args.p.atol = 1e-12; + auto f = fem::setup_fem(o.mesh, args, 0); + if (!f.okay()) + throw std::runtime_error("FEM setup failed"); + auto eos = eos::Polytrope({.n = o.n, .K = scales.K}); + auto surf = surface::Isobaric({.Psurf = dimensions::PressureValue{0.}}); + auto mass = integral::FixedTotalMass({.Mtotal = dimensions::MassValue{1.}}); + auto angular = integral::FixedAngularMomentum( + {.Jtotal = dimensions::AngularMomentumValue{o.angularMomentum}, .axis = {0., 0., 1.}, .center = {0., 0., 0.}}); + if (o.noCentral) + return Setup(model::StellarModel(eos, surf, mass, angular), std::move(f), o, scales); + return Setup(model::StellarModel(eos, surf, mass, angular, + constraint::FixedCentralDensity({.RhoC = dimensions::DensityValue{scales.rho}})), + std::move(f), o, scales); +} +} // namespace +int main(int argc, char **argv) { + mfem::Mpi::Init(argc, argv); + int result = 0; + try { + int count; + MPI_Comm_size(MPI_COMM_WORLD, &count); + if (count != 1) + throw std::runtime_error("Single rank only"); + mfem::Device device("cpu"); + std::cout << std::setprecision(12); + result = Run(Parse(argc, argv)); + } catch (const std::exception &e) { + std::cerr << "Diagnostic failure: " << e.what() << std::endl; + result = 2; + } + mfem::Mpi::Finalize(); + return result; +} diff --git a/experiments/plateau_diagnostic_README.md b/experiments/plateau_diagnostic_README.md new file mode 100644 index 0000000..863f67d --- /dev/null +++ b/experiments/plateau_diagnostic_README.md @@ -0,0 +1,93 @@ +# Plateau diagnostics + +This opt-in experiment runs the current production context, operator, normalization, +preconditioner and Newton implementation. It adds observations and uncommitted frozen +trials. It does not select a replacement numerical algorithm. + +Build from the repository root: + +```sh +cmake --build cmake-build-release-homebrew --target plateau_diagnostic -j 2 +``` + +Run a calibrated spherical family with `G=M=R_reference=1`: + +```sh +./cmake-build-release-homebrew/plateau_diagnostic \ + --index 1.5 --angular-momentum 0 --max-newton 8 \ + --output /absolute/path/to/new-results +``` + +The output directory must not already exist. The mesh defaults to the repository's +`sandbox.smesh`; `--mesh` selects another mesh. The driver is restricted to one MPI +rank. An MPI runtime must be available even for a single-rank run. + +For each index, the driver obtains K and central density from the Lane–Emden +solution so the spherical reference mass and radius are one. Index one uses its +analytic constants. Other indices use the production radial integrator, so this +calibration is seed-consistent, not an independent exact reference. Angular momentum +sets a physical target, not a fraction of critical rotation. `--no-central` omits +the central phase for controlled comparisons; it changes the model specification. + +Default solver controls are restart 40, 200 linear iterations, linear relative +tolerance 0.03, Newton relative tolerance 1e-8 and absolute tolerance zero. Override +with `--restart`, `--max-linear`, `--linear-tol`, `--rtol`, `--atol`, and +`--max-newton`. Exit 0 means the requested Newton criterion was met, 3 means it was +not met (or a seed-only inspection was requested), and 2 means setup/diagnostics +failed. A nonconverged result still saves the retained accepted state and its +physical measurements. + +## Frozen investigations + +Append `--probes` to inspect the retained state after a solve. This compares cold +FGMRES windows 40 and 160 at the same iteration budget and independently recomputes +both true residuals. It also exports normalization consistency checks, the scalar +Schur matrix and its coordinate labels, inverse-diagonal quality, and full-Jacobian +actions on preconditioner-completed scalar directions. + +To inspect a previously saved physical root vector without repeating Newton: + +```sh +./cmake-build-release-homebrew/plateau_diagnostic \ + --index 1.5 --angular-momentum 0 --max-newton 0 \ + --probe-state /absolute/path/to/previous/accepted_state.txt \ + --output /absolute/path/to/new-frozen-results +``` + +Use the same mesh, compiled field orders, index, K/central-density calibration, and +model flags. The snapshot is prepared as an uncommitted trial inside the existing +diagnostic hook; the context's accepted state is unchanged. This is a frozen +inspection facility, not a Newton continuation/restart facility. The saved vector +is dimension-checked, but does not contain a mesh/model fingerprint. + +Finite-difference and nonlinear-trial probes always use the **window-40 direction**, +even if window 160 obtains a better correction. They compute the exact sampled +enthalpy step bounds for both production closure and pressure-force quadrature. +Every trial is restored and none is committed. Finite differences are one-sided; +interpret their epsilon trend and absolute error, particularly for tiny Jacobian +actions. `global_correction_norm` in the block table is the whole-vector norm. + +## Physical measurements + +The observer integrates over stellar material only, at independent quadrature +orders 14 and 18. It reconstructs physical positions and Piola-transformed gravity +flux. For zero surface pressure it measures + +- `W = 1/2 integral rho Phi`, `W_force = -integral rho x dot gradPhi`; +- rotational energy T and both `P(h)` and `P(rho)` pressure integrals; +- energy and force virials `abs(2T + W + 3 integral P) / abs(W_energy)`; +- mass, angular momentum, center of mass, Bernoulli and EOS mismatch, hydrostatic + gradient mismatch, sampled density/enthalpy signs, and geometry quality. + +Pressure is evaluated with the actual polytropic index, including noninteger n. +Signed finite DG density is retained in mass/gravity/rotation integrals. Any +negative density sample makes the complete `P(rho)` pressure and virial undefined +(NaN); `P(h)` remains available for positive enthalpy. The observer reports the +negative-density mass and volume fractions instead of silently clipping them. +NaN in `P(rho)` alone is not a quantitative measure of the global physical error. +Quadrature samples are not a global positivity certificate. + +`normalized_unbordered_residual` removes the central phase's artificial source +from the hydrostatic equation. A small bordered residual can otherwise hide a +pointwise modification at the central vertex. Virial success and Newton stopping +success are deliberately separate measurements. diff --git a/experiments/plateau_enthalpy_step_diagnostics.hpp b/experiments/plateau_enthalpy_step_diagnostics.hpp new file mode 100644 index 0000000..87a752a --- /dev/null +++ b/experiments/plateau_enthalpy_step_diagnostics.hpp @@ -0,0 +1,226 @@ +#pragma once + +// Include after import mean_field. This observer does not change accepted +// coefficients, the prepared operator, or the Newton correction. +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace experiment::plateau_diagnostics { +// Computes the forward h>=0 boundary along the CURRENT normalizedCorrection +// on exactly the production closure and pressure-force quadrature points. +// n must match the active polytropic EOS. Scalar +// enthalpy coefficients pull back unchanged under the moving-domain map, +// so h_q(alpha)=h_q(0)+alpha*dh_q: no geometry derivative is needed here. +// +// Source convention: prepared_barotropic_closure.cpp get_eos_rule (lines +// 102-157) and TryPrepare's basis contraction/domain check (lines 593-632). +// The production operator rejects h<0 strictly, despite the standalone +// Polytrope EOS extension to h<=0. Pressure-force uses the PressureForce +// query with extraOrder=ceil(n*p_h), as in prepared_pressure_force.cpp +// lines 226-282, and also rejects h<0 (lines 787-803). Its tensor and dense +// contraction paths sample the same polynomial and can differ at roundoff. +// The union bound concerns h>=0 only; geometry and other finite-data tests +// can still impose stricter limits. +// +// alpha_h=+infinity means no sampled enthalpy upper bound in this direction. +// alpha_h=NaN means the accepted base was already invalid or a sampled value +// was nonfinite. alpha_h=0 means a sampled h=0 has dh<0. A step exactly at +// the reported boundary can differ at roundoff due to floating-point state +// addition and contraction. alpha_h remains the closure alias; +// closure_alpha_h, pressure_alpha_h, and alpha_thermodynamic are explicit. +// No candidate is prepared or committed here. physicalBase optionally +// selects a caller-owned frozen trial state instead of the accepted state; +// its coefficients must use this context's physical root layout. +template +std::map MeasureEnthalpyStep(State &state, const mean_field::fem::FEM &fem, const double n, + const mfem::Vector *physicalBase = nullptr) { + if (!std::isfinite(n) || n < 1.0) + throw std::invalid_argument("Enthalpy step diagnostics require finite n>=1."); + using DomainSchema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema; + using ClosureDomain = mean_field::field::FieldDomainT; + using PressureDomain = mean_field::field::FieldDomainT; + using EnthalpyField = mean_field::field::Field; + int ranks = 0; + MPI_Comm_size(fem.mesh->GetComm(), &ranks); + if (ranks != 1) + throw std::invalid_argument("Enthalpy step diagnostics require exactly one MPI rank."); + if (!fem.enthalpyFes || !fem.densityFes || !fem.displacementFes || !fem.quadratureFactory) { + throw std::invalid_argument("Enthalpy step diagnostics received an incomplete FEM."); + } + const double maximumExtra = n * mean_field::field::Enthalpy::Scalar::familyOrder; + if (!std::isfinite(maximumExtra) || maximumExtra > std::numeric_limits::max()) { + throw std::invalid_argument("Enthalpy step diagnostics received an invalid effective EOS order."); + } + int offset = -1, size = -1; + for (const auto &block : state.Problem().GetManifest().valueBlocks()) { + if (block.stableId == "specific_enthalpy") { + offset = block.offset; + size = block.size; + break; + } + } + mfem::Vector physicalCorrection; + state.normalizedOperator->DenormalizeState(state.normalizedCorrection, physicalCorrection); + const auto &accepted = physicalBase ? *physicalBase : state.AcceptedPhysicalState(); + mfem::ParGridFunction h(fem.enthalpyFes.get()), dh(fem.enthalpyFes.get()); + const auto adapter = + mean_field::field::make_field_dof_grid_function_adapter( + *fem.enthalpyFes); + if (offset < 0 || size != adapter.dof_map().reduced_size() || offset + size > accepted.Size() || + physicalCorrection.Size() != accepted.Size()) { + throw std::invalid_argument("Enthalpy step diagnostics found an incompatible enthalpy block."); + } + mfem::Vector reducedH(size), reducedDh(size); + for (int i = 0; i < size; ++i) { + reducedH(i) = accepted(offset + i); + reducedDh(i) = physicalCorrection(offset + i); + } + adapter.scatter(reducedH, h); + adapter.scatter(reducedDh, dh); + + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + const auto scan = [&](const bool pressure) -> std::map { + const int extraOrder = + static_cast(std::ceil((pressure ? n : n - 1.0) * mean_field::field::Enthalpy::Scalar::familyOrder)); + double alpha = inf, minH = inf, minDh = inf, minFullStepH = inf; + double limitingH = nan, limitingDh = nan; + int limitingElement = -1, limitingPoint = -1, limitingAttribute = -1; + std::array limitingIp{nan, nan, nan}; + int minimumHElement = -1, minimumHPoint = -1; + int rulePointCountMin = std::numeric_limits::max(), rulePointCountMax = 0; + std::uint64_t samples = 0, nonfiniteSamples = 0, negativeBaseSamples = 0, decreasingSamples = 0, + negativeFullStepSamples = 0; + mfem::Array dofs; + mfem::Vector localH, localDh, shape; + for (int element = 0; element < fem.mesh->GetNE(); ++element) { + auto *transformation = fem.mesh->GetElementTransformation(element); + const bool inSupport = + pressure ? DomainSchema::template attribute_belongs_to(transformation->Attribute) + : DomainSchema::template attribute_belongs_to(transformation->Attribute); + if (!inSupport) + continue; + const auto &densityElement = *fem.densityFes->GetFE(element); + const auto &enthalpyElement = *fem.enthalpyFes->GetFE(element); + if (enthalpyElement.GetOrder() != mean_field::field::Enthalpy::Scalar::familyOrder || + (!pressure && densityElement.GetOrder() != mean_field::field::Density::Scalar::familyOrder) || + (pressure && fem.displacementFes->GetFE(element)->GetOrder() != + mean_field::field::Displacement::Vector::familyOrder)) { + throw std::invalid_argument( + "Enthalpy step diagnostics require the registered production field orders."); + } + const auto query = + pressure ? EnthalpyField::template make_query( + mean_field::quadrature::QuadratureRole::discretization, transformation->OrderW(), + std::array{extraOrder}, mean_field::utils::DOMAINS::STELLAR, + mean_field::quadrature::MappingKind::general) + : EnthalpyField::template make_query( + mean_field::quadrature::QuadratureRole::discretization, transformation->OrderW(), + std::array{extraOrder}, mean_field::utils::DOMAINS::STELLAR, + mean_field::quadrature::MappingKind::general); + const auto resolution = fem.quadratureFactory->get(query, transformation->GetGeometryType()); + if (!resolution.integration_rule) + throw std::runtime_error("Missing production EOS quadrature rule."); + const auto &rule = *resolution.integration_rule; + rulePointCountMin = std::min(rulePointCountMin, rule.GetNPoints()); + rulePointCountMax = std::max(rulePointCountMax, rule.GetNPoints()); + const auto basis = fem.GetReferenceTables().GetScalarTable(enthalpyElement, rule); + auto *dofTransformation = fem.enthalpyFes->GetElementDofs(element, dofs); + h.GetSubVector(dofs, localH); + dh.GetSubVector(dofs, localDh); + if (dofTransformation) { + dofTransformation->InvTransformPrimal(localH); + dofTransformation->InvTransformPrimal(localDh); + } + shape.SetSize(enthalpyElement.GetDof()); + for (int q = 0; q < rule.GetNPoints(); ++q) { + ++samples; + basis->GetValues().GetRow(q, shape); + const double hq = localH * shape; + const double dhq = localDh * shape; + if (!std::isfinite(hq) || !std::isfinite(dhq)) { + ++nonfiniteSamples; + continue; + } + if (hq < minH) { + minH = hq; + minimumHElement = element; + minimumHPoint = q; + } + minDh = std::min(minDh, dhq); + minFullStepH = std::min(minFullStepH, hq + dhq); + negativeBaseSamples += hq < 0.0; + negativeFullStepSamples += hq + dhq < 0.0; + if (dhq < 0.0) { + ++decreasingSamples; + const double candidate = -hq / dhq; + if (candidate < alpha) { + alpha = candidate; + limitingH = hq; + limitingDh = dhq; + limitingElement = element; + limitingPoint = q; + limitingAttribute = transformation->Attribute; + const auto &ip = rule.IntPoint(q); + limitingIp = {ip.x, ip.y, ip.z}; + } + } + } + } + const bool valid = samples > 0 && nonfiniteSamples == 0 && negativeBaseSamples == 0; + return {{"alpha_h", valid ? alpha : nan}, + {"alpha_h_raw_crossing", alpha}, + {"alpha_h_clipped_to_one", valid ? std::min(1.0, alpha) : nan}, + {"enthalpy_base_admissible", valid ? 1.0 : 0.0}, + {"eos_extra_order", static_cast(extraOrder)}, + {"samples", static_cast(samples)}, + {"nonfinite_samples", static_cast(nonfiniteSamples)}, + {"negative_base_samples", static_cast(negativeBaseSamples)}, + {"decreasing_samples", static_cast(decreasingSamples)}, + {"predicted_negative_full_step_samples", static_cast(negativeFullStepSamples)}, + {"minimum_h", samples > nonfiniteSamples ? minH : nan}, + {"minimum_dh", samples > nonfiniteSamples ? minDh : nan}, + {"predicted_minimum_h_full_step", samples > nonfiniteSamples ? minFullStepH : nan}, + {"minimum_h_element", static_cast(minimumHElement)}, + {"minimum_h_point", static_cast(minimumHPoint)}, + {"limiting_element", static_cast(limitingElement)}, + {"limiting_point", static_cast(limitingPoint)}, + {"limiting_attribute", static_cast(limitingAttribute)}, + {"limiting_h", limitingH}, + {"limiting_dh", limitingDh}, + {"limiting_ip_x", limitingIp[0]}, + {"limiting_ip_y", limitingIp[1]}, + {"limiting_ip_z", limitingIp[2]}, + {"quadrature_points_per_element_minimum", samples ? static_cast(rulePointCountMin) : nan}, + {"quadrature_points_per_element_maximum", static_cast(rulePointCountMax)}}; + }; + auto result = scan(false); + const auto pressure = scan(true); + result["closure_alpha_h"] = result.at("alpha_h"); + result["closure_samples"] = result.at("samples"); + for (const auto &[name, value] : pressure) + result["pressure_" + name] = value; + const bool unionValid = + result.at("enthalpy_base_admissible") != 0.0 && pressure.at("enthalpy_base_admissible") != 0.0; + const double unionAlpha = unionValid ? std::min(result.at("alpha_h"), pressure.at("alpha_h")) : nan; + result["alpha_thermodynamic"] = unionAlpha; + result["alpha_thermodynamic_clipped_to_one"] = unionValid ? std::min(1.0, unionAlpha) : nan; + result["thermodynamic_base_admissible"] = unionValid ? 1.0 : 0.0; + // 0=closure, 1=pressure-force, -1=invalid or no finite upper bound. + result["thermodynamic_limiting_family"] = + unionValid && std::isfinite(unionAlpha) ? (pressure.at("alpha_h") < result.at("alpha_h") ? 1.0 : 0.0) : -1.0; + result["thermodynamic_minimum_h"] = std::isfinite(result.at("minimum_h")) && std::isfinite(pressure.at("minimum_h")) + ? std::min(result.at("minimum_h"), pressure.at("minimum_h")) + : nan; + return result; +} +} // namespace experiment::plateau_diagnostics diff --git a/experiments/plateau_physical_diagnostics.hpp b/experiments/plateau_physical_diagnostics.hpp new file mode 100644 index 0000000..84ee6cd --- /dev/null +++ b/experiments/plateau_physical_diagnostics.hpp @@ -0,0 +1,319 @@ +#pragma once + +// Experiment-only observer. Include after `import mean_field;`, with the +// repository's experiments directory on the include path. The live-state +// PhysicalState constructor is generic in n and rotation; its saved-file replay +// constructor is intentionally not used here. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "polytrope_physical_state.hpp" + +namespace experiment::plateau_diagnostics { +using Measurements = std::map; + +// Assumes the production zero-surface-pressure problem, hence no external +// pressure surface term in 2T + W + 3 integral(P). Both the gravitational +// energy W=.5 integral(rho phi) and force W=-integral(rho x.grad(phi)) are +// reported. The latter uses the independently represented physical RT flux, +// not the broken gradient of the L2 potential. PhysicalState::Evaluate +// already applies the deformation Piola transform after MFEM's reference +// mesh Piola transform: no additional vector transformation belongs here. +// +// Call independently with orders 14 and 18 to assess quadrature sensitivity. +// These are quadrature-sampled extrema, not global pointwise certificates. +// Negative rho is not clipped: signed mass, W, T, J, and rho(h) mismatch are +// preserved. P(rho) is undefined for negative rho, so its complete integral +// and associated virials are NaN whenever any such samples occur. P(h) uses +// the production EOS extension rho(h)=0, P(h)=0 for h<=0. Negative samples +// and their volume/mass are explicitly reported. +// +// Invalid mapping/field samples invalidate all complete volume integrals; +// they are counted instead of aborting measurement or silently dropping +// their contribution. Parameters that cannot describe a polytrope throw. +inline Measurements MeasurePhysical(experiment::polytrope_validation::PhysicalState &state, const double n, + const double K, const int quadratureOrder = 14) { + if (!std::isfinite(n) || n < 1.0 || !std::isfinite(K) || K <= 0.0 || quadratureOrder < 0) { + throw std::invalid_argument( + "Physical diagnostics require finite n>=1, finite K>0, and nonnegative quadrature order."); + } + int ranks = 0; + MPI_Comm_size(state.finiteElements.mesh->GetComm(), &ranks); + if (ranks != 1) + throw std::invalid_argument("Physical diagnostics require exactly one MPI rank."); + + const long double index = n; + const long double constant = K; + const long double enthalpyFactor = (index + 1.0L) * constant; + const auto &omega = state.rotation.angular_velocity(); + const auto ¢er = state.rotation.center(); + const double nan = std::numeric_limits::quiet_NaN(); + const long double inf = std::numeric_limits::infinity(); + std::uint64_t samples = 0, validSamples = 0, invalidSamples = 0; + std::uint64_t negativeRhoSamples = 0, negativeHSamples = 0; + std::uint64_t invalidEnthalpyEosSamples = 0, invalidDensityEosSamples = 0; + long double volume = 0.0L, mass = 0.0L, binding = 0.0L, forceBinding = 0.0L; + long double pressureH = 0.0L, pressureRhoNonnegative = 0.0L, kinetic = 0.0L; + long double negativeRhoVolume = 0.0L, negativeHVolume = 0.0L, negativeRhoMassMagnitude = 0.0L; + long double closureSquared = 0.0L, closureIntegral = 0.0L, closureMax = 0.0L; + long double pressureMismatchSquared = 0.0L, pressureMismatchVolume = 0.0L; + long double bernoulliMean = 0.0L, bernoulliCenteredSquared = 0.0L, bernoulliRawSquared = 0.0L; + long double bernoulliMin = inf, bernoulliMax = -inf; + long double rhoMin = inf, rhoMax = -inf, hMin = inf, hMax = -inf, rhoFromHMax = 0.0L; + long double minDeterminant = inf, inertiaZ = 0.0L; + long double gradientMismatchSquared = 0.0L, hydrostaticGradientSquared = 0.0L; + long double drhodhPositiveMin = inf, drhodhPositiveMax = 0.0L; + std::array firstMoment{}, angularMomentum{}, gravitationalForce{}; + experiment::polytrope_validation::PhysicalPoint point; + mfem::Vector centrifugalGradient(3); + + 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) { + ++samples; + const auto &ip = rule.IntPoint(q); + if (state.Evaluate(element, ip, point) != mean_field::mapping::MappingStatus::valid) { + ++invalidSamples; + continue; + } + // Evaluate may reuse MFEM's cached transformation; restore ip. + transformation->SetIntPoint(&ip); + const double referenceDeterminant = transformation->Jacobian().Det(); + const long double weight = + static_cast(ip.weight) * referenceDeterminant * point.mapping.mapping_determinant; + if (!(referenceDeterminant > 0.0) || !std::isfinite(referenceDeterminant) || + !(point.mapping.mapping_determinant > 0.0) || !std::isfinite(point.mapping.mapping_determinant) || + !(weight > 0.0L) || !std::isfinite(weight)) { + ++invalidSamples; + continue; + } + const auto &position = point.mapping.physical_position; + state.rotation.potential_gradient(position, centrifugalGradient); + bool finite = std::isfinite(point.rho) && std::isfinite(point.h) && std::isfinite(point.phi) && + std::isfinite(point.rotationPotential); + for (int d = 0; d < 3; ++d) { + finite = finite && std::isfinite(position(d)) && std::isfinite(point.gravityGradientPhysical(d)) && + std::isfinite(point.potentialGradientPhysical(d)) && + std::isfinite(point.enthalpyGradientPhysical(d)) && std::isfinite(centrifugalGradient(d)); + } + if (!finite) { + ++invalidSamples; + continue; + } + ++validSamples; + const long double rho = point.rho; + const long double h = point.h; + const long double phi = point.phi; + const long double psi = point.rotationPotential; + const long double rhoH = h > 0.0L ? std::pow(h / enthalpyFactor, index) : 0.0L; + const long double pH = h > 0.0L ? rhoH * h / (index + 1.0L) : 0.0L; + const long double pRho = rho >= 0.0L ? constant * std::pow(rho, 1.0L + 1.0L / index) : 0.0L; + const bool finiteHEos = std::isfinite(rhoH) && std::isfinite(pH); + const bool finiteRhoEos = rho >= 0.0L && std::isfinite(pRho); + if (!finiteHEos) + ++invalidEnthalpyEosSamples; + if (rho >= 0.0L && !finiteRhoEos) + ++invalidDensityEosSamples; + + volume += weight; + mass += weight * rho; + binding += 0.5L * weight * rho * phi; + kinetic += weight * rho * psi; + const long double specificBernoulli = h + phi - psi; + if (validSamples == 1) { + // Exact initialization avoids a spurious first-sample + // variance from rounding an O(C) Bernoulli value. + bernoulliMean = specificBernoulli; + } else { + const long double delta = specificBernoulli - bernoulliMean; + bernoulliMean += weight / volume * delta; + bernoulliCenteredSquared += weight * delta * (specificBernoulli - bernoulliMean); + } + const long double rawBernoulli = specificBernoulli - state.bernoulliConstant; + bernoulliRawSquared += weight * rawBernoulli * rawBernoulli; + bernoulliMin = std::min(bernoulliMin, specificBernoulli); + bernoulliMax = std::max(bernoulliMax, specificBernoulli); + rhoMin = std::min(rhoMin, rho); + rhoMax = std::max(rhoMax, rho); + hMin = std::min(hMin, h); + hMax = std::max(hMax, h); + minDeterminant = std::min(minDeterminant, static_cast(point.mapping.mapping_determinant)); + if (rho < 0.0L) { + ++negativeRhoSamples; + negativeRhoVolume += weight; + negativeRhoMassMagnitude -= weight * rho; + } + if (h < 0.0L) { + ++negativeHSamples; + negativeHVolume += weight; + } + if (finiteHEos) { + pressureH += weight * pH; + const long double closure = rho - rhoH; + closureIntegral += weight * closure; + closureSquared += weight * closure * closure; + closureMax = std::max(closureMax, std::abs(closure)); + rhoFromHMax = std::max(rhoFromHMax, rhoH); + if (h > 0.0L) { + const long double derivative = index * rhoH / h; + drhodhPositiveMin = std::min(drhodhPositiveMin, derivative); + drhodhPositiveMax = std::max(drhodhPositiveMax, derivative); + } + } + if (finiteRhoEos) + pressureRhoNonnegative += weight * pRho; + if (finiteHEos && finiteRhoEos) { + const long double pressureMismatch = pRho - pH; + pressureMismatchSquared += weight * pressureMismatch * pressureMismatch; + pressureMismatchVolume += weight; + } + + std::array radius{}, velocity{}; + for (int d = 0; d < 3; ++d) + radius[d] = static_cast(position(d)) - center(d); + velocity[0] = + static_cast(omega(1)) * radius[2] - static_cast(omega(2)) * radius[1]; + velocity[1] = + static_cast(omega(2)) * radius[0] - static_cast(omega(0)) * radius[2]; + velocity[2] = + static_cast(omega(0)) * radius[1] - static_cast(omega(1)) * radius[0]; + angularMomentum[0] += weight * rho * (radius[1] * velocity[2] - radius[2] * velocity[1]); + angularMomentum[1] += weight * rho * (radius[2] * velocity[0] - radius[0] * velocity[2]); + angularMomentum[2] += weight * rho * (radius[0] * velocity[1] - radius[1] * velocity[0]); + inertiaZ += weight * rho * (radius[0] * radius[0] + radius[1] * radius[1]); + for (int d = 0; d < 3; ++d) { + const long double flux = point.gravityGradientPhysical(d); + forceBinding -= weight * rho * position(d) * flux; + firstMoment[d] += weight * rho * position(d); + gravitationalForce[d] -= weight * rho * flux; + const long double mismatch = flux - point.potentialGradientPhysical(d); + const long double hydrostatic = point.enthalpyGradientPhysical(d) + flux - centrifugalGradient(d); + gradientMismatchSquared += weight * mismatch * mismatch; + hydrostaticGradientSquared += weight * hydrostatic * hydrostatic; + } + } + } + + const bool complete = invalidSamples == 0 && validSamples > 0 && volume > 0.0L && std::isfinite(volume); + const bool validHEos = complete && invalidEnthalpyEosSamples == 0; + const bool validRhoEos = complete && negativeRhoSamples == 0 && invalidDensityEosSamples == 0; + const bool validBinding = complete && std::isfinite(binding) && binding < 0.0L; + const auto value = [nan](const long double v, const bool valid = true) -> double { + const double result = static_cast(v); + return valid && std::isfinite(result) ? result : nan; + }; + const auto rms = [volume](const long double sum) { + if (!(volume > 0.0L) || !std::isfinite(volume) || !std::isfinite(sum)) { + return std::numeric_limits::quiet_NaN(); + } + return std::sqrt(std::max(0.0L, sum / volume)); + }; + const auto relative = [nan, &value](const long double a, const long double denominator, const bool valid) { + return valid && denominator > 0.0L ? value(a / denominator) : nan; + }; + const bool validPressureMismatch = validHEos && pressureMismatchVolume > 0.0L && invalidDensityEosSamples == 0; + const double pressureMismatchRms = + validPressureMismatch ? value(std::sqrt(pressureMismatchSquared / pressureMismatchVolume)) : nan; + const long double rhoScale = std::max({std::abs(rhoMin), std::abs(rhoMax), rhoFromHMax}); + const long double hScale = std::max(std::abs(hMin), std::abs(hMax)); + const long double absoluteBinding = std::abs(binding); + const long double volumeRadius = std::cbrt(3.0L * volume / (4.0L * std::numbers::pi_v)); + + Measurements result{ + {"quadrature_order", static_cast(quadratureOrder)}, + {"polytropic_index", n}, + {"polytropic_constant", K}, + {"stellar_samples", static_cast(samples)}, + {"valid_stellar_samples", static_cast(validSamples)}, + {"invalid_stellar_samples", static_cast(invalidSamples)}, + {"complete_volume_measurement", complete ? 1.0 : 0.0}, + {"pressure_from_enthalpy_defined_everywhere", validHEos ? 1.0 : 0.0}, + {"pressure_from_density_defined_everywhere", validRhoEos ? 1.0 : 0.0}, + {"invalid_enthalpy_eos_samples", static_cast(invalidEnthalpyEosSamples)}, + {"invalid_density_eos_samples", static_cast(invalidDensityEosSamples)}, + {"volume", value(volume, complete)}, + {"mass", value(mass, complete)}, + {"volume_radius", value(volumeRadius, complete)}, + {"binding_energy", value(binding, complete)}, + {"force_binding_energy", value(forceBinding, complete)}, + {"kinetic_energy", value(kinetic, complete)}, + {"pressure_integral", value(pressureRhoNonnegative, validRhoEos)}, + {"enthalpy_pressure_integral", value(pressureH, validHEos)}, + {"nonnegative_density_pressure_integral", + value(pressureRhoNonnegative, complete && invalidDensityEosSamples == 0)}, + {"kinetic_to_binding_ratio", relative(kinetic, absoluteBinding, validBinding)}, + {"gravity_energy_consistency", relative(std::abs(binding - forceBinding), absoluteBinding, validBinding)}, + {"moment_of_inertia_z", value(inertiaZ, complete)}, + {"angular_velocity_norm", state.rotation.angular_velocity().Norml2()}, + {"angular_momentum_z", value(angularMomentum[2], complete)}, + {"minimum_density", value(rhoMin)}, + {"maximum_density", value(rhoMax)}, + {"minimum_enthalpy", value(hMin)}, + {"maximum_enthalpy", value(hMax)}, + {"minimum_mapping_determinant", value(minDeterminant)}, + {"negative_density_samples", static_cast(negativeRhoSamples)}, + {"negative_enthalpy_samples", static_cast(negativeHSamples)}, + {"negative_density_volume_fraction", relative(negativeRhoVolume, volume, complete)}, + {"negative_enthalpy_volume_fraction", relative(negativeHVolume, volume, complete)}, + {"negative_density_mass_magnitude", value(negativeRhoMassMagnitude, complete)}, + {"negative_density_mass_fraction", relative(negativeRhoMassMagnitude, std::abs(mass), complete)}, + {"bernoulli_constant", state.bernoulliConstant}, + {"bernoulli_mean", value(bernoulliMean, complete)}, + {"bernoulli_mean_minus_constant", value(bernoulliMean - state.bernoulliConstant, complete)}, + {"bernoulli_raw_rms", value(rms(bernoulliRawSquared), complete)}, + {"bernoulli_mean_subtracted_rms", value(rms(bernoulliCenteredSquared), complete)}, + {"bernoulli_raw_rms_scaled", relative(rms(bernoulliRawSquared), hScale, complete)}, + {"bernoulli_mean_subtracted_rms_scaled", relative(rms(bernoulliCenteredSquared), hScale, complete)}, + {"bernoulli_sampled_range", value(bernoulliMax - bernoulliMin, complete)}, + {"eos_density_mismatch_integral", value(closureIntegral, validHEos)}, + {"eos_density_mismatch_rms", value(rms(closureSquared), validHEos)}, + {"eos_density_mismatch_maximum", value(closureMax, validHEos)}, + {"eos_density_mismatch_rms_scaled", relative(rms(closureSquared), rhoScale, validHEos)}, + {"eos_density_mismatch_maximum_scaled", relative(closureMax, rhoScale, validHEos)}, + {"eos_density_scale_sampled_maximum", value(rhoScale, validHEos)}, + {"eos_enthalpy_scale_sampled_maximum", value(hScale, complete)}, + {"eos_drhodh_positive_h_minimum", value(drhodhPositiveMin, validHEos)}, + {"eos_drhodh_positive_h_maximum", value(drhodhPositiveMax, validHEos)}, + {"eos_pressure_mismatch_rms_on_nonnegative_density", pressureMismatchRms}, + {"gravity_gradient_vs_broken_potential_gradient_rms", value(rms(gradientMismatchSquared), complete)}, + {"hydrostatic_gradient_rms", value(rms(hydrostaticGradientSquared), complete)}}; + for (const auto &entry : + std::array, 2>{{{"", pressureRhoNonnegative}, {"enthalpy_", pressureH}}}) { + const bool validPressure = entry.first.empty() ? validRhoEos : validHEos; + const long double energyNumerator = 2.0L * kinetic + binding + 3.0L * entry.second; + const long double forceNumerator = 2.0L * kinetic + forceBinding + 3.0L * entry.second; + result[entry.first + "virial_signed"] = + relative(energyNumerator, absoluteBinding, validBinding && validPressure); + result[entry.first + "virial_error"] = + relative(std::abs(energyNumerator), absoluteBinding, validBinding && validPressure); + // Use |W_energy| for both definitions to compare their numerators. + result[entry.first + "force_virial_signed"] = + relative(forceNumerator, absoluteBinding, validBinding && validPressure); + result[entry.first + "force_virial_error"] = + relative(std::abs(forceNumerator), absoluteBinding, validBinding && validPressure); + } + long double omegaDotAngularMomentum = 0.0L; + for (int d = 0; d < 3; ++d) { + result["center_of_mass_" + std::to_string(d)] = complete && mass != 0.0L ? value(firstMoment[d] / mass) : nan; + result["angular_momentum_about_rotation_center_" + std::to_string(d)] = value(angularMomentum[d], complete); + result["net_gravitational_force_" + std::to_string(d)] = value(gravitationalForce[d], complete); + result["rotation_center_" + std::to_string(d)] = center(d); + result["angular_velocity_" + std::to_string(d)] = omega(d); + omegaDotAngularMomentum += static_cast(omega(d)) * angularMomentum[d]; + } + result["rigid_rotation_energy_identity_absolute_error"] = + value(std::abs(omegaDotAngularMomentum - 2.0L * kinetic), complete); + return result; +} +} // namespace experiment::plateau_diagnostics diff --git a/experiments/plateau_preconditioner_diagnostics.hpp b/experiments/plateau_preconditioner_diagnostics.hpp new file mode 100644 index 0000000..67d278f --- /dev/null +++ b/experiments/plateau_preconditioner_diagnostics.hpp @@ -0,0 +1,230 @@ +#pragma once + +// Experiment-only, single-rank inspection. Include after `import mean_field;`. +// Requires an already-prepared accepted context. No physical/trial states are +// changed. Cost: three full Jv plus one Jv per scalar border coordinate, and +// corresponding inexpensive inverse actions. No extra Krylov solves. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace experiment::plateau_diagnostics { +namespace preconditioner_detail { +inline std::ofstream Open(const std::filesystem::path &path) { + std::ofstream file(path); + file.exceptions(std::ios::failbit | std::ios::badbit); + file << std::setprecision(17); + return file; +} +inline double Norm(const mfem::Vector &vector, int offset, int size) { + long double sum = 0.0L; + for (int i = offset; i < offset + size; ++i) { + sum += static_cast(vector(i)) * vector(i); + } + return std::sqrt(sum); +} +inline double RelativeDifference(const mfem::Vector &left, const mfem::Vector &right) { + mfem::Vector difference(left); + difference -= right; + return difference.Norml2() / std::max({left.Norml2(), right.Norml2(), 1.0e-300}); +} +} // namespace preconditioner_detail + +template void InspectPreconditioner(State &state, const std::filesystem::path &outputDirectory) { + namespace detail = preconditioner_detail; + namespace normalization = mean_field::normalization; + int ranks = 0; + MPI_Comm_size(state.Problem().GetCommunicator(), &ranks); + if (ranks != 1) + throw std::invalid_argument("Preconditioner diagnostics require one MPI rank."); + if (!state.normalizedOperator->IsPrepared()) + throw std::logic_error("Preconditioner diagnostics require a prepared accepted state."); + state.physicalInverse->Refresh(); + const auto &grouped = state.physicalInverse->GetGroupedPreconditioner(); + const auto &structure = grouped.GetStructurePreconditioner(); + const auto &material = structure.GetMaterialSurfacePreconditioner(); + const auto &couplings = grouped.GetCouplings(); + const auto &coordinates = state.physicalInverse->GetCoordinateMap(); + const auto &normalizationMap = state.normalizedOperator->GetNormalization(); + const auto values = state.Problem().GetManifest().valueBlocks(); + const auto residuals = state.Problem().GetManifest().residualBlocks(); + const int n = state.Problem().StateSize(); + const int k = couplings.StructureSize(); + const int borderSize = couplings.BorderSize(); + mfem::Vector groupedL(n), groupedV(n); + coordinates.PackResidual(normalizationMap.ResidualFactors(), groupedL); + coordinates.PackCorrection(normalizationMap.StateFactors(), groupedV); + + auto ranges = detail::Open(outputDirectory / "normalization_factor_ranges.csv"); + ranges << "kind,block,size,minimum,maximum,max_over_min,rms,manifest_scale\n"; + auto recordFactors = [&](const char *kind, const auto &blocks, const mfem::Vector &factors) { + for (const auto &block : blocks) { + double minimum = std::numeric_limits::infinity(), maximum = 0.0; + for (int i = block.offset; i < block.offset + block.size; ++i) { + minimum = std::min(minimum, factors(i)); + maximum = std::max(maximum, factors(i)); + } + ranges << kind << ',' << block.stableId << ',' << block.size << ',' << minimum << ',' << maximum << ',' + << maximum / minimum << ',' + << (block.size ? detail::Norm(factors, block.offset, block.size) / std::sqrt(block.size) : 0.0) + << ',' << block.scale << '\n'; + } + }; + recordFactors("value", values, normalizationMap.StateFactors()); + recordFactors("residual", residuals, normalizationMap.ResidualFactors()); + + auto diagonal = detail::Open(outputDirectory / "preconditioner_diagonal_quality.csv"); + diagonal << "block,min_abs_before,max_abs_before,applied_floor,regularized_entries\n"; + auto recordDiagonal = [&](const char *name, const auto &quality) { + diagonal << name << ',' << quality.minimumAbsoluteEntryBeforeRegularization << ',' + << quality.maximumAbsoluteEntryBeforeRegularization << ',' << quality.appliedFloor << ',' + << quality.regularizedEntries << '\n'; + }; + recordDiagonal("density", material.GetDensityDiagonalQuality()); + recordDiagonal("surface", material.GetSurfaceDiagonalQuality()); + recordDiagonal("enthalpy", material.GetEnthalpyDiagonalQuality()); + + // Map grouped scalar indices back to public manifest labels. + auto groupedLabel = [](int index, const auto &coordinateRanges, const auto &blocks) { + for (const auto &range : coordinateRanges) { + if (index < range.preconditionerOffset || index >= range.preconditionerOffset + range.size) + continue; + const int rootIndex = range.equilibriumOffset + index - range.preconditionerOffset; + for (const auto &block : blocks) + if (rootIndex >= block.offset && rootIndex < block.offset + block.size) + return std::string(block.stableId); + } + return std::string("unknown"); + }; + std::vector valueLabels, residualLabels; + auto layout = detail::Open(outputDirectory / "border_normalization_layout.csv"); + layout << "index,value,residual,state_to_normalized,residual_to_normalized\n"; + for (int j = 0; j < borderSize; ++j) { + valueLabels.push_back(groupedLabel(k + j, coordinates.GetCorrectionRanges(), values)); + residualLabels.push_back(groupedLabel(k + j, coordinates.GetResidualRanges(), residuals)); + layout << j << ',' << valueLabels.back() << ',' << residualLabels.back() << ',' << groupedV(k + j) << ',' + << groupedL(k + j) << '\n'; + } + const auto &schur = grouped.GetFactorization().GetSchurComplement(); + mfem::DenseMatrix scaledSchur(borderSize, borderSize); + auto schurEntries = detail::Open(outputDirectory / "border_schur_entries.csv"); + auto schurMatrix = detail::Open(outputDirectory / "border_schur_normalized_matrix.csv"); + schurEntries << "row,column,residual,value,physical_entry,normalized_entry\n"; + for (int i = 0; i < borderSize; ++i) { + for (int j = 0; j < borderSize; ++j) { + scaledSchur(i, j) = groupedL(k + i) * schur(i, j) / groupedV(k + j); + schurEntries << i << ',' << j << ',' << residualLabels[i] << ',' << valueLabels[j] << ',' << schur(i, j) + << ',' << scaledSchur(i, j) << '\n'; + schurMatrix << (j ? "," : "") << scaledSchur(i, j); + } + schurMatrix << '\n'; + } + + auto consistency = detail::Open(outputDirectory / "normalization_consistency.csv"); + consistency << "check,relative_difference\n"; + // One deterministic RHS balanced across residual blocks. This is a + // coordinate-consistency check, not a condition-number estimator. + mfem::Vector rhs(n); + rhs = 0.0; + for (const auto &block : residuals) { + for (int i = 0; i < block.size; ++i) + rhs(block.offset + i) = std::sin(0.173 * static_cast(block.offset + i + 1)); + const double blockNorm = detail::Norm(rhs, block.offset, block.size); + if (blockNorm > 0.0) + for (int i = 0; i < block.size; ++i) + rhs(block.offset + i) /= blockNorm; + } + mfem::Vector groupedRhs(n), recoveredRhs(n); + coordinates.PackResidual(rhs, groupedRhs); + coordinates.UnpackResidual(groupedRhs, recoveredRhs); + consistency << "residual_coordinate_roundtrip," << detail::RelativeDifference(rhs, recoveredRhs) << '\n'; + coordinates.PackCorrection(rhs, groupedRhs); + coordinates.UnpackCorrection(groupedRhs, recoveredRhs); + consistency << "correction_coordinate_roundtrip," << detail::RelativeDifference(rhs, recoveredRhs) << '\n'; + + mfem::Vector baseCorrection(n), baseAction(n), physicalRhs(n), physicalCorrection(n); + mfem::Vector expectedCorrection(n), physicalAction(n), expectedAction(n); + state.normalizedInverse->Mult(rhs, baseCorrection); + state.normalizedOperator->Mult(baseCorrection, baseAction); + normalizationMap.DenormalizeResidual(rhs, physicalRhs); + state.physicalInverse->Mult(physicalRhs, physicalCorrection); + normalizationMap.NormalizeState(physicalCorrection, expectedCorrection); + state.Problem().GetLinearizationOperator().Mult(physicalCorrection, physicalAction); + normalizationMap.NormalizeResidual(physicalAction, expectedAction); + consistency << "inverse_wrapper_vs_explicit_physical," + << detail::RelativeDifference(baseCorrection, expectedCorrection) << '\n'; + consistency << "jacobian_wrapper_vs_explicit_physical," << detail::RelativeDifference(baseAction, expectedAction) + << '\n'; + + // Change only variable coordinates, leaving the residual norm and physical + // inverse unchanged. Jhat Phat^-1 must be invariant up to roundoff. + mfem::Vector alternateFactors(normalizationMap.StateFactors()); + int blockIndex = 0; + for (const auto &block : values) { + const double multiplier = std::pow(10.0, static_cast((blockIndex++ % 7) - 3)); + for (int i = block.offset; i < block.offset + block.size; ++i) + alternateFactors(i) *= multiplier; + } + normalization::DiagonalNormalization alternateMap(std::move(alternateFactors), + mfem::Vector(normalizationMap.ResidualFactors())); + normalization::ScaledInverseOperator alternateInverse(*state.physicalInverse, alternateMap); + normalization::ScaledJacobianOperator alternateJacobian(state.Problem().GetLinearizationOperator(), alternateMap); + mfem::Vector alternateCorrection(n), alternateAction(n), alternatePhysicalCorrection(n); + alternateInverse.Mult(rhs, alternateCorrection); + alternateJacobian.Mult(alternateCorrection, alternateAction); + alternateMap.DenormalizeState(alternateCorrection, alternatePhysicalCorrection); + consistency << "state_scaling_cancelled_right_preconditioned_action," + << detail::RelativeDifference(baseAction, alternateAction) << '\n'; + consistency << "state_scaling_cancelled_physical_correction," + << detail::RelativeDifference(physicalCorrection, alternatePhysicalCorrection) << '\n'; + + // Each direction is [-Atilde^-1 B y; y], where y is a unit NORMALIZED + // scalar coordinate. Its true stellar Jacobian action exposes structure + // cancellation errors; its border action should equal the scaled Schur + // column. These directions are probes, never proposed Newton steps. + auto columns = detail::Open(outputDirectory / "border_completed_column_actions.csv"); + columns << "column,value,kind,block,physical_norm,normalized_norm\n"; + auto borderDefects = detail::Open(outputDirectory / "border_completed_schur_consistency.csv"); + borderDefects << "column,value,border_relative_difference,true_structure_action_norm,normalized_direction_norm\n"; + mfem::Vector scalarDirection(borderSize), structureRhs(k), structureCorrection(k); + mfem::Vector groupedDirection(n), rootDirection(n), normalizedDirection(n); + mfem::Vector normalizedAction(n), rootAction(n), groupedAction(n); + for (int j = 0; j < borderSize; ++j) { + scalarDirection = 0.0; + scalarDirection(j) = 1.0 / groupedV(k + j); + couplings.ApplyBorderToStructure(scalarDirection, structureRhs); + structure.Mult(structureRhs, structureCorrection); + for (int i = 0; i < k; ++i) + groupedDirection(i) = -structureCorrection(i); + for (int i = 0; i < borderSize; ++i) + groupedDirection(k + i) = scalarDirection(i); + coordinates.UnpackCorrection(groupedDirection, rootDirection); + normalizationMap.NormalizeState(rootDirection, normalizedDirection); + state.normalizedOperator->Mult(normalizedDirection, normalizedAction); + normalizationMap.DenormalizeResidual(normalizedAction, rootAction); + coordinates.PackResidual(normalizedAction, groupedAction); + for (const auto &block : values) + columns << j << ',' << valueLabels[j] << ",direction," << block.stableId << ',' + << detail::Norm(rootDirection, block.offset, block.size) << ',' + << detail::Norm(normalizedDirection, block.offset, block.size) << '\n'; + for (const auto &block : residuals) + columns << j << ',' << valueLabels[j] << ",action," << block.stableId << ',' + << detail::Norm(rootAction, block.offset, block.size) << ',' + << detail::Norm(normalizedAction, block.offset, block.size) << '\n'; + mfem::Vector actual(borderSize), expected(borderSize); + for (int i = 0; i < borderSize; ++i) { + actual(i) = groupedAction(k + i); + expected(i) = scaledSchur(i, j); + } + borderDefects << j << ',' << valueLabels[j] << ',' << detail::RelativeDifference(actual, expected) << ',' + << detail::Norm(groupedAction, 0, k) << ',' << normalizedDirection.Norml2() << '\n'; + } +} +} // namespace experiment::plateau_diagnostics diff --git a/experiments/system_isolation_profile_harness.cpp b/experiments/system_isolation_profile_harness.cpp new file mode 100644 index 0000000..fe1525e --- /dev/null +++ b/experiments/system_isolation_profile_harness.cpp @@ -0,0 +1,1682 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +import mean_field; + +namespace { + using namespace mean_field; + using Clock = std::chrono::steady_clock; + using Apply = std::function; + + struct Options { + std::string mesh{"sandbox.smesh"}; + std::filesystem::path output{"system_isolation_results"}; + std::string system{"hdiv-displacement"}; + std::string implementation{"both"}; + std::string check{"full"}; + int refinements{0}, samples{5}, iterations{3}, warmup{1}, directions{3}; + int prepareSamples{3}, prepareIterations{1}; + double deformation{0.7}, finiteDifferenceStep{1.0e-5}, polytropicIndex{1.0}; + bool help{false}; + }; + + int Integer(const std::string &value) { + std::size_t consumed = 0; + const int result = std::stoi(value, &consumed); + if (consumed != value.size()) throw std::invalid_argument("Invalid integer: " + value); + return result; + } + + double Real(const std::string &value) { + std::size_t consumed = 0; + const double result = std::stod(value, &consumed); + if (consumed != value.size() || !std::isfinite(result)) + throw std::invalid_argument("Invalid finite number: " + value); + return result; + } + + bool IsLinearDerivativeSystem(const std::string &name); + + Options Parse(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 == "--system") options.system = value(); + else if (argument == "--implementation") options.implementation = value(); + else if (argument == "--check") options.check = value(); + else if (argument == "--refinements") options.refinements = Integer(value()); + else if (argument == "--samples") options.samples = Integer(value()); + else if (argument == "--iterations") options.iterations = Integer(value()); + else if (argument == "--warmup") options.warmup = Integer(value()); + else if (argument == "--directions") options.directions = Integer(value()); + else if (argument == "--prepare-samples") options.prepareSamples = Integer(value()); + else if (argument == "--prepare-iterations") options.prepareIterations = Integer(value()); + else if (argument == "--deformation") options.deformation = Real(value()); + else if (argument == "--fd-step") options.finiteDifferenceStep = Real(value()); + else if (argument == "--polytropic-index") options.polytropicIndex = Real(value()); + else if (argument == "--help") options.help = true; + else throw std::invalid_argument("Unknown argument: " + argument); + } + if (options.system != "hdiv-displacement" && options.system != "hdiv-mass" && + options.system != "mass-normalization" && options.system != "angular-momentum" && + options.system != "hydrostatic-displacement" && options.system != "stellar-derivatives" && + options.system != "all" && options.system != "runtime-optimizations" && + options.system != "geometry-preparation" && options.system != "normalization-diagonals" && + options.system != "geometry-inspection" && options.system != "gravity-source-preparation" && + options.system != "final-optimizations" && options.system != "gravity-source-displacement" && + options.system != "hydrostatic-preparation" && options.system != "gravity-schur-preparation" && + !IsLinearDerivativeSystem(options.system)) + throw std::invalid_argument("Unknown system; use --help for the available systems."); + if (options.implementation != "both" && options.implementation != "optimized" && options.implementation != "reference") + throw std::invalid_argument("Implementation must be both, optimized, or reference."); + if (options.check != "full" && options.check != "reference" && options.check != "none") + throw std::invalid_argument("Check must be full, reference, or none."); + if (options.refinements < 0 || options.samples < 1 || options.iterations < 1 || options.warmup < 0 || + options.directions < 1 || options.prepareSamples < 0 || options.prepareIterations < 1 || + options.finiteDifferenceStep <= 0.0 || options.polytropicIndex <= 0.0) + throw std::invalid_argument("Invalid sample count, refinement, or finite-difference step."); + return options; + } + + std::ofstream File(const std::filesystem::path &path) { + std::ofstream stream(path); + stream.exceptions(std::ios::badbit | std::ios::failbit); + stream << std::setprecision(17); + return stream; + } + + mfem::Vector Displacement(const fem::FEM &f, double scale, double phase = 0.0) { + mfem::ParGridFunction field(f.displacementFes.get()); + mfem::VectorFunctionCoefficient coefficient(3, [=](const mfem::Vector &x, mfem::Vector &u) { + u.SetSize(3); + u(0) = scale * (0.04 * x(0) + 0.01 * x(1) * x(2) + phase * (0.012 + 0.007 * x(2))); + u(1) = scale * (-0.03 * x(1) + 0.008 * x(0) * x(2) + phase * (0.009 * x(0) - 0.003)); + u(2) = scale * (0.02 * x(2) - 0.006 * x(0) * x(1) - phase * 0.011 * x(1)); + }); + field.ProjectCoefficient(coefficient); + mfem::Vector result; + field.GetTrueDofs(result); + return result; + } + + mfem::Vector Flux(int size, double phase) { + mfem::Vector result(size); + for (int i = 0; i < size; ++i) + result(i) = std::sin(0.37 * (i + 1) + phase) + 0.31 * std::cos(0.19 * (i + 1) - 0.5 * phase); + return result; + } + + double Norm(const mfem::Vector &vector) { + for (int i = 0; i < vector.Size(); ++i) + if (!std::isfinite(vector(i))) throw std::runtime_error("An operator returned non-finite output."); + const double result = vector.Norml2(); + if (!std::isfinite(result)) throw std::runtime_error("An output norm is non-finite."); + return result; + } + + double RelativeError(const mfem::Vector &actual, const mfem::Vector &expected) { + if (actual.Size() != expected.Size()) throw std::runtime_error("Comparison vector sizes differ."); + mfem::Vector difference(actual); + difference -= expected; + return Norm(difference) / std::max({Norm(actual), Norm(expected), 1.0e-30}); + } + + double Median(std::vector values) { + std::sort(values.begin(), values.end()); + const std::size_t middle = values.size() / 2; + return values.size() % 2 ? values[middle] : 0.5 * (values[middle - 1] + values[middle]); + } + + struct CheckReport { + std::ofstream stream; + bool passed{true}; + void Add(const std::string &name, double error, double tolerance) { + const bool okay = std::isfinite(error) && error <= tolerance; + stream << name << ',' << error << ',' << tolerance << ',' << okay << '\n'; + stream.flush(); + passed = passed && okay; + std::cout << "Check " << name << ": " << error << (okay ? " PASS" : " FAIL") << std::endl; + } + }; + + // All systems use this timing driver. Construction, checks, warmup and output + // reductions are excluded. Alternate order to reduce systematic A/B bias. + void Benchmark(const Options &options, const std::string &system, + const std::string &referenceName, const Apply &optimized, const Apply &reference, + std::ofstream &timings, std::ofstream &summary, double extraPrepareSeconds = 0.0) { + const bool runOptimized = options.implementation != "reference"; + const bool runReference = options.implementation != "optimized"; + mfem::Vector output; + for (int iteration = 0; iteration < options.warmup; ++iteration) { + if (runOptimized) { optimized(iteration % options.directions, output); Norm(output); } + if (runReference) { reference(iteration % options.directions, output); Norm(output); } + } + std::vector optimizedTimes, referenceTimes; + auto sample = [&](const std::string &name, const Apply &apply, std::vector ×, int index) { + const auto start = Clock::now(); + for (int iteration = 0; iteration < options.iterations; ++iteration) + apply(iteration % options.directions, output); + const double seconds = std::chrono::duration(Clock::now() - start).count(); + const double perCall = seconds / options.iterations; + times.push_back(perCall); + timings << system << ',' << name << ',' << index << ',' << options.iterations << ',' + << seconds << ',' << perCall << ',' << Norm(output) << '\n'; + timings.flush(); + std::cout << system << '/' << name << " sample " << index + 1 << ": " << perCall << " s/apply" << std::endl; + }; + for (int index = 0; index < options.samples; ++index) { + if (index % 2 == 0 && runReference) sample(referenceName, reference, referenceTimes, index); + if (runOptimized) sample("optimized", optimized, optimizedTimes, index); + if (index % 2 != 0 && runReference) sample(referenceName, reference, referenceTimes, index); + } + if (runOptimized) summary << system << ".optimized_median_seconds=" << Median(optimizedTimes) << '\n'; + if (runReference) summary << system << '.' << referenceName << "_median_seconds=" << Median(referenceTimes) << '\n'; + if (runOptimized && runReference) { + const double speedup = Median(referenceTimes) / Median(optimizedTimes); + summary << system << ".reference_over_optimized=" << speedup << '\n'; + if (extraPrepareSeconds > 0.0) { + const double saving = Median(referenceTimes) - Median(optimizedTimes); + summary << system << ".row_prepare_seconds=" << extraPrepareSeconds << '\n'; + summary << system << ".row_prepare_break_even_applications=" + << (saving > 0.0 ? std::ceil(extraPrepareSeconds / saving) : -1.0) << '\n'; + for (const int applications : {1, 10, 25}) + summary << system << ".reference_over_optimized_including_row_prepare_" << applications << '=' + << applications * Median(referenceTimes) / + (extraPrepareSeconds + applications * Median(optimizedTimes)) << '\n'; + } + std::cout << system << " reference/optimized median ratio: " << speedup << std::endl; + } + summary.flush(); + } + + // State/context updates happen before either timer. Each timed operation + // must rebuild once; changing revisions prevents accidental no-op timings. + void BenchmarkPreparation( + const Options &options, const std::string &system, const std::function &advance, + const std::function &optimized, const std::function &reference, + const std::function &optimizedResidual, + const std::function &referenceResidual, + std::ofstream &timings, std::ofstream &summary + ) { + if (options.prepareSamples == 0) return; + const bool runOptimized = options.implementation != "reference"; + const bool runReference = options.implementation != "optimized"; + std::vector optimizedTimes, referenceTimes; + mfem::Vector residual; + for (int sample = 0; sample < options.prepareSamples; ++sample) { + double fastSeconds = 0.0, referenceSeconds = 0.0; + auto measure = [&](const std::function &operation, double &seconds) { + const auto start = Clock::now(); + operation(); + seconds += std::chrono::duration(Clock::now() - start).count(); + }; + for (int iteration = 0; iteration < options.prepareIterations; ++iteration) { + advance(); + if (sample % 2 == 0 && runReference) measure(reference, referenceSeconds); + if (runOptimized) measure(optimized, fastSeconds); + if (sample % 2 != 0 && runReference) measure(reference, referenceSeconds); + } + auto record = [&](const std::string &name, double seconds, auto &values, const auto &getResidual) { + getResidual(residual); // Residual copy/norm is outside preparation timing. + const double perCall = seconds / options.prepareIterations; + values.push_back(perCall); + timings << system << ',' << name << ',' << sample << ',' << options.prepareIterations << ',' + << seconds << ',' << perCall << ',' << Norm(residual) << '\n'; + std::cout << system << '/' << name << " sample " << sample + 1 << ": " + << perCall << " s/prepare" << std::endl; + }; + if (runOptimized) record("optimized", fastSeconds, optimizedTimes, optimizedResidual); + if (runReference) record("reference", referenceSeconds, referenceTimes, referenceResidual); + timings.flush(); + } + if (runOptimized) summary << system << ".optimized_median_seconds=" << Median(optimizedTimes) << '\n'; + if (runReference) summary << system << ".reference_median_seconds=" << Median(referenceTimes) << '\n'; + if (runOptimized && runReference) + summary << system << ".reference_over_optimized=" << Median(referenceTimes) / Median(optimizedTimes) << '\n'; + summary.flush(); + } + + mfem::Vector ScalarField(mfem::ParFiniteElementSpace &space, double offset, double phase) { + mfem::ParGridFunction field(&space); + mfem::FunctionCoefficient coefficient([=](const mfem::Vector &x) { + return offset + 0.09 * std::sin(0.71 * x(0) + phase) + + 0.05 * std::cos(0.63 * x(1) - phase) + 0.02 * x(2); + }); + field.ProjectCoefficient(coefficient); + mfem::Vector result; + field.GetTrueDofs(result); + return result; + } + + // Mixed relative/absolute error also handles zero scalar columns without + // amplifying harmless cancellation into an artificial relative failure. + double ScaledError(const mfem::Vector &actual, const mfem::Vector &expected) { + if (actual.Size() != expected.Size()) throw std::runtime_error("Comparison vector sizes differ."); + mfem::Vector difference(actual); + difference -= expected; + return Norm(difference) / std::max({1.0, Norm(actual), Norm(expected)}); + } + + int RunStellarDerivatives(const Options &options, fem::FEM &f, std::ofstream &metadata, + std::ofstream &summary, std::ofstream &timings, CheckReport &checks) { + const bool group = options.system == "all" || options.system == "stellar-derivatives"; + const bool runMass = group || options.system == "mass-normalization"; + const bool runAngular = group || options.system == "angular-momentum"; + const bool runHydro = group || options.system == "hydrostatic-displacement"; + using GravityContext = operators::context::gravity_field::GravityFieldLinearizationContext; + std::unique_ptr gravity; + std::unique_ptr mass; + std::unique_ptr angular; + std::unique_ptr hydro; + const auto constructStart = Clock::now(); + if (runMass || runAngular) gravity = std::make_unique(f, *f.domainMapperStateless); + if (runMass) mass = std::make_unique(f, *f.domainMapperStateless, *gravity); + if (runAngular) angular = std::make_unique( + f, *f.domainMapperStateless, *gravity, + models::compileConstraint(integral::FixedAngularMomentum( + {.Jtotal = dimensions::AngularMomentumValue{0.41}, .axis = {1.0, -2.0, 3.0}, .center = {0.12, -0.07, 0.03}}))); + if (runHydro) hydro = std::make_unique(f, *f.domainMapperStateless); + summary << "stellar.operator_construction_seconds=" << std::chrono::duration(Clock::now() - constructStart).count() << '\n'; + + const auto baseDensity = ScalarField(*f.densityFes, 0.94, 0.31); + const auto baseDisplacement = Displacement(f, options.deformation); + const auto enthalpy = ScalarField(*f.enthalpyFes, 0.73, 0.27); + const auto potential = ScalarField(*f.gravityPotentialFes, -0.38, 0.61); + mfem::Vector flux(f.gravityFluxFes->GetTrueVSize()); + flux = 0.0; + operators::MassNormalizationDependencies md{ + .discretization = {1, 1}, .density = {2, 1}, .displacement = {3, 1}, .targetMass = {4, 1}}; + operators::AngularMomentumDependencies ad{ + .discretization = {1, 1}, .density = {2, 1}, .displacement = {3, 1}, .rotation = {5, 1}}; + operators::context::hydrostatic::HydrostaticEquilibriumDependencies hd{ + .discretization = {1, 1}, .enthalpy = {6, 1}, .gravityPotential = {7, 1}, + .displacement = {3, 1}, .rotation = {5, 1}, .bernoulliConstant = {8, 1}}; + double targetMass = 1.23; + auto prepare = [&](const mfem::Vector &density, const mfem::Vector &displacement, double omega, + bool geometryChanged, bool densityChanged, bool rotationChanged, + const std::string &timingLabel) { + if (geometryChanged) ++md.displacement.revision; + if (densityChanged) ++md.density.revision; + if (rotationChanged) ++ad.rotation.revision; + ad.density.revision = md.density.revision; + ad.displacement.revision = md.displacement.revision; + hd.displacement.revision = md.displacement.revision; + hd.rotation.revision = ad.rotation.revision; + auto time = [&](const std::string &name, auto &&operation) { + const auto start = Clock::now(); + operation(); + if (!timingLabel.empty()) summary << name << '.' << timingLabel << "_seconds=" + << std::chrono::duration(Clock::now() - start).count() << '\n'; + }; + if (gravity) time("stellar.shared_gravity_prepare", [&] { + gravity->Prepare( + {.density = gravity->GetDensityMap().gather(density), + .displacement = gravity->GetDisplacementMap().gather(displacement), + .gravity_gradient = gravity->GetGravityGradientMap().gather(flux), + .gravity_potential = gravity->GetGravityPotentialMap().gather(potential)}, + {.discretization = {1}, .displacement = {md.displacement.revision}, + .density = {md.density.revision}, .gravity_gradient = {1}, .gravity_potential = {1}}); + }); + if (mass) time("mass-normalization.prepare", [&] { mass->Prepare({.targetMass = targetMass}, md); }); + if (angular) time("angular-momentum.prepare", [&] { angular->Prepare(omega, ad); }); + if (hydro) time("hydrostatic-displacement.prepare", [&] { + mfem::Vector velocity(3), center(3); + velocity(0) = omega / std::sqrt(14.0); velocity(1) = -2.0 * velocity(0); velocity(2) = 3.0 * velocity(0); + center(0) = 0.12; center(1) = -0.07; center(2) = 0.03; + hydro->Prepare({.enthalpy = hydro->GetEnthalpyMap().gather(enthalpy), + .gravityPotential = hydro->GetGravityPotentialMap().gather(potential), + .displacement = hydro->GetDisplacementMap().gather(displacement), + .bernoulliConstant = 0.39}, hd, physics::RigidRotation(velocity, center)); + }); + }; + prepare(baseDensity, baseDisplacement, 0.63, true, true, true, "initial"); + std::vector densityDirections, fullDensityDirections, displacementDirections, + hydroDirections, fullDisplacementDirections; + std::vector omegaDirections; + for (int i = 0; i < options.directions; ++i) { + fullDensityDirections.push_back(ScalarField(*f.densityFes, 0.17, 0.4 + i)); + fullDisplacementDirections.push_back(Displacement(f, 0.43, 0.2 + 0.7 * i)); + // Include a direction with variation at the element scale as well + // as smooth shear/translation directions. It is not a base state. + if (i == 1) fullDisplacementDirections.back().Add(0.002, Flux(f.displacementFes->GetTrueVSize(), 0.37)); + if (gravity) { + densityDirections.push_back(gravity->GetDensityMap().gather(fullDensityDirections.back())); + displacementDirections.push_back(gravity->GetDisplacementMap().gather(fullDisplacementDirections.back())); + } + if (hydro) hydroDirections.push_back(hydro->GetDisplacementMap().gather(fullDisplacementDirections.back())); + omegaDirections.push_back(-0.13 + 0.07 * i); + } + const Apply massFast = [&](int i, mfem::Vector &out) { mass->ApplyCompleteJacobianAction(densityDirections[i], displacementDirections[i], out); }; + const Apply massReference = [&](int i, mfem::Vector &out) { mass->ApplyCompleteJacobianActionReference(densityDirections[i], displacementDirections[i], out); }; + const Apply angularFast = [&](int i, mfem::Vector &out) { angular->ApplyCompleteJacobianAction(densityDirections[i], displacementDirections[i], omegaDirections[i], out); }; + const Apply angularReference = [&](int i, mfem::Vector &out) { angular->ApplyCompleteJacobianActionReference(densityDirections[i], displacementDirections[i], omegaDirections[i], out); }; + const Apply hydroFast = [&](int i, mfem::Vector &out) { hydro->ApplyDisplacementJacobianAction(hydroDirections[i], out); }; + const Apply hydroReference = [&](int i, mfem::Vector &out) { hydro->ApplyDisplacementJacobianActionCachedReference(hydroDirections[i], out); }; + const Apply hydroGeneralReference = [&](int i, mfem::Vector &out) { hydro->ApplyDisplacementJacobianActionReference(hydroDirections[i], out); }; + auto compare = [&](const std::string &name, const Apply &fast, const Apply &reference) { + for (int i = 0; i < options.directions; ++i) { + mfem::Vector a, b; + reference(i, b); + fast(i, a); + checks.Add(name + "_direction" + std::to_string(i), ScaledError(a, b), 2.0e-11); + } + }; + if (options.check != "none") { + const auto validationStart = Clock::now(); + if (mass) checks.Add("mass_rows_initially_lazy", mass->GetJacobianRowPreparationCount(), 0.0); + if (angular) checks.Add("angular_rows_initially_lazy", angular->GetJacobianRowPreparationCount(), 0.0); + const int states = options.check == "full" ? 4 : 1; + for (int state = 0; state < states; ++state) { + const auto density = state >= 2 ? ScalarField(*f.densityFes, 1.13, 0.82) : baseDensity; + const auto displacement = state == 0 && states > 1 ? Displacement(f, 0.0) : baseDisplacement; + prepare(density, displacement, state == 3 ? -0.27 : 0.63, state < 2, state != 3, true, ""); + const std::string label = "state" + std::to_string(state); + const auto massCount = mass ? mass->GetJacobianRowPreparationCount() : 0; + const auto angularCount = angular ? angular->GetJacobianRowPreparationCount() : 0; + if (mass) { + compare(label + "_mass", massFast, massReference); + checks.Add(label + "_mass_row_refresh", mass->GetJacobianRowPreparationCount() == massCount + (state != 3) ? 0.0 : 1.0, 0.0); + } + if (angular) { + compare(label + "_angular", angularFast, angularReference); + checks.Add(label + "_angular_row_refresh", angular->GetJacobianRowPreparationCount() == angularCount + (state != 3) ? 0.0 : 1.0, 0.0); + } + if (hydro) { + compare(label + "_hydro_cached", hydroFast, hydroReference); + compare(label + "_hydro_general", hydroFast, hydroGeneralReference); + mfem::Vector zero(hydroDirections[0].Size()), action; + zero = 0.0; + hydro->ApplyDisplacementJacobianAction(zero, action); + checks.Add(label + "_hydro_zero", Norm(action), 0.0); + } + } + prepare(baseDensity, baseDisplacement, 0.63, true, true, true, ""); + mfem::Vector massAnalytic, angularAnalytic, hydroAnalytic; + if (mass) massFast(0, massAnalytic); + if (angular) angularFast(0, angularAnalytic); + if (hydro) hydroFast(0, hydroAnalytic); + if (options.check == "full") { + // Check individual scalar columns, including zero input, so + // cancellation in a combined direction cannot hide an error. + if (gravity) { + mfem::Vector zeroDensity(densityDirections[0].Size()), zeroDisplacement(displacementDirections[0].Size()); + zeroDensity = 0.0; zeroDisplacement = 0.0; + for (int column = 0; column < 4; ++column) { + const auto &rho = column == 0 ? densityDirections[0] : zeroDensity; + const auto &u = column == 1 ? displacementDirections[0] : zeroDisplacement; + const double omega = column == 2 ? omegaDirections[0] : 0.0; + mfem::Vector a, b; + if (mass) { + mass->ApplyCompleteJacobianAction(rho, u, a); + mass->ApplyCompleteJacobianActionReference(rho, u, b); + checks.Add("mass_column" + std::to_string(column), ScaledError(a, b), 2.0e-11); + } + if (angular) { + angular->ApplyCompleteJacobianAction(rho, u, omega, a); + angular->ApplyCompleteJacobianActionReference(rho, u, omega, b); + checks.Add("angular_column" + std::to_string(column), ScaledError(a, b), 2.0e-11); + } + } + } + if (mass) { + mfem::Vector rhoDual, displacementDual; + mass->ApplyCompleteJacobianTransposeAction(0.73, rhoDual, displacementDual); + mfem::Vector expected(1), actual(1); + expected(0) = 0.73 * massAnalytic(0); + actual(0) = rhoDual * densityDirections[0] + displacementDual * displacementDirections[0]; + checks.Add("mass_transpose_identity", ScaledError(actual, expected), 2.0e-11); + const auto count = mass->GetJacobianRowPreparationCount(); + targetMass = 1.37; ++md.targetMass.revision; + mass->Prepare({.targetMass = targetMass}, md); + mfem::Vector afterTarget; + massFast(0, afterTarget); + checks.Add("mass_target_only_action", ScaledError(afterTarget, massAnalytic), 2.0e-11); + checks.Add("mass_target_only_reuses_rows", mass->GetJacobianRowPreparationCount() == count ? 0.0 : 1.0, 0.0); + } + for (const double step : {options.finiteDifferenceStep, 0.5 * options.finiteDifferenceStep}) { + mfem::Vector massPlus, angularPlus, hydroPlus, massMinus, angularMinus, hydroMinus; + for (const int sign : {1, -1}) { + mfem::Vector rho(baseDensity), displacement(baseDisplacement); + rho.Add(sign * step, fullDensityDirections[0]); + displacement.Add(sign * step, fullDisplacementDirections[0]); + // Scalar FD varies rho/u/Omega together. Hydro FD keeps + // all fields and rotation fixed except displacement. + prepare(rho, displacement, 0.63, true, true, true, ""); + if (mass) mass->BuildResidual(sign == 1 ? massPlus : massMinus); + if (angular) { + ++ad.rotation.revision; + angular->Prepare(0.63 + sign * step * omegaDirections[0], ad); + angular->BuildResidual(sign == 1 ? angularPlus : angularMinus); + } + if (hydro) hydro->BuildResidual(sign == 1 ? hydroPlus : hydroMinus); + } + auto difference = [&](const std::string &name, const mfem::Vector &analytic, mfem::Vector &plus, const mfem::Vector &minus) { + plus -= minus; plus /= 2.0 * step; + checks.Add(name + "_finite_difference_" + std::to_string(step), ScaledError(analytic, plus), 2.0e-6); + }; + if (mass) difference("mass", massAnalytic, massPlus, massMinus); + if (angular) difference("angular", angularAnalytic, angularPlus, angularMinus); + if (hydro) difference("hydro", hydroAnalytic, hydroPlus, hydroMinus); + } + } + summary << "stellar.validation_seconds=" << std::chrono::duration(Clock::now() - validationStart).count() << '\n'; + if (!checks.passed) { summary << "stellar.checks_passed=0\nstellar.benchmark_skipped=1\n"; return 3; } + } + prepare(baseDensity, baseDisplacement, 0.63, true, true, true, "repeat"); + auto rows = [&](const std::string &name, auto &operation) { + if (!operation) return 0.0; + const auto start = Clock::now(); + const bool supported = operation->PrepareJacobianRows(); + const double seconds = std::chrono::duration(Clock::now() - start).count(); + metadata << name << ".prepared_rows_supported=" << supported << '\n'; + summary << name << ".explicit_row_prepare_seconds=" << seconds << '\n'; + return seconds; + }; + const double massRowSeconds = rows("mass-normalization", mass); + const double angularRowSeconds = rows("angular-momentum", angular); + if (hydro) { + metadata << "hydrostatic-displacement.fast_elements=" << hydro->GetFastDisplacementElementCount() << '\n' + << "hydrostatic-displacement.tensor_elements=" << hydro->GetTensorDisplacementElementCount() << '\n' + << "hydrostatic-displacement.reference=previous_cached_pointwise\n"; + std::size_t nq = 0; + hydro->VisitMappedGeometryRules([&](int, const mfem::IntegrationRule &rule) { nq += rule.GetNPoints(); }); + metadata << "hydrostatic-displacement.quadrature_points=" << nq << '\n'; + } + metadata << "stellar.check_error_metric=norm_difference/max(1,norm_actual,norm_reference)\n"; + metadata.flush(); + summary << "stellar.checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + if (mass) Benchmark(options, "mass-normalization", "prepared_reference", massFast, massReference, timings, summary, massRowSeconds); + if (angular) Benchmark(options, "angular-momentum", "prepared_reference", angularFast, angularReference, timings, summary, angularRowSeconds); + if (hydro) Benchmark(options, "hydrostatic-displacement", "cached_pointwise_reference", hydroFast, hydroReference, timings, summary); + return 0; + } + + + bool IsLinearDerivativeSystem(const std::string &name) { + return name == "application-optimizations" || name == "linear-derivatives" || name == "barotropic-closure" || + name == "barotropic-enthalpy" || name == "barotropic-displacement" || + name == "pressure-displacement" || name == "pressure-enthalpy" || + name == "gravity-force" || name == "rotation-force" || name == "preparation-optimizations"; + } + + int RunLinearDerivatives(const Options &options, fem::FEM &f, std::ofstream &metadata, + std::ofstream &summary, std::ofstream &timings, CheckReport &checks) { + const bool preparationGroup = options.system == "preparation-optimizations"; + const bool group = preparationGroup || options.system == "all" || options.system == "linear-derivatives"; + const bool applicationGroup = options.system == "application-optimizations"; + const bool runClosure = applicationGroup || (group && !preparationGroup) || options.system.starts_with("barotropic-"); + const bool runPressure = group || options.system.starts_with("pressure-"); + const bool runGravity = group || options.system == "gravity-force"; + const bool runRotation = applicationGroup || group || options.system == "rotation-force"; + using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema; + const auto densityMap = field::make_field_dof_map(*f.densityFes); + const auto enthalpyMap = field::make_field_dof_map(*f.enthalpyFes); + const auto displacementMap = field::make_field_dof_map(*f.displacementFes); + const eos::Polytrope equationOfState(options.polytropicIndex, 1.0); + using GravityContext = operators::context::gravity_field::GravityFieldLinearizationContext; + const auto constructStart = Clock::now(); + std::unique_ptr gravityContext; + std::unique_ptr closure; + std::unique_ptr pressure, pressureReference; + std::unique_ptr gravity, gravityReference; + std::unique_ptr rotation, rotationReference; + if (runClosure) closure = std::make_unique( + f, *f.domainMapperStateless, equationOfState); + if (runPressure) { + pressure = std::make_unique(f, *f.domainMapperStateless, equationOfState); + pressureReference = std::make_unique(f, *f.domainMapperStateless, equationOfState); + } + if (runGravity) { + gravityContext = std::make_unique(f, *f.domainMapperStateless); + gravity = std::make_unique( + f, *f.domainMapperStateless, *gravityContext); + gravityReference = std::make_unique( + f, *f.domainMapperStateless, *gravityContext); + } + if (runRotation) { + rotation = std::make_unique(f, *f.domainMapperStateless); + rotationReference = std::make_unique(f, *f.domainMapperStateless); + } + summary << "linear.operator_construction_seconds=" << + std::chrono::duration(Clock::now() - constructStart).count() << '\n'; + + const auto baseDensity = densityMap.gather(ScalarField(*f.densityFes, 0.94, 0.31)); + const auto baseEnthalpy = enthalpyMap.gather(ScalarField(*f.enthalpyFes, 0.83, 0.27)); + const auto baseDisplacement = displacementMap.gather(Displacement(f, options.deformation)); + mfem::Vector baseFlux; + mfem::Vector potential; + if (gravityContext) { + baseFlux = gravityContext->GetGravityGradientMap().gather(Flux(f.gravityFluxFes->GetTrueVSize(), 0.29)); + baseFlux *= 0.04; + potential = gravityContext->GetGravityPotentialMap().gather( + ScalarField(*f.gravityPotentialFes, -0.38, 0.61)); + } + std::vector rhoDirections, hDirections, uDirections, gDirections; + for (int i = 0; i < options.directions; ++i) { + rhoDirections.push_back(densityMap.gather(ScalarField(*f.densityFes, 0.17, 0.4 + i))); + hDirections.push_back(enthalpyMap.gather(ScalarField(*f.enthalpyFes, 0.11, 0.6 + i))); + auto u = Displacement(f, 0.43, 0.2 + 0.7 * i); + if (i == 1) u.Add(0.002, Flux(f.displacementFes->GetTrueVSize(), 0.37)); + uDirections.push_back(displacementMap.gather(u)); + if (gravityContext) { + gDirections.push_back(gravityContext->GetGravityGradientMap().gather( + Flux(f.gravityFluxFes->GetTrueVSize(), 0.81 + i))); + gDirections.back() *= 0.03; + } + } + mfem::Vector zeroRho(baseDensity.Size()), zeroH(baseEnthalpy.Size()), + zeroU(baseDisplacement.Size()), zeroG(baseFlux.Size()); + zeroRho = 0.0; zeroH = 0.0; zeroU = 0.0; zeroG = 0.0; + // The last direction is reserved for checks and is never timed. + rhoDirections.push_back(zeroRho); hDirections.push_back(zeroH); uDirections.push_back(zeroU); + if (gravityContext) gDirections.push_back(zeroG); + + std::uint64_t densityRevision = 1, enthalpyRevision = 1, displacementRevision = 1, + fluxRevision = 1, rotationRevision = 1; + const bool needPreparationReferences = options.check != "none" || options.implementation != "optimized"; + bool prepareReferences = needPreparationReferences; + auto prepare = [&](const mfem::Vector &rho, const mfem::Vector &h, const mfem::Vector &u, + const mfem::Vector &g, double omega, unsigned changed, const std::string &label) { + if (changed & 1) ++densityRevision; + if (changed & 2) ++enthalpyRevision; + if (changed & 4) ++displacementRevision; + if (changed & 8) ++fluxRevision; + if (changed & 16) ++rotationRevision; + auto time = [&](const std::string &name, auto &&operation) { + const auto start = Clock::now(); operation(); + if (!label.empty()) summary << name << ".prepare." << label << "_seconds=" + << std::chrono::duration(Clock::now() - start).count() << '\n'; + }; + if (gravityContext) time("linear.shared-gravity", [&] { + gravityContext->Prepare( + {.density = rho, .displacement = u, .gravity_gradient = g, .gravity_potential = potential}, + {.discretization = {1}, .displacement = {displacementRevision}, .density = {densityRevision}, + .gravity_gradient = {fluxRevision}, .gravity_potential = {1}}); + }); + if (closure) time("barotropic-closure", [&] { + closure->Prepare({.density = rho, .enthalpy = h, .displacement = u}, + {.discretization = {1, 1}, .density = {2, densityRevision}, .enthalpy = {3, enthalpyRevision}, + .displacement = {4, displacementRevision}}); + }); + if (pressure) time("pressure-displacement", [&] { + pressure->Prepare({.enthalpy = h, .displacement = u}, + {.discretization = {1, 1}, .enthalpy = {3, enthalpyRevision}, .displacement = {4, displacementRevision}}); + }); + if (pressureReference && prepareReferences) time("pressure-displacement.reference", [&] { + pressureReference->PrepareCachedReference({.enthalpy = h, .displacement = u}, + {.discretization = {1, 1}, .enthalpy = {3, enthalpyRevision}, .displacement = {4, displacementRevision}}); + }); + if (gravity) time("gravity-force", [&] { gravity->Prepare(); }); + if (gravityReference && prepareReferences) time("gravity-force.reference", [&] { gravityReference->PrepareReference(); }); + if (rotation) time("rotation-force", [&] { + mfem::Vector velocity(3), center(3); + velocity(0) = omega / std::sqrt(14.0); velocity(1) = -2.0 * velocity(0); velocity(2) = 3.0 * velocity(0); + center(0) = 0.12; center(1) = -0.07; center(2) = 0.03; + rotation->Prepare({.density = rho, .displacement = u}, + {.discretization = {1, 1}, .density = {2, densityRevision}, + .displacement = {4, displacementRevision}, .rotation = {5, rotationRevision}}, + physics::RigidRotation(velocity, center)); + }); + if (rotationReference && prepareReferences) time("rotation-force.reference", [&] { + mfem::Vector velocity(3), center(3); + velocity(0) = omega / std::sqrt(14.0); velocity(1) = -2.0 * velocity(0); velocity(2) = 3.0 * velocity(0); + center(0) = 0.12; center(1) = -0.07; center(2) = 0.03; + rotationReference->PrepareReference({.density = rho, .displacement = u}, + {.discretization = {1, 1}, .density = {2, densityRevision}, + .displacement = {4, displacementRevision}, .rotation = {5, rotationRevision}}, + physics::RigidRotation(velocity, center)); + }); + }; + prepare(baseDensity, baseEnthalpy, baseDisplacement, baseFlux, 0.63, 31, "initial"); + if (gravity) metadata << "gravity_tensor_elements=" << gravity->GetTensorElementCount() << '\n'; + + struct System { + std::string name; + Apply fast, reference; + std::function residual; + unsigned columns; // density=1, enthalpy=2, displacement=4, gravity=8 + }; + std::vector systems; + auto add = [&](System system) { + if (applicationGroup || group || options.system == system.name) systems.push_back(std::move(system)); + }; + if (closure) { + auto residual = [&](mfem::Vector &out) { closure->BuildResidual(out); }; + add({"barotropic-closure", + [&](int i, mfem::Vector &out) { closure->Mult(rhoDirections[i], hDirections[i], uDirections[i], out); }, + [&](int i, mfem::Vector &out) { closure->MultCachedReference(rhoDirections[i], hDirections[i], uDirections[i], out); }, + residual, 7}); + add({"barotropic-enthalpy", + [&](int i, mfem::Vector &out) { closure->ApplyEnthalpyJacobianAction(hDirections[i], out); }, + [&](int i, mfem::Vector &out) { closure->ApplyEnthalpyJacobianAction(hDirections[i], out, false); }, + residual, 2}); + add({"barotropic-displacement", + [&](int i, mfem::Vector &out) { closure->ApplyDisplacementJacobianAction(uDirections[i], out); }, + [&](int i, mfem::Vector &out) { closure->ApplyDisplacementJacobianAction(uDirections[i], out, false); }, + residual, 4}); + } + if (pressure) add({"pressure-displacement", + [&](int i, mfem::Vector &out) { pressure->ApplyDisplacementJacobianAction(uDirections[i], out); }, + [&](int i, mfem::Vector &out) { pressure->ApplyDisplacementJacobianActionCachedReference(uDirections[i], out); }, + [&](mfem::Vector &out) { pressure->BuildResidual(out); }, 4}); + if (pressure) add({"pressure-enthalpy", + [&](int i, mfem::Vector &out) { pressure->ApplyEnthalpyJacobianAction(hDirections[i], out); }, + [&](int i, mfem::Vector &out) { pressureReference->ApplyEnthalpyJacobianAction(hDirections[i], out); }, + [&](mfem::Vector &out) { pressure->BuildResidual(out); }, 2}); + if (gravity) add({"gravity-force", + [&](int i, mfem::Vector &out) { gravity->ApplyCompleteJacobianAction(rhoDirections[i], uDirections[i], gDirections[i], out); }, + [&](int i, mfem::Vector &out) { gravity->ApplyCompleteJacobianActionCachedReference(rhoDirections[i], uDirections[i], gDirections[i], out); }, + [&](mfem::Vector &out) { gravity->BuildResidual(out); }, 13}); + if (rotation) add({"rotation-force", + [&](int i, mfem::Vector &out) { rotation->ApplyCompleteJacobianAction(rhoDirections[i], uDirections[i], out); }, + [&](int i, mfem::Vector &out) { rotation->ApplyCompleteJacobianActionCachedReference(rhoDirections[i], uDirections[i], out); }, + [&](mfem::Vector &out) { rotation->BuildResidual(out); }, 5}); + + auto compare = [&](const std::string &label, const mfem::Vector &a, const mfem::Vector &b) { + checks.Add(label, ScaledError(a, b), 2.0e-11); + if (std::max(Norm(a), Norm(b)) > 1.0e-14) + checks.Add(label + "_relative", RelativeError(a, b), 2.0e-10); + }; + if (options.check != "none") { + const auto start = Clock::now(); + const int states = options.check == "full" ? 5 : 1; + for (int state = 0; state < states; ++state) { + const auto rho = state >= 2 ? densityMap.gather(ScalarField(*f.densityFes, 1.13, 0.82)) : baseDensity; + const auto h = state >= 2 ? enthalpyMap.gather(ScalarField(*f.enthalpyFes, 0.91, 0.42)) : baseEnthalpy; + const auto &u = state == 0 && states > 1 ? zeroU : baseDisplacement; + auto g = baseFlux; + if (state >= 3) g *= -0.71; + const unsigned revisions = state == 0 ? 31u : state == 1 ? 4u : state == 2 ? 3u : state == 3 ? 24u : 16u; + const double omega = state == 4 ? 0.0 : state == 3 ? -0.27 : 0.63; + prepare(rho, h, u, g, omega, revisions, ""); + auto compareResidual = [&](const std::string &name, auto &fast, auto &reference) { + if (!fast) return; + mfem::Vector a, b; + fast->BuildResidual(a); reference->BuildResidual(b); + compare("state" + std::to_string(state) + "_" + name + "_prepared_residual", a, b); + }; + compareResidual("pressure", pressure, pressureReference); + compareResidual("gravity", gravity, gravityReference); + compareResidual("rotation", rotation, rotationReference); + if (pressure) { + for (int i = 0; i <= options.directions; ++i) { + mfem::Vector a, b; + pressure->ApplyEnthalpyJacobianAction(hDirections[i], a); + pressureReference->ApplyEnthalpyJacobianAction(hDirections[i], b); + compare("state" + std::to_string(state) + "_pressure_assembled_enthalpy_" + std::to_string(i), a, b); + } + } + for (const auto &system : systems) { + for (int i = 0; i <= options.directions; ++i) { + mfem::Vector a, b; + system.fast(i, a); system.reference(i, b); + compare("state" + std::to_string(state) + "_" + system.name + "_direction" + std::to_string(i), a, b); + if (system.name == "pressure-displacement") { + pressure->ApplyDisplacementJacobianActionReference(uDirections[i], b); + compare("state" + std::to_string(state) + "_pressure_general_" + std::to_string(i), a, b); + } + if (system.name == "gravity-force") { + gravity->ApplyCompleteJacobianActionReference(rhoDirections[i], uDirections[i], gDirections[i], b); + compare("state" + std::to_string(state) + "_gravity_general_" + std::to_string(i), a, b); + } + if (system.name == "rotation-force") { + rotation->ApplyCompleteJacobianActionReference(rhoDirections[i], uDirections[i], b); + compare("state" + std::to_string(state) + "_rotation_general_" + std::to_string(i), a, b); + } + if (system.name.starts_with("barotropic-")) { + closure->MultReference(system.columns & 1 ? rhoDirections[i] : zeroRho, + system.columns & 2 ? hDirections[i] : zeroH, + system.columns & 4 ? uDirections[i] : zeroU, b); + compare("state" + std::to_string(state) + "_" + system.name + "_general_" + std::to_string(i), a, b); + } + if (i == options.directions) checks.Add(system.name + "_zero", Norm(a), 0.0); + } + } + } + prepare(baseDensity, baseEnthalpy, baseDisplacement, baseFlux, 0.63, 31, ""); + if (options.check == "full") { + // Reference preparation was checked at every state above. + // Finite differences below need only the production residual. + prepareReferences = false; + // Compare separate columns against the old complete action. + // Individual gravity/rotation column methods remain independent + // stateless kernels and serve as an additional cross-check. + for (unsigned column : {1u, 2u, 4u, 8u}) { + const auto &rho = column == 1 ? rhoDirections[0] : zeroRho; + const auto &h = column == 2 ? hDirections[0] : zeroH; + const auto &u = column == 4 ? uDirections[0] : zeroU; + const auto &g = column == 8 && gravity ? gDirections[0] : zeroG; + mfem::Vector a, b, independent; + if (closure) { + closure->Mult(rho, h, u, a); closure->MultReference(rho, h, u, b); + compare("closure_column" + std::to_string(column), a, b); + } + if (gravity && column != 2) { + gravity->ApplyCompleteJacobianAction(rho, u, g, a); + gravity->ApplyCompleteJacobianActionReference(rho, u, g, b); + compare("gravity_column" + std::to_string(column), a, b); + if (column == 1) gravity->ApplyDensityJacobianAction(rho, independent); + if (column == 4) gravity->ApplyDisplacementJacobianAction(u, independent); + if (column == 8) gravity->ApplyGravityGradientJacobianAction(g, independent); + compare("gravity_stateless_column" + std::to_string(column), a, independent); + } + if (rotation && (column == 1 || column == 4)) { + rotation->ApplyCompleteJacobianAction(rho, u, a); + rotation->ApplyCompleteJacobianActionReference(rho, u, b); + compare("rotation_column" + std::to_string(column), a, b); + if (column == 1) rotation->ApplyDensityJacobianAction(rho, independent); + else rotation->ApplyDisplacementJacobianAction(u, independent); + compare("rotation_stateless_column" + std::to_string(column), a, independent); + } + } + for (const auto &system : systems) { + // The complete closure FD plus independent column checks + // covers its two couplings in a grouped run. + if (group && (system.name == "barotropic-enthalpy" || system.name == "barotropic-displacement")) continue; + prepare(baseDensity, baseEnthalpy, baseDisplacement, baseFlux, 0.63, 31, ""); + mfem::Vector analytic; + system.fast(0, analytic); + for (double step : {options.finiteDifferenceStep, 0.5 * options.finiteDifferenceStep}) { + mfem::Vector plus, minus; + for (int sign : {1, -1}) { + auto rho = baseDensity, h = baseEnthalpy, u = baseDisplacement, g = baseFlux; + if (system.columns & 1) rho.Add(sign * step, rhoDirections[0]); + if (system.columns & 2) h.Add(sign * step, hDirections[0]); + if (system.columns & 4) u.Add(sign * step, uDirections[0]); + if (system.columns & 8) g.Add(sign * step, gDirections[0]); + prepare(rho, h, u, g, 0.63, 31, ""); + system.residual(sign == 1 ? plus : minus); + } + plus -= minus; plus /= 2.0 * step; + checks.Add(system.name + "_finite_difference_" + std::to_string(step), + ScaledError(analytic, plus), 2.0e-6); + if (Norm(analytic) > 1.0e-12) + checks.Add(system.name + "_finite_difference_relative_" + std::to_string(step), + RelativeError(analytic, plus), 2.0e-6); + } + } + } + prepareReferences = needPreparationReferences; + summary << "linear.validation_seconds=" << std::chrono::duration(Clock::now() - start).count() << '\n'; + if (!checks.passed) { summary << "linear.checks=failed\n"; return 3; } + } + prepare(baseDensity, baseEnthalpy, baseDisplacement, baseFlux, 0.63, 31, "repeat"); + // Unchanged dependencies must preserve the prepared state and results. + const auto count = [&] { + return (closure ? closure->GetPreparationCount() : 0) + + (pressure ? pressure->GetResidualPreparationCount() : 0) + + (gravity ? gravity->GetResidualPreparationCount() : 0) + + (rotation ? rotation->GetResidualPreparationCount() : 0); + }; + const auto beforeNoop = count(); + prepare(baseDensity, baseEnthalpy, baseDisplacement, baseFlux, 0.63, 0, "unchanged"); + if (options.check != "none") { + checks.Add("linear_unchanged_dependencies_reuse", count() == beforeNoop ? 0.0 : 1.0, 0.0); + if (!checks.passed) { summary << "linear.checks=failed\n"; return 3; } + } + if (gravity) metadata << "gravity-force.fast_elements=" << gravity->GetFastElementCount() << '\n'; + if (rotation) metadata << "rotation-force.fast_elements=" << rotation->GetFastElementCount() + << "\nrotation_tensor_elements=" << rotation->GetTensorElementCount() << '\n'; + if (pressure) metadata << "pressure_tensor_elements=" << pressure->GetTensorElementCount() << '\n'; + if (closure) metadata << "closure_tensor_elements=" << closure->GetTensorElementCount() << '\n'; + metadata << "linear.polytropic_index=" << options.polytropicIndex << "\nlinear.polytropic_constant=1\n" + << "linear.check_error_metric=norm_difference/max(1,norm_actual,norm_reference)\n" + << "linear.relative_check_metric=norm_difference/max(norm_actual,norm_reference,1e-30)\n"; + metadata.flush(); + summary << "linear.checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + for (const auto &system : systems) + Benchmark(options, system.name, (system.name == "gravity-force" || system.name == "rotation-force" || system.name == "pressure-displacement" || system.name.starts_with("barotropic-")) ? + "cached_pointwise_reference" : "prepared_reference", + system.fast, system.reference, timings, summary); + + // Use the same physical state for both paths, with fresh dependency + // revisions on each pair. Shared gravity context preparation is excluded. + auto requireRebuild = [](auto &operation, auto &&prepareOperation) { + const auto before = operation.GetResidualPreparationCount(); + prepareOperation(); + if (operation.GetResidualPreparationCount() != before + 1) + throw std::runtime_error("Preparation benchmark timed an unchanged-state reuse."); + }; + if (pressure) { + auto dependencies = operators::context::pressure_force::PressureForceDependencies{ + .discretization = {1, 1}, .enthalpy = {3, enthalpyRevision}, .displacement = {4, displacementRevision}}; + const auto state = operators::context::pressure_force::PressureForceStateView{ + .enthalpy = baseEnthalpy, .displacement = baseDisplacement}; + BenchmarkPreparation(options, "pressure-preparation", + [&] { dependencies.enthalpy.revision = ++enthalpyRevision; + dependencies.displacement.revision = ++displacementRevision; }, + [&] { requireRebuild(*pressure, [&] { pressure->Prepare(state, dependencies); }); }, + [&] { requireRebuild(*pressureReference, [&] { pressureReference->PrepareCachedReference(state, dependencies); }); }, + [&](mfem::Vector &out) { pressure->BuildResidual(out); }, + [&](mfem::Vector &out) { pressureReference->BuildResidual(out); }, timings, summary); + } + if (gravity) { + BenchmarkPreparation(options, "gravity-force-preparation", + [&] { + gravityContext->Prepare( + {.density = baseDensity, .displacement = baseDisplacement, + .gravity_gradient = baseFlux, .gravity_potential = potential}, + {.discretization = {1}, .displacement = {++displacementRevision}, + .density = {++densityRevision}, .gravity_gradient = {++fluxRevision}, .gravity_potential = {1}}); + }, + [&] { requireRebuild(*gravity, [&] { gravity->Prepare(); }); }, + [&] { requireRebuild(*gravityReference, [&] { gravityReference->PrepareReference(); }); }, + [&](mfem::Vector &out) { gravity->BuildResidual(out); }, + [&](mfem::Vector &out) { gravityReference->BuildResidual(out); }, timings, summary); + } + if (rotation) { + auto dependencies = operators::context::rotational_displacement_force::RotationalDisplacementForceDependencies{ + .discretization = {1, 1}, .density = {2, densityRevision}, + .displacement = {4, displacementRevision}, .rotation = {5, rotationRevision}}; + const auto state = operators::context::rotational_displacement_force::RotationalDisplacementForceStateView{ + .density = baseDensity, .displacement = baseDisplacement}; + mfem::Vector velocity(3), center(3); + velocity(0) = 0.63 / std::sqrt(14.0); velocity(1) = -2.0 * velocity(0); velocity(2) = 3.0 * velocity(0); + center(0) = 0.12; center(1) = -0.07; center(2) = 0.03; + const physics::RigidRotation rigidRotation(velocity, center); + BenchmarkPreparation(options, "rotation-force-preparation", + [&] { dependencies.density.revision = ++densityRevision; + dependencies.displacement.revision = ++displacementRevision; + dependencies.rotation.revision = ++rotationRevision; }, + [&] { requireRebuild(*rotation, [&] { rotation->Prepare(state, dependencies, rigidRotation); }); }, + [&] { requireRebuild(*rotationReference, [&] { rotationReference->PrepareReference(state, dependencies, rigidRotation); }); }, + [&](mfem::Vector &out) { rotation->BuildResidual(out); }, + [&](mfem::Vector &out) { rotationReference->BuildResidual(out); }, timings, summary); + } + return 0; + } + + int RunNormalizationDiagonals( + const Options &options, fem::FEM &f, std::ofstream &summary, std::ofstream &timings, CheckReport &checks + ) { + mfem::Array stellarMarker(f.mesh->attributes.Max()); + stellarMarker = 0; + for (int e = 0; e < f.mesh->GetNE(); ++e) + if (!f.domainMapperStateless->IsCompactifiedElement(*f.mesh->GetElementTransformation(e))) + stellarMarker[f.mesh->GetAttribute(e)-1] = 1; + const auto surfaceMap = field::make_stellar_surface_scalar_dof_map(*f.surfaceDeformationFes); + struct Diagonal { std::string name; std::function assemble; }; + const std::vector systems{ + {"normalization-hdiv", [&](bool ref) { return normalization::AssembleReferenceHDivMassDiagonal(*f.gravityFluxFes, ref); }}, + {"normalization-potential", [&](bool ref) { return normalization::AssembleReferenceScalarMassDiagonal(*f.gravityPotentialFes, nullptr, ref); }}, + {"normalization-density", [&](bool ref) { return normalization::AssembleReferenceScalarMassDiagonal(*f.densityFes, &stellarMarker, ref); }}, + {"normalization-enthalpy", [&](bool ref) { return normalization::AssembleReferenceScalarMassDiagonal(*f.enthalpyFes, &stellarMarker, ref); }}, + {"normalization-surface", [&](bool ref) { return normalization::AssembleReferenceSurfaceMassDiagonal(f, surfaceMap, ref); }} + }; + for (const auto &system : systems) { + mfem::Vector fast, reference; + if (options.check != "none") { + const auto start = Clock::now(); + fast = system.assemble(false); + summary << system.name << ".first_optimized_seconds=" << + std::chrono::duration(Clock::now()-start).count() << '\n'; + reference = system.assemble(true); + summary << system.name << ".optimized_diagonal_norm=" << Norm(fast) << '\n' + << system.name << ".reference_diagonal_norm=" << Norm(reference) << '\n'; + checks.Add(system.name + "_exact_true_diagonal", RelativeError(fast, reference), 2.0e-11); + if (system.name == "normalization-surface") { + int nonpositiveEntries = 0; + for (int i = 0; i < fast.Size(); ++i) + if (fast(i) <= 0.0) ++nonpositiveEntries; + checks.Add(system.name + "_positive_surface_diagonal", nonpositiveEntries, 0.0); + } + } + if (!checks.passed) { summary << "checks_passed=0\nbenchmark_skipped=1\n"; return 3; } + BenchmarkPreparation(options, system.name, [] {}, + [&] { fast = system.assemble(false); }, [&] { reference = system.assemble(true); }, + [&](mfem::Vector &out) { out = fast; }, [&](mfem::Vector &out) { out = reference; }, timings, summary); + } + return 0; + } + + int RunGeometryPreparation( + const Options &options, fem::FEM &f, std::ofstream &metadata, std::ofstream &summary, + std::ofstream &timings, CheckReport &checks + ) { + const auto &mapper = *f.domainMapperStateless; + struct RestoreCacheMode { + const mapping::DomainMapper &mapper; + bool enabled; + ~RestoreCacheMode() { mapper.SetGeometryCacheEnabled(enabled); } + } restore{mapper, mapper.IsGeometryCacheEnabled()}; + operators::PreparedMappedHDivMassOperator fast(f, mapper), reference(f, mapper); + const auto flux = Flux(f.gravityFluxFes->GetTrueVSize(), 0.37); + const auto reducedFlux = fast.GetFluxMap().gather(flux); + const auto direction = Displacement(f, 0.21, 0.32); + const std::vector states{ + fast.GetDisplacementMap().gather(Displacement(f, 0.0)), + fast.GetDisplacementMap().gather(Displacement(f, options.deformation)), + fast.GetDisplacementMap().gather(Displacement(f, 0.5*options.deformation, 0.4)) + }; + int state = 1; + auto prepare = [&](auto &op, bool cache) { + mapper.SetGeometryCacheEnabled(cache); + const auto count = op.GetPreparationCount(); + op.Prepare(states[state]); + if (op.GetPreparationCount() != count + 1) + throw std::runtime_error("Geometry preparation benchmark did not rebuild its operator."); + }; + mapper.ClearGeometryCache(); + const auto start = Clock::now(); + prepare(fast, true); + summary << "geometry-preparation.cold_cache_prepare_seconds=" << + std::chrono::duration(Clock::now()-start).count() << '\n'; + if (options.check != "none") { + for (state = 0; state < static_cast(states.size()); ++state) { + prepare(fast, true); prepare(reference, false); + mfem::Vector a, b; + fast.Mult(reducedFlux, a); reference.Mult(reducedFlux, b); + checks.Add("geometry_state" + std::to_string(state) + "_mass", RelativeError(a,b), 2.0e-11); + fast.MultDisplacementVariationTrue(flux, direction, a); + reference.MultDisplacementVariationTrue(flux, direction, b); + checks.Add("geometry_state" + std::to_string(state) + "_derivative", RelativeError(a,b), 2.0e-11); + } + } + if (!checks.passed) { summary << "checks_passed=0\nbenchmark_skipped=1\n"; return 3; } + state = 0; + const auto before = mapper.GetGeometryCacheStatistics(); + BenchmarkPreparation(options, "geometry-preparation", + [&] { state = (state + 1) % static_cast(states.size()); }, + [&] { prepare(fast, true); }, [&] { prepare(reference, false); }, + [&](mfem::Vector &out) { fast.Mult(reducedFlux, out); }, + [&](mfem::Vector &out) { reference.Mult(reducedFlux, out); }, timings, summary); + const auto after = mapper.GetGeometryCacheStatistics(); + metadata << "geometry_cache_points=" << after.points << "\ngeometry_cache_hits=" << after.hits + << "\ngeometry_cache_misses=" << after.misses << '\n'; + if (options.check != "none" && options.prepareSamples > 0 && options.implementation != "reference") { + checks.Add("geometry_warm_preparation_new_fixed_points", double(after.misses-before.misses), 0.0); + checks.Add("geometry_warm_preparation_reuses_fixed_points", after.hits > before.hits ? 0.0 : 1.0, 0.0); + } + return checks.passed ? 0 : 3; + } + + int RunGeometryInspection( + const Options &options, fem::FEM &f, std::ofstream &metadata, + std::ofstream &summary, std::ofstream &timings, CheckReport &checks + ) { + fem::PreparedGeometryInspector inspector; + std::vector states{ + Displacement(f, 0.0), Displacement(f, options.deformation), + Displacement(f, 0.5 * options.deformation, 0.4) + }; + const auto start = Clock::now(); + double fast = inspector.Inspect(*f.displacementFes, states[1]), reference = 0.0; + summary << "geometry-inspection.cold_cache_seconds=" + << std::chrono::duration(Clock::now()-start).count() << '\n'; + if (options.check != "none") { + for (int i = 0; i < static_cast(states.size()); ++i) { + fast = inspector.Inspect(*f.displacementFes, states[i]); + reference = inspector.Inspect(*f.displacementFes, states[i], true); + checks.Add("geometry_inspection_state" + std::to_string(i), + std::abs(fast-reference) / std::max(1.0, std::abs(reference)), 2.0e-11); + checks.Add("geometry_inspection_state" + std::to_string(i) + "_positive", + fast > 0.0 ? 0.0 : 1.0, 0.0); + } + if (options.check == "full") { + mfem::ParGridFunction folded(f.displacementFes.get()); + mfem::VectorFunctionCoefficient fold(3, [](const mfem::Vector &x, mfem::Vector &u) { + u.SetSize(3); u = 0.0; u(0) = -2.0*x(0); + }); + folded.ProjectCoefficient(fold); + mfem::Vector foldedTrue; folded.GetTrueDofs(foldedTrue); + fast = inspector.Inspect(*f.displacementFes, foldedTrue); + reference = inspector.Inspect(*f.displacementFes, foldedTrue, true); + checks.Add("geometry_inspection_fold_matches_reference", std::abs(fast-reference), 2.0e-11); + checks.Add("geometry_inspection_fold_rejected", fast < 0.0 ? 0.0 : 1.0, 0.0); + auto nonfinite = states[1]; + nonfinite(0) = std::numeric_limits::quiet_NaN(); + fast = inspector.Inspect(*f.displacementFes, nonfinite); + reference = inspector.Inspect(*f.displacementFes, nonfinite, true); + checks.Add("geometry_inspection_nonfinite_rejected", + !std::isfinite(fast) && !std::isfinite(reference) ? 0.0 : 1.0, 0.0); + } + } + if (!checks.passed) { summary << "checks_passed=0\nbenchmark_skipped=1\n"; return 3; } + int state = 0; + const auto builds = inspector.GetGeometryBuildCount(); + BenchmarkPreparation(options, "geometry-inspection", + [&] { state = (state + 1) % static_cast(states.size()); }, + [&] { fast = inspector.Inspect(*f.displacementFes, states[state]); }, + [&] { reference = inspector.Inspect(*f.displacementFes, states[state], true); }, + [&](mfem::Vector &out) { out.SetSize(1); out(0) = fast; }, + [&](mfem::Vector &out) { out.SetSize(1); out(0) = reference; }, timings, summary); + metadata << "geometry_inspection_tensor_elements=" << inspector.GetTensorElementCount() + << "\ngeometry_inspection_geometry_builds=" << inspector.GetGeometryBuildCount() << '\n'; + if (options.check != "none") + checks.Add("geometry_inspection_warm_cache_reuse", double(inspector.GetGeometryBuildCount()-builds), 0.0); + summary << "geometry-inspection.checks=" << (options.check == "none" ? "not_run" : checks.passed ? "passed" : "failed") << '\n'; + return checks.passed ? 0 : 3; + } + + int RunGravitySourcePreparation( + const Options &options, fem::FEM &f, std::ofstream &summary, + std::ofstream &timings, CheckReport &checks + ) { + operators::PreparedMappedGravitySourceOperator fast(f, *f.domainMapperStateless), + reference(f, *f.domainMapperStateless); + const auto rhoTrue = ScalarField(*f.densityFes, 0.94, 0.31); + const auto rho = fast.GetDensityMap().gather(rhoTrue); + const auto potential = fast.GetPotentialMap().gather(ScalarField(*f.gravityPotentialFes, -0.38, 0.61)); + const auto direction = Displacement(f, 0.21, 0.32); + const std::vector states{ + fast.GetDisplacementMap().gather(Displacement(f, 0.0)), + fast.GetDisplacementMap().gather(Displacement(f, options.deformation)), + fast.GetDisplacementMap().gather(Displacement(f, 0.5 * options.deformation, 0.4)) + }; + int state = 1; + auto prepare = [&](auto &op, bool oldWeights) { + const auto count = op.GetPreparationCount(); + if (oldWeights) op.PrepareReference(states[state]); else op.Prepare(states[state]); + if (op.GetPreparationCount() != count + 1) + throw std::runtime_error("Gravity-source preparation benchmark did not rebuild."); + }; + const auto start = Clock::now(); + prepare(fast, false); + summary << "gravity-source-preparation.first_optimized_seconds=" + << std::chrono::duration(Clock::now()-start).count() << '\n'; + if (options.check != "none") { + for (state = 0; state < static_cast(states.size()); ++state) { + prepare(fast, false); prepare(reference, true); + mfem::Vector a, b; + fast.Mult(rho, a); reference.Mult(rho, b); + checks.Add("source_state" + std::to_string(state) + "_mass", RelativeError(a,b), 2.0e-11); + fast.MultTranspose(potential, a); reference.MultTranspose(potential, b); + checks.Add("source_state" + std::to_string(state) + "_transpose", RelativeError(a,b), 2.0e-11); + fast.MultDisplacementVariationTrue(rhoTrue, direction, a); + reference.MultDisplacementVariationTrue(rhoTrue, direction, b); + checks.Add("source_state" + std::to_string(state) + "_derivative", RelativeError(a,b), 2.0e-11); + } + } + if (!checks.passed) { summary << "checks_passed=0\nbenchmark_skipped=1\n"; return 3; } + state = 0; + BenchmarkPreparation(options, "gravity-source-preparation", + [&] { state = (state + 1) % static_cast(states.size()); }, + [&] { prepare(fast, false); }, [&] { prepare(reference, true); }, + [&](mfem::Vector &out) { fast.Mult(rho, out); }, + [&](mfem::Vector &out) { reference.Mult(rho, out); }, timings, summary); + summary << "gravity-source-preparation.checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + return 0; + } + + + int RunSourceDisplacement(const Options &options, fem::FEM &f, std::ofstream &metadata, + std::ofstream &summary, std::ofstream &timings, CheckReport &checks) { + operators::PreparedMappedGravitySourceOperator source(f, *f.domainMapperStateless); + const auto rho = ScalarField(*f.densityFes, 0.94, 0.31); + const auto reducedRho = source.GetDensityMap().gather(rho); + const auto base = Displacement(f, options.deformation); + std::vector directions; + for (int i = 0; i < options.directions; ++i) { + directions.push_back(Displacement(f, 0.43, 0.2+0.7*i)); + if (i == 1) directions.back().Add(0.002, Flux(f.displacementFes->GetTrueVSize(), 0.37)); + } + auto prepare = [&](const mfem::Vector &u) { source.Prepare(source.GetDisplacementMap().gather(u)); }; + const Apply fast = [&](int i, mfem::Vector &out) { source.MultDisplacementVariationTrue(rho, directions[i], out); }; + const Apply reference = [&](int i, mfem::Vector &out) { + source.MultDisplacementVariationTrueReference(rho, directions[i], out); + }; + if (options.check != "none") { + for (int state = 0; state < (options.check == "full" ? 3 : 1); ++state) { + const auto u = state == 0 ? base : Displacement(f, state == 1 ? 0.0 : 0.5*options.deformation, 0.3); + prepare(u); + for (int i = 0; i < options.directions; ++i) { + mfem::Vector a,b; + fast(i,a); reference(i,b); + const auto name = "source_displacement_state"+std::to_string(state)+"_direction"+std::to_string(i); + checks.Add(name, RelativeError(a,b), 2.0e-11); + if (options.check == "full" && i == 0) { + operators::kernels::apply_mapped_source_variation(f, *f.domainMapperStateless, rho, u, directions[i], b); + checks.Add(name+"_stateless", RelativeError(a,b), 2.0e-11); + } + } + mfem::Vector zero(directions[0]); zero=0.0; + mfem::Vector a; + source.MultDisplacementVariationTrue(rho,zero,a); + checks.Add("source_displacement_zero_state"+std::to_string(state),Norm(a),0.0); + } + prepare(base); + if (options.check == "full") { + mfem::Vector analytic; fast(0,analytic); + for (double step : {options.finiteDifferenceStep,0.5*options.finiteDifferenceStep}) { + mfem::Vector up(base),um(base),a,b; + up.Add(step,directions[0]); um.Add(-step,directions[0]); + source.PreparePrimal(source.GetDisplacementMap().gather(up)); source.Mult(reducedRho,a); + source.PreparePrimal(source.GetDisplacementMap().gather(um)); source.Mult(reducedRho,b); + a-=b; a/=2.0*step; + auto expected=source.GetPotentialMap().gather(analytic); + checks.Add("source_displacement_fd_"+std::to_string(step),RelativeError(a,expected),2.0e-6); + } + } + } + if (!checks.passed) return 3; + prepare(base); + metadata << "source_tensor_variation_elements=" << source.GetTensorVariationElementCount() << '\n'; + Benchmark(options,"gravity-source-displacement","cached_pointwise_reference",fast,reference,timings,summary); + return 0; + } + + int RunHydrostaticPreparation(const Options &options, fem::FEM &f, std::ofstream &metadata, + std::ofstream &summary, std::ofstream &timings, CheckReport &checks) { + operators::PreparedHydrostaticEquilibriumOperator fast(f,*f.domainMapperStateless),reference(f,*f.domainMapperStateless); + auto h = fast.GetEnthalpyMap().gather(ScalarField(*f.enthalpyFes,0.73,0.27)); + auto phi = fast.GetGravityPotentialMap().gather(ScalarField(*f.gravityPotentialFes,-0.38,0.61)); + const auto dh = fast.GetEnthalpyMap().gather(ScalarField(*f.enthalpyFes,0.13,0.41)); + const auto dp = fast.GetGravityPotentialMap().gather(ScalarField(*f.gravityPotentialFes,0.09,0.71)); + const auto du = fast.GetDisplacementMap().gather(Displacement(f,0.31,0.4)); + const std::vector states{ + fast.GetDisplacementMap().gather(Displacement(f,options.deformation)), + fast.GetDisplacementMap().gather(Displacement(f,0.0)), + fast.GetDisplacementMap().gather(Displacement(f,0.5*options.deformation,0.3))}; + mfem::Vector velocity(3),center(3); center=0.0; + velocity(0)=0.17; velocity(1)=-0.21; velocity(2)=0.39; + const physics::RigidRotation rotation(velocity,center); + operators::context::hydrostatic::HydrostaticEquilibriumDependencies dependencies{ + .discretization={1,1},.enthalpy={2,1},.gravityPotential={3,1}, + .displacement={4,1},.rotation={5,1},.bernoulliConstant={6,1}}; + int state=0; + auto prepare = [&](auto &op,bool old) { + const auto view=operators::context::hydrostatic::HydrostaticEquilibriumStateView{ + .enthalpy=h,.gravityPotential=phi,.displacement=states[state],.bernoulliConstant=0.39}; + const auto before=op.GetResidualPreparationCount(); + if (old) op.PrepareReference(view,dependencies,rotation); else op.Prepare(view,dependencies,rotation); + if (op.GetResidualPreparationCount()!=before+1) + throw std::runtime_error("Hydrostatic preparation benchmark did not rebuild."); + }; + const auto start=Clock::now(); prepare(fast,false); + summary << "hydrostatic-preparation.first_optimized_seconds=" << std::chrono::duration(Clock::now()-start).count() << '\n'; + if (options.check!="none") { + for (state=0;state<(options.check=="full"?3:1);++state) { + ++dependencies.displacement.revision; + ++dependencies.enthalpy.revision; ++dependencies.gravityPotential.revision; + h*=1.01; phi*=0.99; + prepare(fast,false); prepare(reference,true); + auto compare=[&](const std::string &name,auto &&action) { + mfem::Vector a,b; action(fast,a); action(reference,b); + checks.Add("hydro_prepare_state"+std::to_string(state)+"_"+name,RelativeError(a,b),2.0e-11); + }; + compare("residual",[](auto &op,auto &out){op.BuildResidual(out);}); + compare("enthalpy",[&](auto &op,auto &out){op.ApplyEnthalpyJacobianAction(dh,out);}); + compare("potential",[&](auto &op,auto &out){op.ApplyGravityPotentialJacobianAction(dp,out);}); + compare("bernoulli",[&](auto &op,auto &out){op.ApplyBernoulliConstantJacobianAction(0.13,out);}); + compare("displacement",[&](auto &op,auto &out){op.ApplyDisplacementJacobianAction(du,out);}); + compare("complete",[&](auto &op,auto &out){op.ApplyCompleteJacobianAction(dh,dp,0.13,du,out);}); + compare("diagonal",[](auto &op,auto &out){op.AssembleEnthalpyJacobianDiagonal(out);}); + const auto view=operators::context::hydrostatic::HydrostaticEquilibriumStateView{ + .enthalpy=h,.gravityPotential=phi,.displacement=states[state],.bernoulliConstant=0.39}; + checks.Add("hydro_prepare_unchanged_state"+std::to_string(state), + fast.Prepare(view,dependencies,rotation).DidAnyWork()?1.0:0.0,0.0); + } + } + if (options.check == "full") { + state = 0; + ++dependencies.displacement.revision; + prepare(fast, false); + mfem::Vector analytic; + fast.ApplyCompleteJacobianAction(dh, dp, 0.13, du, analytic); + for (double step : {options.finiteDifferenceStep, 0.5*options.finiteDifferenceStep}) { + mfem::Vector hp(h), hm(h), pp(phi), pm(phi), up(states[0]), um(states[0]), a, b; + hp.Add(step, dh); hm.Add(-step, dh); + pp.Add(step, dp); pm.Add(-step, dp); + up.Add(step, du); um.Add(-step, du); + ++dependencies.enthalpy.revision; + ++dependencies.gravityPotential.revision; + ++dependencies.displacement.revision; + ++dependencies.bernoulliConstant.revision; + fast.Prepare({.enthalpy=hp, .gravityPotential=pp, .displacement=up, + .bernoulliConstant=0.39+step*0.13}, dependencies, rotation); + fast.BuildResidual(a); + ++dependencies.enthalpy.revision; + ++dependencies.gravityPotential.revision; + ++dependencies.displacement.revision; + ++dependencies.bernoulliConstant.revision; + fast.Prepare({.enthalpy=hm, .gravityPotential=pm, .displacement=um, + .bernoulliConstant=0.39-step*0.13}, dependencies, rotation); + fast.BuildResidual(b); + a -= b; a /= 2.0*step; + checks.Add("hydro_prepare_complete_fd_"+std::to_string(step), RelativeError(a, analytic), 2.0e-6); + } + mfem::ParGridFunction folded(f.displacementFes.get()); + mfem::VectorFunctionCoefficient fold(3, [](const mfem::Vector &x, mfem::Vector &u) { + u = x; u *= -2.0; + }); + folded.ProjectCoefficient(fold); + mfem::Vector foldedTrue; folded.GetTrueDofs(foldedTrue); + const auto rejectedDisplacement = fast.GetDisplacementMap().gather(foldedTrue); + ++dependencies.displacement.revision; + const auto rejectedView = operators::context::hydrostatic::HydrostaticEquilibriumStateView{ + .enthalpy=h, .gravityPotential=phi, .displacement=rejectedDisplacement, .bernoulliConstant=0.39}; + const auto rejected = fast.TryPrepare(rejectedView, dependencies, rotation); + const auto referenceRejected = reference.TryPrepareReference(rejectedView, dependencies, rotation); + checks.Add("hydro_prepare_fold_rejected", + !rejected && !referenceRejected && + rejected.error().mappingStatus == mapping::MappingStatus::non_positive_determinant && + rejected.error().mappingStatus == referenceRejected.error().mappingStatus ? 0.0 : 1.0, 0.0); + checks.Add("hydro_prepare_rejected_state_unavailable", + !fast.IsPrepared() && !reference.IsPrepared() ? 0.0 : 1.0, 0.0); + } + if(!checks.passed)return 3; + state=0; + ++dependencies.displacement.revision; + ++dependencies.enthalpy.revision; ++dependencies.gravityPotential.revision; + ++dependencies.bernoulliConstant.revision; + prepare(fast,false); prepare(reference,true); + metadata << "hydro_preparation_tensor_elements=" << fast.GetTensorDisplacementElementCount() << '\n'; + BenchmarkPreparation(options,"hydrostatic-preparation", + [&]{state=(state+1)%static_cast(states.size());++dependencies.displacement.revision;}, + [&]{prepare(fast,false);},[&]{prepare(reference,true);}, + [&](auto &out){fast.BuildResidual(out);},[&](auto &out){reference.BuildResidual(out);},timings,summary); + return 0; + } + + int RunGravitySchurPreparation(const Options &options,fem::FEM &f,std::ofstream &metadata, + std::ofstream &summary,std::ofstream &timings,CheckReport &checks) { + preconditioning::PreparedGravityDivergenceCache cache; + operators::PreparedMappedHDivMassOperator mass(f,*f.domainMapperStateless); + std::vector diagonals(3); + for(int i=0;i<3;++i) { + mass.PreparePrimal(mass.GetDisplacementMap().gather(Displacement(f,0.5*i*options.deformation,0.2))); + mass.AssembleTrueDiagonal(diagonals[i]); + } + const auto x=Flux(f.gravityPotentialFes->GetTrueVSize(),0.37); + std::unique_ptr fast,reference; + int state=0; + auto prepareFast=[&]{fast=preconditioning::assembleGravityPotentialSchurSurrogate(f,diagonals[state],&cache);}; + auto prepareReference=[&]{reference=preconditioning::assembleGravityPotentialSchurSurrogate(f,diagonals[state]);}; + const auto start=Clock::now(); prepareFast(); + summary << "gravity-schur-preparation.cold_cache_seconds=" << std::chrono::duration(Clock::now()-start).count() << '\n'; + auto compare=[&](const std::string &label){ + mfem::Vector a(fast->Height()),b(reference->Height()); + fast->Mult(x,a);reference->Mult(x,b);checks.Add(label,RelativeError(a,b),2.0e-11); + }; + if(options.check!="none") { + for(state=0;state<3;++state){prepareFast();prepareReference();compare("schur_state"+std::to_string(state));} + // An exact scaling check also works with --deformation 0, and catches + // both a stale Schur matrix and accidental cumulative scaling of B. + const auto originalDiagonal = diagonals[0]; + state = 0; prepareFast(); + mfem::Vector originalAction(fast->Height()), changedAction(fast->Height()); + fast->Mult(x, originalAction); + diagonals[0] *= 2.0; prepareFast(); prepareReference(); compare("schur_changed_mass_diagonal"); + fast->Mult(x, changedAction); + originalAction *= 0.5; + checks.Add("schur_inverse_mass_scaling", RelativeError(changedAction, originalAction), 2.0e-11); + diagonals[0] = originalDiagonal; prepareFast(); prepareReference(); compare("schur_restored_mass_diagonal"); + checks.Add("divergence_shared_across_geometry",cache.GetBuildCount()==1?0.0:1.0,0.0); + state=0; + if(options.check=="full") { + cache.Clear();prepareFast();prepareReference();compare("schur_after_clear"); + checks.Add("divergence_clear_rebuild",cache.GetBuildCount()==2?0.0:1.0,0.0); + f.mesh->NodesUpdated();prepareFast();prepareReference();compare("schur_after_nodes_update"); + checks.Add("divergence_nodes_update_rebuild",cache.GetBuildCount()==3?0.0:1.0,0.0); + } + } + if(!checks.passed)return 3; + state=0;const auto builds=cache.GetBuildCount(); + BenchmarkPreparation(options,"gravity-schur-preparation", + [&]{state=(state+1)%3;},prepareFast,prepareReference, + [&](auto &out){out.SetSize(fast->Height());fast->Mult(x,out);}, + [&](auto &out){out.SetSize(reference->Height());reference->Mult(x,out);},timings,summary); + if(options.check!="none")checks.Add("divergence_warm_rebuilds",double(cache.GetBuildCount()-builds),0.0); + metadata << "fixed_divergence_assemblies=" << cache.GetBuildCount() << '\n'; + return checks.passed?0:3; + } + + int Run(const Options &options) { + if (options.help) { + std::cout << "system_isolation_profile_harness\n" + " --system hdiv-displacement|hdiv-mass|mass-normalization|angular-momentum|\n" + " hydrostatic-displacement|stellar-derivatives|barotropic-closure|barotropic-enthalpy|\n" + " barotropic-displacement|pressure-displacement|pressure-enthalpy|gravity-force|rotation-force|\n" + " linear-derivatives|preparation-optimizations|runtime-optimizations|\n" + " geometry-preparation|normalization-diagonals|application-optimizations|\n" + " final-optimizations|gravity-source-displacement|hydrostatic-preparation|gravity-schur-preparation|\n" + " geometry-inspection|gravity-source-preparation|all (default hdiv-displacement)\n" + " --implementation both|optimized|reference (default both)\n" + " --check full|reference|none (default full)\n" + " --mesh sandbox.smesh --output NEW_DIRECTORY --refinements 0\n" + " --samples 5 --iterations 3 --warmup 1 --directions 3\n" + " --prepare-samples 3 --prepare-iterations 1 (prepare-samples 0 skips preparation timing)\n" + " --deformation 0.7 --fd-step 1e-5 --polytropic-index 1\n" + "Single MPI rank, CPU. No Newton solve. Exit 0: requested checks pass;\n" + "2: execution error; 3: check failure. Existing output directories are refused.\n" + "hdiv-displacement compares the optimized and retained prepared derivatives.\n" + "hdiv-mass compares the new CPU application with retained MFEM partial assembly.\n" + "Full checks also compare the independent stateless mass kernel.\n"; + return 0; + } + if (!std::filesystem::create_directory(options.output)) + throw std::invalid_argument("Output directory already exists: " + options.output.string()); + auto metadata = File(options.output / "metadata.txt"); + auto summary = File(options.output / "summary.txt"); + auto timings = File(options.output / "timings.csv"); + CheckReport checks{File(options.output / "checks.csv")}; + checks.stream << "check,normalized_error,tolerance,passed\n"; + timings << "system,implementation,sample,applications,total_seconds,seconds_per_application,last_output_norm\n"; + metadata << "mesh=" << std::filesystem::absolute(options.mesh).string() + << "\nsystem=" << options.system << "\nimplementation=" << options.implementation + << "\ncheck=" << options.check << "\nrefinements=" << options.refinements + << "\nsamples=" << options.samples << "\niterations=" << options.iterations + << "\nwarmup=" << options.warmup << "\ndirections=" << options.directions + << "\nprepare_samples=" << options.prepareSamples << "\nprepare_iterations=" << options.prepareIterations + << "\npreparation_benchmark=forced_revisions_shared_gravity_excluded" + << "\npreparation_reference=original_matrix_assembly_and_separate_force_residual_traversals" + << "\nhydrostatic_reference=previous_cached_pointwise" + << "\nhdiv_displacement_reference=previous_optimized_pointwise" + << "\ngravity_force_reference=previous_optimized_pointwise" + << "\ngeometry_preparation_reference=same_operator_with_fixed_mesh_cache_disabled" + << "\nhdiv_mass_reference=previous_mfem_partial_assembly" + << "\nclosure_reference=previous_optimized_pointwise" + << "\nrotation_reference=previous_optimized_pointwise" + << "\ngeometry_inspection_reference=original_gridfunction_gradient_same_points" + << "\ngravity_source_preparation_reference=previous_mesh_weight_recomputation" + << "\npressure_displacement_reference=previous_optimized_pointwise" + << "\npressure_preparation_reference=previous_blocked_matrix_assembly_and_pointwise_fields" + << "\ngravity_source_displacement_reference=previous_pointwise" + << "\nhydrostatic_preparation_reference=previous_pointwise_fields_and_dense_matrix_assembly" + << "\ngravity_schur_preparation_reference=fixed_divergence_reassembled_every_call" + << "\ndeformation=" << options.deformation << "\nfd_step=" << options.finiteDifferenceStep + << "\nmpi_ranks=1\ndevice=cpu\ncompiler=" << __VERSION__ + << "\nuniform_order_increment=" << MEAN_FIELD_UNIFORM_POLYNOMIAL_ORDER_INCREMENT << '\n'; + metadata.flush(); + + utils::Args arguments; + arguments.mesh_file = options.mesh; + const auto setupStart = Clock::now(); + auto f = fem::setup_fem(arguments.mesh_file, arguments, options.refinements); + if (!f.okay() || f.mesh->Dimension() != 3) throw std::runtime_error("A valid 3D discretization is required."); + summary << "fem_setup_seconds=" << std::chrono::duration(Clock::now() - setupStart).count() << '\n'; + int vacuumElements = 0; + for (int element = 0; element < f.mesh->GetNE(); ++element) + vacuumElements += f.domainMapperStateless->IsCompactifiedElement(*f.mesh->GetElementTransformation(element)); + metadata << "elements=" << f.mesh->GetNE() << "\nstellar_elements=" << f.mesh->GetNE() - vacuumElements + << "\nvacuum_elements=" << vacuumElements << "\nflux_true_dofs=" << f.gravityFluxFes->GetTrueVSize() + << "\ndisplacement_true_dofs=" << f.displacementFes->GetTrueVSize() + << "\ndensity_true_dofs=" << f.densityFes->GetTrueVSize() + << "\nenthalpy_true_dofs=" << f.enthalpyFes->GetTrueVSize() << '\n'; + + const bool finalGroup = options.system == "final-optimizations"; + if (finalGroup || options.system == "gravity-source-displacement" || + options.system == "hydrostatic-preparation" || options.system == "gravity-schur-preparation") { + int result=0; + if(finalGroup) { + auto pressureOptions=options;pressureOptions.system="pressure-displacement"; + result=RunLinearDerivatives(pressureOptions,f,metadata,summary,timings,checks); + if(result)return result; + } + if(finalGroup || options.system=="gravity-source-displacement") { + result=RunSourceDisplacement(options,f,metadata,summary,timings,checks); + if(result)return result; + } + if(finalGroup || options.system=="hydrostatic-preparation") { + result=RunHydrostaticPreparation(options,f,metadata,summary,timings,checks); + if(result)return result; + } + if(finalGroup || options.system=="gravity-schur-preparation") { + result=RunGravitySchurPreparation(options,f,metadata,summary,timings,checks); + if(result)return result; + } + summary << "final.checks=" << (options.check=="none"?"not_run":"passed") << '\n'; + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } + if (options.system == "all") { + int result = RunSourceDisplacement(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + result = RunHydrostaticPreparation(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + result = RunGravitySchurPreparation(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + } + const bool runtimeGroup = options.system == "runtime-optimizations"; + const bool applicationGroup = options.system == "application-optimizations"; + if (applicationGroup || options.system == "all" || options.system == "geometry-inspection") { + const int result = RunGeometryInspection(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + if (options.system == "geometry-inspection") { + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } + } + if (applicationGroup || options.system == "all" || options.system == "gravity-source-preparation") { + const int result = RunGravitySourcePreparation(options, f, summary, timings, checks); + if (result != 0) return result; + if (options.system == "gravity-source-preparation") { + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } + } + if (runtimeGroup || options.system == "all" || options.system == "normalization-diagonals") { + const int result = RunNormalizationDiagonals(options, f, summary, timings, checks); + if (result != 0) return result; + if (options.system == "normalization-diagonals") { + summary << "checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } + } + if (runtimeGroup || options.system == "all" || options.system == "geometry-preparation") { + const int result = RunGeometryPreparation(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + if (options.system == "geometry-preparation") { + summary << "checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } + } + + if (IsLinearDerivativeSystem(options.system) && !applicationGroup) { + int result = RunLinearDerivatives(options, f, metadata, summary, timings, checks); + if (result == 0 && options.system == "preparation-optimizations") { + auto hydroOptions = options; + hydroOptions.system = "hydrostatic-displacement"; + result = RunStellarDerivatives(hydroOptions, f, metadata, summary, timings, checks); + } + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return result; + } + if (!runtimeGroup && !applicationGroup && options.system != "hdiv-displacement" && options.system != "hdiv-mass" && options.system != "all") { + const int result = RunStellarDerivatives(options, f, metadata, summary, timings, checks); + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return result; + } + + const auto constructStart = Clock::now(); + operators::PreparedMappedHDivMassOperator mass(f, *f.domainMapperStateless); + summary << "operator_construction_seconds=" << std::chrono::duration(Clock::now() - constructStart).count() << '\n'; + const mfem::Vector base = Displacement(f, options.deformation); + std::vector fluxes, directions, reducedFluxes; + for (int index = 0; index < options.directions; ++index) { + fluxes.push_back(Flux(f.gravityFluxFes->GetTrueVSize(), 0.31 + 0.47 * index)); + directions.push_back(Displacement(f, 0.43, 0.2 + 0.7 * index)); + if (index == 1) directions.back().Add(0.002, Flux(f.displacementFes->GetTrueVSize(), 0.37)); + reducedFluxes.push_back(mass.GetFluxMap().gather(fluxes.back())); + } + const bool derivative = options.system != "hdiv-mass" && !applicationGroup; + auto prepare = [&](const mfem::Vector &state) { + const auto reduced = mass.GetDisplacementMap().gather(state); + if (derivative) mass.Prepare(reduced); else mass.PreparePrimal(reduced); + }; + auto start = Clock::now(); + prepare(base); + summary << "initial_prepare_seconds=" << std::chrono::duration(Clock::now() - start).count() << '\n'; + metadata << "fast_mass_elements=" << mass.GetFastMassElementCount() << '\n'; + metadata << "fast_variation_elements=" << mass.GetFastVariationElementCount() + << "\ntensor_variation_elements=" << mass.GetTensorVariationElementCount() << '\n'; + std::size_t quadraturePoints = 0; + mass.VisitMappedGeometryRules([&](int, const mfem::IntegrationRule &rule) { quadraturePoints += rule.GetNPoints(); }); + metadata << "variation_quadrature_points=" << quadraturePoints << '\n'; + metadata.flush(); + + const Apply fastDerivative = [&](int index, mfem::Vector &out) { + mass.MultDisplacementVariationTrue(fluxes[index], directions[index], out); + }; + const Apply referenceDerivative = [&](int index, mfem::Vector &out) { + mass.MultDisplacementVariationTrueCachedReference(fluxes[index], directions[index], out); + }; + const Apply fastMass = [&](int index, mfem::Vector &out) { mass.Mult(reducedFluxes[index], out); }; + const Apply referenceMass = [&](int index, mfem::Vector &out) { mass.MultReference(reducedFluxes[index], out); }; + + if (options.check != "none") { + const auto validationStart = Clock::now(); + // Reprepare distinct geometries to expose stale geometry caches. + const std::vector scales = options.check == "full" ? std::vector{0.0, options.deformation} + : std::vector{options.deformation}; + for (std::size_t state = 0; state < scales.size(); ++state) { + const auto displacement = Displacement(f, scales[state]); + prepare(displacement); + for (int index = 0; index < options.directions; ++index) { + mfem::Vector actual, expected; + const std::string label = "state" + std::to_string(state) + "_direction" + std::to_string(index); + if (derivative) { + fastDerivative(index, actual); + referenceDerivative(index, expected); + checks.Add(label + "_cached_derivative", RelativeError(actual, expected), 2.0e-11); + mass.MultDisplacementVariationTrueReference(fluxes[index], directions[index], expected); + checks.Add(label + "_general_prepared_derivative", RelativeError(actual, expected), 2.0e-11); + if (options.check == "full" && index == 0) { + operators::kernels::apply_mapped_hdiv_mass_variation( + f, *f.domainMapperStateless, fluxes[index], displacement, directions[index], expected); + checks.Add(label + "_stateless_derivative", RelativeError(actual, expected), 2.0e-11); + } + } + if (options.system != "hdiv-displacement" && !runtimeGroup) { + fastMass(index, actual); + referenceMass(index, expected); + checks.Add(label + "_mfem_pa_mass", RelativeError(actual, expected), 2.0e-11); + if (options.check == "full" && index == 0) { + mfem::Vector full; + operators::kernels::apply_mapped_hdiv_mass(f, *f.domainMapperStateless, fluxes[index], displacement, full); + expected = mass.GetFluxMap().gather(full); + checks.Add(label + "_stateless_mass", RelativeError(actual, expected), 2.0e-11); + } + } + } + if (options.system != "hdiv-displacement" && !runtimeGroup) { + const std::string label = "state" + std::to_string(state) + "_mass"; + mfem::Vector other = mass.GetFluxMap().gather( + Flux(f.gravityFluxFes->GetTrueVSize(), 1.37)); + mfem::Vector firstAction, otherAction, combinedAction, expected; + mass.Mult(reducedFluxes[0], firstAction); + mass.Mult(other, otherAction); + const double xy = reducedFluxes[0] * otherAction; + const double yx = other * firstAction; + checks.Add(label + "_symmetry", std::abs(xy-yx) / std::max({1.0, std::abs(xy), std::abs(yx)}), 2.0e-11); + checks.Add(label + "_positive_energy", reducedFluxes[0] * firstAction > 0.0 ? 0.0 : 1.0, 0.0); + mfem::Vector combined(reducedFluxes[0]); + combined *= 0.7; + combined.Add(-0.4, other); + mass.Mult(combined, combinedAction); + expected = firstAction; + expected *= 0.7; + expected.Add(-0.4, otherAction); + checks.Add(label + "_linearity", RelativeError(combinedAction, expected), 2.0e-11); + combined = 0.0; + mass.Mult(combined, combinedAction); + checks.Add(label + "_zero", combinedAction.Norml2(), 0.0); + mass.MultReference(reducedFluxes[0], expected); + mass.Mult(reducedFluxes[0], combinedAction); + checks.Add(label + "_reference_restores_fast_path", RelativeError(combinedAction, firstAction), 0.0); + } + } + if (derivative && options.check == "full") { + mfem::Vector analyticTrue; + fastDerivative(0, analyticTrue); + const auto analytic = mass.GetFluxMap().gather(analyticTrue); + operators::PreparedMappedHDivMassOperator differenceMass(f, *f.domainMapperStateless); + for (const double step : {options.finiteDifferenceStep, 0.5 * options.finiteDifferenceStep}) { + mfem::Vector plus(base), minus(base), plusAction, minusAction; + plus.Add(step, directions[0]); + minus.Add(-step, directions[0]); + differenceMass.PreparePrimal(differenceMass.GetDisplacementMap().gather(plus)); + differenceMass.Mult(reducedFluxes[0], plusAction); + differenceMass.PreparePrimal(differenceMass.GetDisplacementMap().gather(minus)); + differenceMass.Mult(reducedFluxes[0], minusAction); + plusAction -= minusAction; + plusAction /= 2.0 * step; + checks.Add("finite_difference_" + std::to_string(step), RelativeError(analytic, plusAction), 2.0e-6); + } + } + summary << "validation_seconds=" << std::chrono::duration(Clock::now() - validationStart).count() << '\n'; + if (!checks.passed) { + summary << "checks_passed=0\nbenchmark_skipped=1\n"; + return 3; + } + } + + start = Clock::now(); + prepare(base); + summary << "repeat_prepare_seconds=" << std::chrono::duration(Clock::now() - start).count() << '\n'; + summary << "checks=" << (options.check == "none" ? "not_run" : "passed") << '\n'; + summary.flush(); + if (derivative) Benchmark(options, "hdiv-displacement", "cached_pointwise_reference", fastDerivative, referenceDerivative, timings, summary); + if (options.system != "hdiv-displacement" && !runtimeGroup) Benchmark(options, "hdiv-mass", "mfem_pa_reference", fastMass, referenceMass, timings, summary); + if (applicationGroup) { + auto linearOptions = options; + linearOptions.prepareSamples = 0; // preparation measurements above isolate this batch + const int result = RunLinearDerivatives(linearOptions, f, metadata, summary, timings, checks); + if (result != 0) return result; + } + if (runtimeGroup) { + auto gravityOptions = options; + gravityOptions.system = "gravity-force"; + gravityOptions.prepareSamples = 0; // this group measures new application and geometry changes + const int result = RunLinearDerivatives(gravityOptions, f, metadata, summary, timings, checks); + if (result != 0) return result; + } + if (options.system == "all") { + const int result = RunStellarDerivatives(options, f, metadata, summary, timings, checks); + if (result != 0) return result; + const int linearResult = RunLinearDerivatives(options, f, metadata, summary, timings, checks); + if (linearResult != 0) return linearResult; + } + std::cout << "Results: " << std::filesystem::absolute(options.output) << std::endl; + return 0; + } +} + +int main(int argc, char **argv) { + // Parse before MPI initialization so --help does not require a runtime. + Options options; + try { + options = Parse(argc, argv); + if (options.help) return Run(options); + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + return 2; + } + 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("This harness currently requires exactly one MPI rank."); + mfem::Device device("cpu"); + std::cout << std::setprecision(12); + result = Run(options); + } catch (const std::exception &error) { + std::cerr << "Isolation harness error: " << error.what() << std::endl; + result = 2; + } + mfem::Mpi::Finalize(); + return result; +} diff --git a/libmeanfield/impl/fem/reference_tables.cpp b/libmeanfield/impl/fem/reference_tables.cpp index 762bef2..86c41c7 100644 --- a/libmeanfield/impl/fem/reference_tables.cpp +++ b/libmeanfield/impl/fem/reference_tables.cpp @@ -1,6 +1,8 @@ module; +#include #include +#include #include #include #include @@ -11,12 +13,54 @@ module; #include #include +#include module mean_field; import :fem.reference_tables; namespace mean_field::fem { namespace { + // Three one-dimensional contractions, with x fastest throughout. + // Input/output must not alias either scratch array. + void tensor_values( + int dx, int dy, int dz, int q, const double *bx, const double *by, const double *bz, + const double *input, double *output, double *sx, double *sxy, bool transpose + ) { + if (!transpose) { + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int x = 0; x < q; ++x) { + double v = 0.0; + for (int i = 0; i < dx; ++i) v += bx[x + q*i] * input[i + dx*(j + dy*k)]; + sx[x + q*(j + dy*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + double v = 0.0; + for (int j = 0; j < dy; ++j) v += by[y + q*j] * sx[x + q*(j + dy*k)]; + sxy[x + q*(y + q*k)] = v; + } + for (int z = 0; z < q; ++z) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + double v = 0.0; + for (int k = 0; k < dz; ++k) v += bz[z + q*k] * sxy[x + q*(y + q*k)]; + output[x + q*(y + q*z)] = v; + } + } else { + for (int k = 0; k < dz; ++k) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + double v = 0.0; + for (int z = 0; z < q; ++z) v += bz[z + q*k] * input[x + q*(y + q*z)]; + sxy[x + q*(y + q*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int x = 0; x < q; ++x) { + double v = 0.0; + for (int y = 0; y < q; ++y) v += by[y + q*j] * sxy[x + q*(y + q*k)]; + sx[x + q*(j + dy*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int i = 0; i < dx; ++i) { + double v = 0.0; + for (int x = 0; x < q; ++x) v += bx[x + q*i] * sx[x + q*(j + dy*k)]; + output[i + dx*(j + dy*k)] = v; + } + } + } + struct ReferenceTableKey { const mfem::FiniteElement *element; std::vector> points; @@ -112,6 +156,7 @@ namespace mean_field::fem { element.CalcDShape(point, gradient); } } + PrepareTensorEvaluation3D(element, rule); } const mfem::DenseMatrix &ScalarReferenceTable::GetValues() const { @@ -130,6 +175,207 @@ namespace mean_field::fem { return m_dimension; } + void ScalarReferenceTable::PrepareTensorEvaluation3D( + const mfem::FiniteElement &element, const mfem::IntegrationRule &rule + ) { + // Nodal H1 and L2 hexes share the same verified tensor contract. + // Other elements and non-Cartesian rules keep the existing dense path. + const auto *hex = dynamic_cast(&element); + if (dynamic_cast(&element) == nullptr && + dynamic_cast(&element) == nullptr) return; + if (hex == nullptr || element.GetMapType() != mfem::FiniteElement::VALUE || + element.GetDerivType() != mfem::FiniteElement::GRAD || rule.GetNPoints() == 0) return; + const int d = element.GetOrder() + 1; + const int q = static_cast(std::lround(std::cbrt(rule.GetNPoints()))); + if (d * d * d != element.GetDof() || q * q * q != rule.GetNPoints()) return; + for (int z = 0; z < q; ++z) { + for (int y = 0; y < q; ++y) { + for (int x = 0; x < q; ++x) { + const auto &point = rule.IntPoint(x + q * (y + q * z)); + if (point.x != rule.IntPoint(x).x || + point.y != rule.IntPoint(q * y).y || + point.z != rule.IntPoint(q * q * z).z) return; + } + } + } + const auto &map = hex->GetDofMap(); + if (map.Size() != 0 && map.Size() != element.GetDof()) return; + m_tensorDofMap.SetSize(element.GetDof()); + std::vector seen(element.GetDof(), false); + for (int i = 0; i < element.GetDof(); ++i) { + const int native = map.Size() == 0 ? i : map[i]; + if (native < 0 || native >= element.GetDof() || seen[native]) return; + seen[native] = true; + m_tensorDofMap[i] = native; + } + mfem::Vector b(d), g(d); + for (int axis = 0; axis < 3; ++axis) { + m_tensorValues[axis].SetSize(q, d); + m_tensorGradients[axis].SetSize(q, d); + for (int i = 0; i < q; ++i) { + const double coordinate = axis == 0 ? rule.IntPoint(i).x : + axis == 1 ? rule.IntPoint(q * i).y : rule.IntPoint(q * q * i).z; + hex->GetBasis1D().Eval(coordinate, b, g); + for (int j = 0; j < d; ++j) { + m_tensorValues[axis](i, j) = b(j); + m_tensorGradients[axis](i, j) = g(j); + } + } + } + // Verify the native DOF map and factorization once per shared table. + // This also guards against an unexpected MFEM basis convention. + const auto agrees = [](double actual, double expected) { + return std::isfinite(actual) && std::abs(actual - expected) <= + 64.0 * std::numeric_limits::epsilon() * std::max(1.0, std::abs(expected)); + }; + for (int z = 0; z < q; ++z) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + const int point = x + q * (y + q * z); + for (int k = 0; k < d; ++k) for (int j = 0; j < d; ++j) for (int i = 0; i < d; ++i) { + const int native = m_tensorDofMap[i + d * (j + d * k)]; + const double bx = m_tensorValues[0](x, i), by = m_tensorValues[1](y, j), + bz = m_tensorValues[2](z, k); + if (!agrees(bx * by * bz, m_values(point, native)) || + !agrees(m_tensorGradients[0](x, i) * by * bz, m_gradients[point](native, 0)) || + !agrees(bx * m_tensorGradients[1](y, j) * bz, m_gradients[point](native, 1)) || + !agrees(bx * by * m_tensorGradients[2](z, k), m_gradients[point](native, 2))) return; + } + } + m_tensorDofsPerAxis = d; + m_tensorPointsPerAxis = q; + } + + bool ScalarReferenceTable::HasTensorEvaluation3D() const noexcept { + return m_tensorPointsPerAxis != 0; + } + + void ScalarReferenceTable::EvaluateTensor3D( + const mfem::DenseMatrix &elementDofs, mfem::DenseMatrix &valuesAndGradients, + mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && elementDofs.Height() == GetDofCount(), + "Tensor evaluation requires a compatible verified hexahedral reference table."); + const int d = m_tensorDofsPerAxis, q = m_tensorPointsPerAxis; + const int nx = q * d * d, nxy = q * q * d, nq = q * q * q; + workspace.SetSize(2 * nx + 3 * nxy); + double *xValue = workspace.HostWrite(), *xGradient = xValue + nx; + double *xyValue = xGradient + nx, *xyDx = xyValue + nxy, *xyDy = xyDx + nxy; + valuesAndGradients.SetSize(nq, 4 * elementDofs.Width()); + const double *u = elementDofs.Data(); + double *out = valuesAndGradients.Data(); + const double *bx = m_tensorValues[0].Data(), *by = m_tensorValues[1].Data(), + *bz = m_tensorValues[2].Data(), *gx = m_tensorGradients[0].Data(), + *gy = m_tensorGradients[1].Data(), *gz = m_tensorGradients[2].Data(); + const int ndof = GetDofCount(); + for (int c = 0; c < elementDofs.Width(); ++c) { + // Contract x, retaining the value and x derivative. + for (int k = 0; k < d; ++k) for (int j = 0; j < d; ++j) for (int x = 0; x < q; ++x) { + double value = 0.0, dx = 0.0; + for (int i = 0; i < d; ++i) { + const double coefficient = u[m_tensorDofMap[i + d * (j + d * k)] + c * ndof]; + value += bx[x + q * i] * coefficient; + dx += gx[x + q * i] * coefficient; + } + const int index = x + q * (j + d * k); + xValue[index] = value; xGradient[index] = dx; + } + // Contract y; propagate dx and form dy. + for (int k = 0; k < d; ++k) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + double value = 0.0, dx = 0.0, dy = 0.0; + for (int j = 0; j < d; ++j) { + const int index = x + q * (j + d * k); + value += by[y + q * j] * xValue[index]; + dx += by[y + q * j] * xGradient[index]; + dy += gy[y + q * j] * xValue[index]; + } + const int index = x + q * (y + q * k); + xyValue[index] = value; xyDx[index] = dx; xyDy[index] = dy; + } + // Contract z and write all four fields in quadrature ordering. + for (int z = 0; z < q; ++z) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + double value = 0.0, dx = 0.0, dy = 0.0, dz = 0.0; + for (int k = 0; k < d; ++k) { + const int index = x + q * (y + q * k); + value += bz[z + q * k] * xyValue[index]; + dx += bz[z + q * k] * xyDx[index]; + dy += bz[z + q * k] * xyDy[index]; + dz += gz[z + q * k] * xyValue[index]; + } + const int point = x + q * (y + q * z); + out[point + nq * (4 * c)] = value; + out[point + nq * (4 * c + 1)] = dx; + out[point + nq * (4 * c + 2)] = dy; + out[point + nq * (4 * c + 3)] = dz; + } + } + } + + void ScalarReferenceTable::ApplyGradientTransposeTensor3D( + const mfem::DenseMatrix &quadratureDuals, mfem::DenseMatrix &elementDuals, + mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && quadratureDuals.Height() == GetPointCount() && + quadratureDuals.Width() % 3 == 0, "Tensor gradient transpose has incompatible duals."); + const int d = m_tensorDofsPerAxis, q = m_tensorPointsPerAxis, n = GetDofCount(); + workspace.SetSize(n + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + n, *sxy = sx + q*d*d; + elementDuals.SetSize(n, quadratureDuals.Width()/3); + elementDuals = 0.0; + for (int c = 0; c < elementDuals.Width(); ++c) for (int a = 0; a < 3; ++a) { + tensor_values(d, d, d, q, + (a == 0 ? m_tensorGradients[0] : m_tensorValues[0]).Data(), + (a == 1 ? m_tensorGradients[1] : m_tensorValues[1]).Data(), + (a == 2 ? m_tensorGradients[2] : m_tensorValues[2]).Data(), + quadratureDuals.GetColumn(3*c+a), lex, sx, sxy, true); + for (int i = 0; i < n; ++i) elementDuals(m_tensorDofMap[i], c) += lex[i]; + } + } + + void ScalarReferenceTable::EvaluateValuesTensor3D( + const mfem::Vector &elementDofs, mfem::Vector &values, mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && elementDofs.Size() == GetDofCount(), + "Tensor values require compatible element DOFs."); + const int d = m_tensorDofsPerAxis, q = m_tensorPointsPerAxis, n = GetDofCount(); + workspace.SetSize(n + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + n, *sxy = sx + q*d*d; + const double *u = elementDofs.HostRead(); + for (int i = 0; i < n; ++i) lex[i] = u[m_tensorDofMap[i]]; + values.SetSize(GetPointCount()); + tensor_values(d, d, d, q, m_tensorValues[0].Data(), m_tensorValues[1].Data(), + m_tensorValues[2].Data(), lex, values.HostWrite(), sx, sxy, false); + } + + void ScalarReferenceTable::ApplyTransposeTensor3D( + const mfem::Vector &quadratureDuals, mfem::Vector &elementDuals, mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && quadratureDuals.Size() == GetPointCount(), + "Tensor transpose requires compatible quadrature duals."); + const int d = m_tensorDofsPerAxis, q = m_tensorPointsPerAxis, n = GetDofCount(); + workspace.SetSize(n + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + n, *sxy = sx + q*d*d; + tensor_values(d, d, d, q, m_tensorValues[0].Data(), m_tensorValues[1].Data(), + m_tensorValues[2].Data(), quadratureDuals.HostRead(), lex, sx, sxy, true); + elementDuals.SetSize(n); + double *out = elementDuals.HostWrite(); + for (int i = 0; i < n; ++i) out[m_tensorDofMap[i]] = lex[i]; + } + + void ScalarReferenceTable::ApplyTransposeTensor3D( + const mfem::DenseMatrix &quadratureDuals, mfem::DenseMatrix &elementDuals, mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && quadratureDuals.Height() == GetPointCount(), + "Tensor transpose requires compatible quadrature duals."); + const int d = m_tensorDofsPerAxis, q = m_tensorPointsPerAxis, n = GetDofCount(); + workspace.SetSize(n + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + n, *sxy = sx + q*d*d; + elementDuals.SetSize(n, quadratureDuals.Width()); + for (int c = 0; c < quadratureDuals.Width(); ++c) { + tensor_values(d, d, d, q, m_tensorValues[0].Data(), m_tensorValues[1].Data(), + m_tensorValues[2].Data(), quadratureDuals.GetColumn(c), lex, sx, sxy, true); + for (int i = 0; i < n; ++i) elementDuals(m_tensorDofMap[i], c) = lex[i]; + } + } + VectorReferenceTable::VectorReferenceTable( const mfem::FiniteElement &element, const mfem::IntegrationRule &rule @@ -142,6 +388,122 @@ namespace mean_field::fem { values.SetSize(m_dof_count, m_dimension); element.CalcVShape(rule.IntPoint(q), values); } + PrepareTensorEvaluation3D(element, rule); + } + + void VectorReferenceTable::PrepareTensorEvaluation3D( + const mfem::FiniteElement &element, const mfem::IntegrationRule &rule + ) { + const auto *rt = dynamic_cast(&element); + if (rt == nullptr || element.GetMapType() != mfem::FiniteElement::H_DIV || rule.GetNPoints() == 0) return; + const int q = static_cast(std::lround(std::cbrt(rule.GetNPoints()))); + if (q*q*q != rule.GetNPoints()) return; + for (int z = 0; z < q; ++z) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + const auto &point = rule.IntPoint(x + q*(y + q*z)); + if (point.x != rule.IntPoint(x).x || point.y != rule.IntPoint(q*y).y || + point.z != rule.IntPoint(q*q*z).z) return; + } + int d = 2; + while (3*d*(d-1)*(d-1) < m_dof_count) ++d; + if (3*d*(d-1)*(d-1) != m_dof_count) return; + const auto &map = rt->GetDofMap(); + if (map.Size() != m_dof_count) return; + m_tensorDofMap = map; + std::vector seen(m_dof_count, false); + for (int i = 0; i < m_dof_count; ++i) { + const int native = map[i] < 0 ? -1-map[i] : map[i]; + if (native < 0 || native >= m_dof_count || seen[native]) return; + seen[native] = true; + } + const int block = m_dof_count / 3; + const auto basis = [&](int point, int lex, int component) { + const int encoded = map[lex], native = encoded < 0 ? -1-encoded : encoded; + return (encoded < 0 ? -1.0 : 1.0) * m_values[point](native, component); + }; + // Extract owned 1D factors from the full basis. This supports nodal and + // integrated open bases without retaining DofToQuad pointers to rules. + for (int c = 0; c < 3; ++c) { + const int dims[3]{c == 0 ? d : d-1, c == 1 ? d : d-1, c == 2 ? d : d-1}; + int anchor = 0; + for (int point = 1; point < q*q*q; ++point) + if (std::abs(basis(point, c*block, c)) > std::abs(basis(anchor, c*block, c))) anchor = point; + const double base = basis(anchor, c*block, c); + if (!std::isfinite(base) || base == 0.0) return; + const int coordinates[3]{anchor % q, (anchor/q) % q, anchor/(q*q)}; + const int strides[3]{1, dims[0], dims[0]*dims[1]}; + for (int axis = 0; axis < 3; ++axis) { + auto &b = m_tensorValues[c][axis]; + b.SetSize(q, dims[axis]); + for (int i = 0; i < dims[axis]; ++i) for (int t = 0; t < q; ++t) { + int xyz[3]{coordinates[0], coordinates[1], coordinates[2]}; + xyz[axis] = t; + b(t, i) = basis(xyz[0] + q*(xyz[1] + q*xyz[2]), c*block + strides[axis]*i, c) / + (axis == 0 ? 1.0 : base); + } + } + // Check every native basis entry, including zero components. + for (int k = 0; k < dims[2]; ++k) for (int j = 0; j < dims[1]; ++j) + for (int i = 0; i < dims[0]; ++i) { + const int lex = c*block + i + dims[0]*(j + dims[1]*k); + for (int z = 0; z < q; ++z) for (int y = 0; y < q; ++y) for (int x = 0; x < q; ++x) { + const int point = x + q*(y + q*z); + const double product = m_tensorValues[c][0](x,i) * m_tensorValues[c][1](y,j) * + m_tensorValues[c][2](z,k); + for (int component = 0; component < 3; ++component) { + const double expected = basis(point, lex, component); + const double actual = component == c ? product : 0.0; + if (!std::isfinite(actual) || std::abs(actual - expected) > + 128.0 * std::numeric_limits::epsilon() * std::max(1.0, std::abs(expected))) return; + } + } + } + } + m_tensorClosedDofs = d; + m_tensorPointsPerAxis = q; + } + + bool VectorReferenceTable::HasTensorEvaluation3D() const noexcept { + return m_tensorPointsPerAxis != 0; + } + + void VectorReferenceTable::EvaluateTensor3D( + const mfem::Vector &elementDofs, mfem::DenseMatrix &values, mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && elementDofs.Size() == m_dof_count, + "RT tensor evaluation requires compatible element DOFs."); + const int d = m_tensorClosedDofs, q = m_tensorPointsPerAxis, block = m_dof_count/3; + workspace.SetSize(block + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + block, *sxy = sx + q*d*d; + values.SetSize(q*q*q, 3); + for (int c = 0; c < 3; ++c) { + for (int i = 0; i < block; ++i) { + const int encoded = m_tensorDofMap[c*block+i]; + lex[i] = encoded < 0 ? -elementDofs(-1-encoded) : elementDofs(encoded); + } + tensor_values(c == 0 ? d : d-1, c == 1 ? d : d-1, c == 2 ? d : d-1, q, + m_tensorValues[c][0].Data(), m_tensorValues[c][1].Data(), m_tensorValues[c][2].Data(), + lex, values.GetColumn(c), sx, sxy, false); + } + } + + void VectorReferenceTable::ApplyTransposeTensor3D( + const mfem::DenseMatrix &quadratureDuals, mfem::Vector &elementDuals, mfem::Vector &workspace + ) const { + MFEM_VERIFY(HasTensorEvaluation3D() && quadratureDuals.Height() == GetPointCount() && quadratureDuals.Width() == 3, + "RT tensor transpose requires three quadrature dual components."); + const int d = m_tensorClosedDofs, q = m_tensorPointsPerAxis, block = m_dof_count/3; + workspace.SetSize(block + q*d*d + q*q*d); + double *lex = workspace.HostWrite(), *sx = lex + block, *sxy = sx + q*d*d; + elementDuals.SetSize(m_dof_count); + for (int c = 0; c < 3; ++c) { + tensor_values(c == 0 ? d : d-1, c == 1 ? d : d-1, c == 2 ? d : d-1, q, + m_tensorValues[c][0].Data(), m_tensorValues[c][1].Data(), m_tensorValues[c][2].Data(), + quadratureDuals.GetColumn(c), lex, sx, sxy, true); + for (int i = 0; i < block; ++i) { + const int encoded = m_tensorDofMap[c*block+i]; + if (encoded < 0) elementDuals(-1-encoded) = -lex[i]; else elementDuals(encoded) = lex[i]; + } + } } const mfem::DenseMatrix &VectorReferenceTable::GetValues(const int point) const { @@ -156,4 +518,132 @@ namespace mean_field::fem { int VectorReferenceTable::GetDimension() const { return m_dimension; } + + struct PreparedGeometryInspector::Storage { + struct Element { + std::shared_ptr basis; + mfem::DenseMatrix inverseJacobians; // rows: points; columns: row-major entries + }; + const mfem::ParFiniteElementSpace *space{nullptr}; + const mfem::Mesh *mesh{nullptr}; + long spaceSequence{-1}, meshSequence{-1}, nodesSequence{-1}; + std::unique_ptr tables; + std::vector elements; + std::unique_ptr field; + mfem::Array dofs; + mfem::Vector elementDofs, workspace; + mfem::DenseMatrix dofMatrix, samples, gradient; + std::uint64_t builds{0}; + + void Clear() { + elements.clear(); + tables.reset(); + field.reset(); + space = nullptr; + mesh = nullptr; + } + }; + + PreparedGeometryInspector::PreparedGeometryInspector() : m_storage(std::make_unique()) {} + PreparedGeometryInspector::~PreparedGeometryInspector() = default; + PreparedGeometryInspector::PreparedGeometryInspector(PreparedGeometryInspector &&) noexcept = default; + PreparedGeometryInspector &PreparedGeometryInspector::operator=(PreparedGeometryInspector &&) noexcept = default; + void PreparedGeometryInspector::ClearCache() const { m_storage->Clear(); } + int PreparedGeometryInspector::GetTensorElementCount() const noexcept { + int count = 0; + for (const auto &element : m_storage->elements) + count += element.basis && element.basis->HasTensorEvaluation3D(); + return count; + } + std::uint64_t PreparedGeometryInspector::GetGeometryBuildCount() const noexcept { return m_storage->builds; } + + double PreparedGeometryInspector::Inspect( + mfem::ParFiniteElementSpace &space, const mfem::Vector &displacement, bool reference + ) const { + MFEM_VERIFY(displacement.Size() == space.GetTrueVSize(), "Geometry inspection received incompatible DOFs."); + auto &s = *m_storage; + auto *mesh = space.GetMesh(); + const int dimension = mesh->SpaceDimension(); + MFEM_VERIFY(space.GetVDim() == dimension, "Geometry inspection requires a spatial displacement vector."); + if (s.space != &space || s.mesh != mesh || s.spaceSequence != space.GetSequence() || + s.meshSequence != mesh->GetSequence() || s.nodesSequence != mesh->GetNodesSequence()) { + s.Clear(); + s.space = &space; s.mesh = mesh; + s.spaceSequence = space.GetSequence(); s.meshSequence = mesh->GetSequence(); + s.nodesSequence = mesh->GetNodesSequence(); + s.field = std::make_unique(&space); + s.tables = std::make_unique(); + s.elements.resize(mesh->GetNE()); + } + s.field->SetFromTrueDofs(displacement); + double minimum = std::numeric_limits::infinity(); + int finite = 1; + auto accumulate = [&](double determinant) { + if (!std::isfinite(determinant)) finite = 0; + else minimum = std::min(minimum, determinant); + }; + for (int e = 0; e < mesh->GetNE(); ++e) { + auto &entry = s.elements[e]; + auto &transformation = *mesh->GetElementTransformation(e); + const auto &element = *space.GetFE(e); + // Preserve the existing d*p geometry inspection order, including + // vacuum elements and points beyond the physics quadrature. + const int order = std::max(element.GetOrder() + 2, 2 * dimension * element.GetOrder()); + const auto &rule = mfem::IntRules.Get(transformation.GetGeometryType(), order); + if (!reference && dimension == 3 && element.GetDim() == 3 && + element.GetMapType() == mfem::FiniteElement::VALUE && + dynamic_cast(&element)) { + if (!entry.basis) { + entry.basis = s.tables->GetScalarTable(element, rule); + if (entry.basis->HasTensorEvaluation3D()) { + entry.inverseJacobians.SetSize(rule.GetNPoints(), 9); + for (int q = 0; q < rule.GetNPoints(); ++q) { + transformation.SetIntPoint(&rule.IntPoint(q)); + const auto &inverse = transformation.InverseJacobian(); + for (int a = 0; a < 3; ++a) for (int b = 0; b < 3; ++b) + entry.inverseJacobians(q, a*3+b) = inverse(a,b); + } + ++s.builds; + } + } + } + if (!reference && entry.basis && entry.basis->HasTensorEvaluation3D()) { + auto *dofTransformation = space.GetElementVDofs(e, s.dofs); + s.field->GetSubVector(s.dofs, s.elementDofs); + if (dofTransformation) dofTransformation->InvTransformPrimal(s.elementDofs); + const int nd = element.GetDof(); + s.dofMatrix.SetSize(nd, 3); + // GetElementVDofs groups local entries by component for either + // global ordering, as used by GridFunction::GetVectorGradientHat. + for (int c = 0; c < 3; ++c) for (int i = 0; i < nd; ++i) + s.dofMatrix(i,c) = s.elementDofs(i+c*nd); + entry.basis->EvaluateTensor3D(s.dofMatrix, s.samples, s.workspace); + s.gradient.SetSize(3); + for (int q = 0; q < rule.GetNPoints(); ++q) { + for (int c = 0; c < 3; ++c) for (int b = 0; b < 3; ++b) { + double value = c == b ? 1.0 : 0.0; + for (int a = 0; a < 3; ++a) + value += s.samples(q, 4*c+a+1) * entry.inverseJacobians(q, a*3+b); + s.gradient(c,b) = value; + } + accumulate(s.gradient.Det()); + } + } else { + for (int q = 0; q < rule.GetNPoints(); ++q) { + transformation.SetIntPoint(&rule.IntPoint(q)); + s.field->GetVectorGradient(transformation, s.gradient); + for (int c = 0; c < dimension; ++c) s.gradient(c,c) += 1.0; + accumulate(s.gradient.Det()); + } + } + } + double globalMinimum = 0.0; + int globalFinite = 0; + const int minimumStatus = MPI_Allreduce(&minimum, &globalMinimum, 1, MPI_DOUBLE, MPI_MIN, space.GetComm()); + const int finiteStatus = MPI_Allreduce(&finite, &globalFinite, 1, MPI_INT, MPI_MIN, space.GetComm()); + if (minimumStatus != MPI_SUCCESS || finiteStatus != MPI_SUCCESS) + throw std::runtime_error("Could not reduce the mapped-geometry inspection."); + return globalFinite ? globalMinimum : std::numeric_limits::quiet_NaN(); + } + } // namespace mean_field::fem diff --git a/libmeanfield/impl/mapping/compactification/kelvin.cpp b/libmeanfield/impl/mapping/compactification/kelvin.cpp index ca5f150..356c326 100644 --- a/libmeanfield/impl/mapping/compactification/kelvin.cpp +++ b/libmeanfield/impl/mapping/compactification/kelvin.cpp @@ -242,6 +242,23 @@ namespace mean_field::mapping::compactification { return MappingStatus::valid; } + MappingStatus KelvinCompactification::EvaluateJacobianVariationFactors( + const double coordinate, + const mfem::Vector &coordinateGradient, + double &scale, + mfem::Vector &scaleGradient + ) const { + if (coordinateGradient.Size() <= 0) return MappingStatus::invalid_dimension; + if (!vector_is_finite(coordinateGradient)) return MappingStatus::non_finite_input; + RadialFactors factors; + const MappingStatus status = ComputeRadialFactors(coordinate, factors); + if (status != MappingStatus::valid) return status; + scale = factors.scale; + scaleGradient = coordinateGradient; + scaleGradient *= factors.scale_derivative; + return vector_is_finite(scaleGradient) ? MappingStatus::valid : MappingStatus::non_finite_result; + } + std::string_view KelvinCompactification::GetName() const noexcept { return "KelvinCompactification"; } @@ -257,4 +274,4 @@ namespace mean_field::mapping::compactification { double KelvinCompactification::GetCoordinateTolerance() const noexcept { return m_options.coordinate_tolerance; } -} // namespace mean_field::mapping::compactification \ No newline at end of file +} // namespace mean_field::mapping::compactification diff --git a/libmeanfield/impl/mapping/domain_mapper.cpp b/libmeanfield/impl/mapping/domain_mapper.cpp index 12ee7be..4b923e3 100644 --- a/libmeanfield/impl/mapping/domain_mapper.cpp +++ b/libmeanfield/impl/mapping/domain_mapper.cpp @@ -1,6 +1,9 @@ module; #include +#include +#include +#include #include #include #include @@ -32,6 +35,65 @@ namespace { } // namespace namespace mean_field::mapping { + struct DomainMapper::GeometryCache { + bool enabled{true}; + const mfem::Mesh *mesh{nullptr}; + long sequence{-1}, nodesSequence{-1}; + std::map, FixedMeshGeometry> points; + std::uint64_t hits{0}, misses{0}; + }; + + DomainMapper::~DomainMapper() = default; + DomainMapper::DomainMapper(DomainMapper &&) noexcept = default; + DomainMapper &DomainMapper::operator=(DomainMapper &&) noexcept = default; + + void DomainMapper::SetGeometryCacheEnabled(bool enabled) const { m_geometryCache->enabled = enabled; } + bool DomainMapper::IsGeometryCacheEnabled() const noexcept { return m_geometryCache->enabled; } + void DomainMapper::ClearGeometryCache() const { + auto &cache = *m_geometryCache; + cache.points.clear(); + cache.mesh = nullptr; + cache.sequence = cache.nodesSequence = -1; + cache.hits = cache.misses = 0; + } + DomainMapper::GeometryCacheStatistics DomainMapper::GetGeometryCacheStatistics() const { + const auto &cache = *m_geometryCache; + return {.hits = cache.hits, .misses = cache.misses, .points = cache.points.size()}; + } + + const DomainMapper::FixedMeshGeometry *DomainMapper::GetFixedMeshGeometry( + mfem::ElementTransformation &transformation, const mfem::IntegrationPoint &point + ) const { + transformation.SetIntPoint(&point); + auto &cache = *m_geometryCache; + const auto *mesh = transformation.mesh; + if (!cache.enabled || mesh == nullptr || transformation.ElementType != mfem::ElementTransformation::ELEMENT || + transformation.ElementNo < 0 || transformation.ElementNo >= mesh->GetNE() || + transformation.GetSpaceDim() != m_options.dimension) return nullptr; + const std::array coordinates{ + point.x, m_options.dimension > 1 ? point.y : 0.0, m_options.dimension > 2 ? point.z : 0.0}; + for (double coordinate : coordinates) if (!std::isfinite(coordinate)) return nullptr; + if (cache.mesh != mesh || cache.sequence != mesh->GetSequence() || cache.nodesSequence != mesh->GetNodesSequence()) { + cache.points.clear(); + cache.mesh = mesh; + cache.sequence = mesh->GetSequence(); + cache.nodesSequence = mesh->GetNodesSequence(); + } + const auto key = std::tuple{transformation.ElementNo, coordinates[0], coordinates[1], coordinates[2]}; + const auto found = cache.points.find(key); + if (found != cache.points.end()) { ++cache.hits; return &found->second; } + FixedMeshGeometry geometry; + transformation.Transform(point, geometry.position); + geometry.jacobian = transformation.Jacobian(); + geometry.inverseJacobian = transformation.InverseJacobian(); + geometry.weight = transformation.Weight(); + ++cache.misses; + if (!vector_is_finite(geometry.position) || !matrix_is_finite(geometry.jacobian) || + !matrix_is_finite(geometry.inverseJacobian) || !std::isfinite(geometry.weight) || geometry.weight <= 0.0) + return nullptr; + return &cache.points.emplace(key, std::move(geometry)).first->second; + } + ElementCompactificationData::ElementCompactificationData( const mfem::FiniteElement &element, const mfem::Vector &dofs @@ -195,7 +257,8 @@ namespace mean_field::mapping { const utils::DomainMapperOptions options, std::unique_ptr exterior_map ) - : m_options(options), + : m_geometryCache(std::make_unique()), + m_options(options), m_exterior_map(std::move(exterior_map)) { if (m_options.dimension <= 0) throw std::invalid_argument("The domain-mapping dimension must be positive."); @@ -588,6 +651,16 @@ namespace mean_field::mapping { const mfem::IntegrationPoint &integration_point, Workspace &workspace, MappingPointContext &context + ) const { + const auto *meshGeometry = GetFixedMeshGeometry(transformation, integration_point); + return EvaluatePointImpl(element_data, transformation, integration_point, workspace, context, meshGeometry); + } + + MappingStatus DomainMapper::EvaluatePointImpl( + const ElementMappingData &element_data, mfem::ElementTransformation &transformation, + const mfem::IntegrationPoint &integration_point, Workspace &workspace, + MappingPointContext &context, const FixedMeshGeometry *meshGeometry, + const ReferenceDisplacementSample *displacementSample ) const { ValidateElementData(element_data); @@ -602,16 +675,28 @@ namespace mean_field::mapping { "element data." ); - transformation.SetIntPoint(&integration_point); - context.reference_position.SetSize(m_options.dimension); - transformation.Transform(integration_point, context.reference_position); + if (meshGeometry) context.reference_position = meshGeometry->position; + else transformation.Transform(integration_point, context.reference_position); // Get the displacement field value and its Jacobian at the integration point. Note these are in the workspace to avoid repeated allocations. - EvaluateField( - element_data.displacement, transformation, integration_point, workspace, workspace.m_field_value, - workspace.m_field_jacobian, nullptr - ); + if (displacementSample) { + if (element_data.displacement.GetElement().GetMapType() != mfem::FiniteElement::VALUE || + displacementSample->value.Size() != m_options.dimension || + displacementSample->gradient.Height() != m_options.dimension || + displacementSample->gradient.Width() != m_options.dimension) + return MappingStatus::invalid_dimension; + workspace.m_field_value = displacementSample->value; + workspace.m_field_jacobian.SetSize(m_options.dimension); + mfem::Mult(displacementSample->gradient, + meshGeometry ? meshGeometry->inverseJacobian : transformation.InverseJacobian(), + workspace.m_field_jacobian); + } else { + EvaluateField( + element_data.displacement, transformation, integration_point, workspace, workspace.m_field_value, + workspace.m_field_jacobian, meshGeometry ? &meshGeometry->inverseJacobian : nullptr + ); + } if (!vector_is_finite(context.reference_position) || !vector_is_finite(workspace.m_field_value) || !matrix_is_finite(workspace.m_field_jacobian)) { @@ -647,7 +732,7 @@ namespace mean_field::mapping { // the gradient of s as we move along the mesh coordinates. All of this is stashes within workspace.m_compactification_point. const MappingStatus coordinate_status = EvaluateCompactificationCoordinate( element_data.compactification, transformation, integration_point, workspace, - workspace.m_compactification_point, nullptr + workspace.m_compactification_point, meshGeometry ? &meshGeometry->inverseJacobian : nullptr ); if (coordinate_status != MappingStatus::valid) @@ -701,21 +786,25 @@ namespace mean_field::mapping { mfem::ElementTransformation &transformation, const mfem::IntegrationPoint &integration_point, Workspace &workspace, - VolumeMappingContext &context + VolumeMappingContext &context, + const ReferenceDisplacementSample *displacementSample ) const { + const auto *meshGeometry = GetFixedMeshGeometry(transformation, integration_point); const MappingStatus point_status = - EvaluatePoint(element_data, transformation, integration_point, workspace, context.mapping); + EvaluatePointImpl(element_data, transformation, integration_point, workspace, context.mapping, meshGeometry, displacementSample); if (point_status != MappingStatus::valid) return point_status; - mfem::Mult(context.mapping.mapping_jacobian, transformation.Jacobian(), workspace.m_full_element_jacobian); + mfem::Mult(context.mapping.mapping_jacobian, + meshGeometry ? meshGeometry->jacobian : transformation.Jacobian(), workspace.m_full_element_jacobian); context.quadrature.J_inv.SetSize(m_options.dimension, m_options.dimension); mfem::CalcInverse(workspace.m_full_element_jacobian, context.quadrature.J_inv); context.quadrature.detJ = context.mapping.mapping_determinant; context.quadrature.weight = - integration_point.weight * transformation.Weight() * context.mapping.mapping_determinant; + integration_point.weight * (meshGeometry ? meshGeometry->weight : transformation.Weight()) * + context.mapping.mapping_determinant; if (!matrix_is_finite(context.quadrature.J_inv) || !std::isfinite(context.quadrature.weight)) return MappingStatus::non_finite_result; @@ -846,7 +935,8 @@ namespace mean_field::mapping { ); if (inverse_mesh_jacobian == nullptr) { - transformation.SetIntPoint(&integration_point); + if (const auto *geometry = GetFixedMeshGeometry(transformation, integration_point)) + inverse_mesh_jacobian = &geometry->inverseJacobian; } EvaluateField( diff --git a/libmeanfield/impl/operators/interior_displacement_contractions.hpp b/libmeanfield/impl/operators/interior_displacement_contractions.hpp new file mode 100644 index 0000000..a140103 --- /dev/null +++ b/libmeanfield/impl/operators/interior_displacement_contractions.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include + +namespace { + // U and G are n x 3, component/derivative columns respectively. Keep the + // nine reductions together instead of invoking a general GEMM per point. + // The returned 3 x 3 matrix is column-major: A(c,j) = sum_i U(i,c) G(i,j). + inline void displacement_gradient_3d( + const mfem::DenseMatrix &direction, const mfem::DenseMatrix &gradients, double *a + ) { + const int n = direction.Height(); + const double *u = direction.Data(); + const double *g = gradients.Data(); + for (int j = 0; j < 3; ++j) { + double x = 0.0, y = 0.0, z = 0.0; + const double *gj = g + j * n; + for (int i = 0; i < n; ++i) { + const double v = gj[i]; + x += u[i] * v; + y += u[i + n] * v; + z += u[i + 2 * n] * v; + } + a[3 * j] = x; a[3 * j + 1] = y; a[3 * j + 2] = z; + } + } + + inline void vector_basis_value_3d( + const mfem::DenseMatrix &basis, const mfem::Vector &coefficients, double *value + ) { + const int n = basis.Height(); + const double *b = basis.Data(); + const double *v = coefficients.HostRead(); + double x = 0.0, y = 0.0, z = 0.0; + for (int i = 0; i < n; ++i) { + x += b[i] * v[i]; y += b[i + n] * v[i]; z += b[i + 2 * n] * v[i]; + } + value[0] = x; value[1] = y; value[2] = z; + } + + // Matrix cache layout: nq x 9, with row-major matrix entries in columns. + inline void cached_matrix_vector_3d( + const mfem::DenseMatrix &cache, const int point, const double *v, double *out + ) { + const int nq = cache.Height(); + const double *m = cache.Data() + point; + for (int r = 0; r < 3; ++r) + out[r] = m[(3 * r) * nq] * v[0] + m[(3 * r + 1) * nq] * v[1] + + m[(3 * r + 2) * nq] * v[2]; + } + + inline double displacement_trace_3d( + const mfem::DenseMatrix &direction, const mfem::DenseMatrix &gradients, + const mfem::DenseMatrix &inverseJacobians, const int point + ) { + double a[9]; + displacement_gradient_3d(direction, gradients, a); + const double *k = inverseJacobians.Data() + point; + const int nq = inverseJacobians.Height(); + double trace = 0.0; + for (int j = 0; j < 3; ++j) + for (int c = 0; c < 3; ++c) trace += k[(3 * j + c) * nq] * a[c + 3 * j]; + return trace; + } + + // (du/dxi) v, without constructing du/dxi. v is fixed at this point. + inline void displacement_directional_value_3d( + const mfem::DenseMatrix &direction, const mfem::DenseMatrix &gradients, + const double *v, double *out + ) { + const int n = direction.Height(); + const double *u = direction.Data(); + const double *g = gradients.Data(); + double x = 0.0, y = 0.0, z = 0.0; + for (int i = 0; i < n; ++i) { + const double d = g[i] * v[0] + g[i + n] * v[1] + g[i + 2 * n] * v[2]; + x += u[i] * d; y += u[i + n] * d; z += u[i + 2 * n] * d; + } + out[0] = x; out[1] = y; out[2] = z; + } + + inline void pressure_displacement_action_3d( + const mfem::DenseMatrix &direction, const mfem::DenseMatrix &physicalGradients, + const double pressureWeight, mfem::Vector &action + ) { + // H = du/dx; d(G dx) = (tr(H) G - G H) dx. + double h[9]; + displacement_gradient_3d(direction, physicalGradients, h); + const double trace = h[0] + h[4] + h[8]; + const int n = direction.Height(); + const double *g = physicalGradients.Data(); + double *r = action.HostReadWrite(); + for (int c = 0; c < 3; ++c) { + const double a = pressureWeight * (h[3 * c] - (c == 0 ? trace : 0.0)); + const double b = pressureWeight * (h[3 * c + 1] - (c == 1 ? trace : 0.0)); + const double d = pressureWeight * (h[3 * c + 2] - (c == 2 ? trace : 0.0)); + for (int i = 0; i < n; ++i) r[i + c * n] += a * g[i] + b * g[i + n] + d * g[i + 2 * n]; + } + } + + // Element VDofs are component-blocked, including for a byVDIM global space; + // this is the ordering consumed by ElementDisplacementDataFromElementVDofs. + // For an ordinary interior VALUE field, dx = du and + // d(log det J_element) = tr(J_element^-1 * du/dxi). + inline void add_interior_functional_row( + const mfem::DenseMatrix &gradients, + const mfem::DenseMatrix &values, + const int point, + const mfem::DenseMatrix &inverseElementJacobian, + const double determinantCoefficient, + const double *positionCoefficient, + mfem::Vector &row + ) { + const int n = gradients.Height(); + const int dimension = gradients.Width(); + const int nq = values.Height(); + const double *g = gradients.Data(); + const double *b = values.Data() + point; + double *r = row.HostReadWrite(); + for (int component = 0; component < dimension; ++component) { + for (int i = 0; i < n; ++i) { + double coefficient = positionCoefficient[component] * b[i * nq]; + for (int derivative = 0; derivative < dimension; ++derivative) { + coefficient += determinantCoefficient * inverseElementJacobian(derivative, component) * + g[i + derivative * n]; + } + r[i + component * n] += coefficient; + } + } + } + + inline double apply_interior_functional( + const mfem::DenseMatrix &direction, + const mfem::DenseMatrix &gradients, + const mfem::DenseMatrix &values, + const int point, + const double *inverseElementJacobian, + const double determinantCoefficient, + const double *positionCoefficient + ) { + const int n = direction.Height(); + const int dimension = direction.Width(); + const int nq = values.Height(); + const double *u = direction.Data(); + const double *g = gradients.Data(); + const double *b = values.Data() + point; + double result = 0.0; + for (int component = 0; component < dimension; ++component) { + double value = 0.0; + for (int i = 0; i < n; ++i) value += u[i + component * n] * b[i * nq]; + result += positionCoefficient[component] * value; + for (int derivative = 0; derivative < dimension; ++derivative) { + double gradient = 0.0; + for (int i = 0; i < n; ++i) + gradient += u[i + component * n] * g[i + derivative * n]; + result += determinantCoefficient * inverseElementJacobian[derivative + component * dimension] * + gradient; + } + } + return result; + } +} diff --git a/libmeanfield/impl/operators/prepared_angular_momentum.cpp b/libmeanfield/impl/operators/prepared_angular_momentum.cpp index b2a1429..87765ff 100644 --- a/libmeanfield/impl/operators/prepared_angular_momentum.cpp +++ b/libmeanfield/impl/operators/prepared_angular_momentum.cpp @@ -11,6 +11,7 @@ module; #include #include +#include "interior_displacement_contractions.hpp" module mean_field; @@ -228,6 +229,7 @@ namespace mean_field::operators { const bool updateAngularVelocity = !m_isPrepared || dependencies.rotation != m_preparedDependencies.rotation || angularVelocity != m_angularVelocity; + if (refreshGeometry || refreshDensity) m_jacobianRowsReady = false; m_isPrepared = false; if (synchronize_non_finite_failure( !std::isfinite(angularVelocity), m_fem.mesh->GetComm(), "angular-velocity validity" @@ -466,8 +468,99 @@ namespace mean_field::operators { ++m_residualApplicationCount; } + bool PreparedAngularMomentumOperator::PrepareJacobianRows() const { + VerifyPrepared(); + if (m_jacobianRowsReady) return m_jacobianRowsSupported; + m_jacobianRowsSupported = false; + for (const ElementPAData &data : m_elements) { + const auto &element = *m_fem.displacementFes->GetFE(data.elementId); + if (element.GetMapType() != mfem::FiniteElement::VALUE || + element.GetDerivType() != mfem::FiniteElement::GRAD || + m_domainMapper.IsCompactifiedElement(*m_fem.mesh->GetElementTransformation(data.elementId))) { + m_jacobianRowsReady = true; + return false; + } + } + m_densityJacobianLocal.SetSize(m_fem.densityFes->GetVSize()); + m_displacementJacobianLocal.SetSize(m_fem.displacementFes->GetVSize()); + m_densityJacobianLocal = 0.0; + m_displacementJacobianLocal = 0.0; + mfem::Vector densityRow, displacementRow, weightedRadius; + mapping::VolumeMappingContext mappingContext; + const auto &axis = m_constraint.specification().axis(); + const auto ¢er = m_constraint.specification().center(); + for (const ElementPAData &data : m_elements) { + const int nq = data.integrationRule->GetNPoints(); + weightedRadius.SetSize(nq); + for (int q = 0; q < nq; ++q) + weightedRadius(q) = data.quadratureWeights(q) * data.cylindricalRadiusSquared(q); + densityRow.SetSize(data.densityDofs.Size()); + data.densityBasis->GetValues().MultTranspose(weightedRadius, densityRow); + displacementRow.SetSize(data.displacementDofs.Size()); + displacementRow = 0.0; + const auto basis = m_fem.GetReferenceTables().GetScalarTable( + *m_fem.displacementFes->GetFE(data.elementId), *data.integrationRule); + for (int q = 0; q < nq; ++q) { + data.mappingContexts.Load(q, mappingContext); + double relative[3], positionCoefficient[3], axialPosition = 0.0; + for (int c = 0; c < 3; ++c) { + relative[c] = mappingContext.mapping.physical_position(c) - center[c]; + axialPosition += axis[c] * relative[c]; + } + for (int c = 0; c < 3; ++c) + positionCoefficient[c] = 2.0 * data.density(q) * data.quadratureWeights(q) * + (relative[c] - axis[c] * axialPosition); + add_interior_functional_row( + basis->GetGradients(q), basis->GetValues(), q, mappingContext.quadrature.J_inv, + data.density(q) * weightedRadius(q), positionCoefficient, displacementRow); + } + if (data.densityDofTransformation) data.densityDofTransformation->TransformDual(densityRow); + if (data.displacementDofTransformation) data.displacementDofTransformation->TransformDual(displacementRow); + m_densityJacobianLocal.AddElementVector(data.densityDofs, densityRow); + m_displacementJacobianLocal.AddElementVector(data.displacementDofs, displacementRow); + } + verify_finite_vector(m_densityJacobianLocal, "Prepared angular-momentum density row is non-finite."); + verify_finite_vector(m_displacementJacobianLocal, "Prepared angular-momentum displacement row is non-finite."); + m_jacobianRowsSupported = true; + m_jacobianRowsReady = true; + ++m_jacobianRowPreparationCount; + return true; + } + + std::uint64_t PreparedAngularMomentumOperator::GetJacobianRowPreparationCount() const noexcept { + return m_jacobianRowPreparationCount; + } + + double PreparedAngularMomentumOperator::EvaluateDensityMomentActionLocal(const mfem::Vector &direction) const { + if (!PrepareJacobianRows()) return EvaluateDensityMomentActionLocalReference(direction); + true_to_local(*m_fem.densityFes, direction, m_densityVariationLocal); + return m_densityJacobianLocal * m_densityVariationLocal; + } + + double PreparedAngularMomentumOperator::EvaluateDisplacementMomentActionLocal(const mfem::Vector &direction) const { + if (!PrepareJacobianRows()) return EvaluateDisplacementMomentActionLocalReference(direction); + true_to_local(*m_fem.displacementFes, direction, m_displacementVariationLocal); + return m_displacementJacobianLocal * m_displacementVariationLocal; + } + + void PreparedAngularMomentumOperator::ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, const mfem::Vector &displacementVariation, + const double angularVelocityVariation, mfem::Vector &action + ) const { + VerifyPrepared(); + verify_finite_vector(densityVariation, "Angular-momentum density direction is non-finite."); + verify_finite_vector(displacementVariation, "Angular-momentum displacement direction is non-finite."); + MFEM_VERIFY(std::isfinite(angularVelocityVariation), "Angular-velocity direction is non-finite."); + m_gravityContext.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_gravityContext.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + action.SetSize(1); + action(0) = m_angularVelocity * GlobalSum(EvaluateDensityMomentActionLocalReference(m_densityVariationTrue) + + EvaluateDisplacementMomentActionLocalReference(m_displacementVariationTrue)) + + m_momentOfInertia * angularVelocityVariation; + } + double - PreparedAngularMomentumOperator::EvaluateDensityMomentActionLocal(const mfem::Vector &densityVariation) const { + PreparedAngularMomentumOperator::EvaluateDensityMomentActionLocalReference(const mfem::Vector &densityVariation) const { MFEM_VERIFY( densityVariation.Size() == m_fem.densityFes->GetTrueVSize(), "Angular-momentum density action has the wrong true-vector size." @@ -490,7 +583,7 @@ namespace mean_field::operators { return localAction; } - double PreparedAngularMomentumOperator::EvaluateDisplacementMomentActionLocal( + double PreparedAngularMomentumOperator::EvaluateDisplacementMomentActionLocalReference( const mfem::Vector &displacementVariation ) const { MFEM_VERIFY( diff --git a/libmeanfield/impl/operators/prepared_barotropic_closure.cpp b/libmeanfield/impl/operators/prepared_barotropic_closure.cpp index 534f347..a57e60e 100644 --- a/libmeanfield/impl/operators/prepared_barotropic_closure.cpp +++ b/libmeanfield/impl/operators/prepared_barotropic_closure.cpp @@ -1,5 +1,7 @@ module; +#include "interior_displacement_contractions.hpp" + #include #include #include @@ -730,10 +732,66 @@ namespace mean_field::operators { } void PreparedBarotropicClosureOperator::Mult( + const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + MultImpl(densityVariation, enthalpyVariation, displacementVariation, action, true, true); + } + + void PreparedBarotropicClosureOperator::MultReference( + const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + MultImpl(densityVariation, enthalpyVariation, displacementVariation, action, false, false); + } + + void PreparedBarotropicClosureOperator::MultCachedReference( + const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + MultImpl(densityVariation, enthalpyVariation, displacementVariation, action, true, false); + } + + int PreparedBarotropicClosureOperator::GetTensorElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + count += data.densityBasis->HasTensorEvaluation3D() && + data.enthalpyBasis->HasTensorEvaluation3D() && data.displacementBasis->HasTensorEvaluation3D(); + return count; + } + + void PreparedBarotropicClosureOperator::ApplyEnthalpyJacobianAction( + const mfem::Vector &enthalpyVariation, mfem::Vector &action, const bool useTensorPath + ) const { + VerifyPrepared(); + MFEM_VERIFY(enthalpyVariation.Size() == m_enthalpyMap.reduced_size(), + "The supported enthalpy variation has the wrong size."); + validate_finite_vector(enthalpyVariation, "The enthalpy variation contains a non-finite value."); + m_enthalpyMap.scatter(enthalpyVariation, m_enthalpyVariationTrue); + ApplyThermodynamicActionFull(nullptr, m_enthalpyVariationTrue, m_fullThermodynamicAction, useTensorPath); + action.SetSize(m_densityMap.reduced_size()); + m_densityMap.gather(m_fullThermodynamicAction, action); + } + + void PreparedBarotropicClosureOperator::ApplyDisplacementJacobianAction( + const mfem::Vector &displacementVariation, mfem::Vector &action, const bool useTensorPath + ) const { + VerifyPrepared(); + MFEM_VERIFY(displacementVariation.Size() == m_displacementMap.reduced_size(), + "The supported displacement variation has the wrong size."); + validate_finite_vector(displacementVariation, "The displacement variation contains a non-finite value."); + m_displacementMap.scatter(displacementVariation, m_displacementVariationTrue); + ApplyDisplacementActionFull(m_displacementVariationTrue, m_fullDisplacementAction, true, useTensorPath); + action.SetSize(m_densityMap.reduced_size()); + m_densityMap.gather(m_fullDisplacementAction, action); + } + + void PreparedBarotropicClosureOperator::MultImpl( const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, const mfem::Vector &displacementVariation, - mfem::Vector &action + mfem::Vector &action, + const bool useFastPath, const bool useTensorPath ) const { VerifyPrepared(); @@ -758,9 +816,9 @@ namespace mean_field::operators { m_enthalpyMap.scatter(enthalpyVariation, m_enthalpyVariationTrue); m_displacementMap.scatter(displacementVariation, m_displacementVariationTrue); - ApplyThermodynamicActionFull(m_densityVariationTrue, m_enthalpyVariationTrue, m_fullThermodynamicAction); + ApplyThermodynamicActionFull(&m_densityVariationTrue, m_enthalpyVariationTrue, m_fullThermodynamicAction, useTensorPath); - ApplyDisplacementActionFull(m_displacementVariationTrue, m_fullDisplacementAction); + ApplyDisplacementActionFull(m_displacementVariationTrue, m_fullDisplacementAction, useFastPath, useTensorPath); MFEM_VERIFY( m_fullThermodynamicAction.Size() == m_densityMap.full_size() && @@ -797,12 +855,13 @@ namespace mean_field::operators { } void PreparedBarotropicClosureOperator::ApplyThermodynamicActionFull( - const mfem::Vector &densityVariationTrue, + const mfem::Vector *densityVariationTrue, const mfem::Vector &enthalpyVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, const bool useTensorPath ) const { MFEM_VERIFY( - densityVariationTrue.Size() == m_densityMap.full_size(), "The full density variation has the wrong size." + densityVariationTrue == nullptr || densityVariationTrue->Size() == m_densityMap.full_size(), + "The full density variation has the wrong size." ); MFEM_VERIFY( enthalpyVariationTrue.Size() == m_enthalpyMap.full_size(), "The full enthalpy variation has the wrong size." @@ -811,7 +870,7 @@ namespace mean_field::operators { mfem::Vector densityVariationLocal; mfem::Vector enthalpyVariationLocal; - true_to_local(*m_fem.densityFes, densityVariationTrue, densityVariationLocal); + if (densityVariationTrue) true_to_local(*m_fem.densityFes, *densityVariationTrue, densityVariationLocal); true_to_local(*m_fem.enthalpyFes, enthalpyVariationTrue, enthalpyVariationLocal); mfem::Vector localAction(m_fem.densityFes->GetVSize()); @@ -825,10 +884,10 @@ namespace mean_field::operators { mfem::Vector elementAction; for (const ElementPAData &data : m_elements) { - densityVariationLocal.GetSubVector(data.densityDofs, elementDensityVariation); + if (densityVariationTrue) densityVariationLocal.GetSubVector(data.densityDofs, elementDensityVariation); enthalpyVariationLocal.GetSubVector(data.enthalpyDofs, elementEnthalpyVariation); - if (data.densityDofTransformation != nullptr) { + if (densityVariationTrue && data.densityDofTransformation != nullptr) { data.densityDofTransformation->InvTransformPrimal(elementDensityVariation); } if (data.enthalpyDofTransformation != nullptr) { @@ -839,17 +898,25 @@ namespace mean_field::operators { quadratureEnthalpyVariation.SetSize(data.quadratureWeights.Size()); quadratureAction.SetSize(data.quadratureWeights.Size()); - data.densityBasis->GetValues().Mult(elementDensityVariation, quadratureDensityVariation); - data.enthalpyBasis->GetValues().Mult(elementEnthalpyVariation, quadratureEnthalpyVariation); + if (densityVariationTrue) { + if (useTensorPath && data.densityBasis->HasTensorEvaluation3D()) + data.densityBasis->EvaluateValuesTensor3D(elementDensityVariation, quadratureDensityVariation, m_tensorWorkspace); + else data.densityBasis->GetValues().Mult(elementDensityVariation, quadratureDensityVariation); + } + if (useTensorPath && data.enthalpyBasis->HasTensorEvaluation3D()) + data.enthalpyBasis->EvaluateValuesTensor3D(elementEnthalpyVariation, quadratureEnthalpyVariation, m_tensorWorkspace); + else data.enthalpyBasis->GetValues().Mult(elementEnthalpyVariation, quadratureEnthalpyVariation); for (int quadraturePoint = 0; quadraturePoint < quadratureAction.Size(); ++quadraturePoint) { quadratureAction(quadraturePoint) = - data.quadratureWeights(quadraturePoint) * quadratureDensityVariation(quadraturePoint) - + (densityVariationTrue ? data.quadratureWeights(quadraturePoint) * quadratureDensityVariation(quadraturePoint) : 0.0) - data.weightedEnthalpyDerivative(quadraturePoint) * quadratureEnthalpyVariation(quadraturePoint); } elementAction.SetSize(data.densityDofs.Size()); - data.densityBasis->GetValues().MultTranspose(quadratureAction, elementAction); + if (useTensorPath && data.densityBasis->HasTensorEvaluation3D()) + data.densityBasis->ApplyTransposeTensor3D(quadratureAction, elementAction, m_tensorWorkspace); + else data.densityBasis->GetValues().MultTranspose(quadratureAction, elementAction); if (data.densityDofTransformation != nullptr) { data.densityDofTransformation->TransformDual(elementAction); @@ -863,7 +930,8 @@ namespace mean_field::operators { void PreparedBarotropicClosureOperator::ApplyDisplacementActionFull( const mfem::Vector &displacementVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + const bool useFastPath, const bool useTensorPath ) const { MFEM_VERIFY( displacementVariationTrue.Size() == m_displacementMap.full_size(), @@ -905,8 +973,29 @@ namespace mean_field::operators { m_referenceDisplacementJacobian.SetSize(dimension, dimension); m_quadratureDisplacementAction.SetSize(integrationRule.GetNPoints()); + const bool tensor = useTensorPath && data.displacementBasis->HasTensorEvaluation3D(); + if (tensor) data.displacementBasis->EvaluateTensor3D(directionDofs, m_tensorDisplacement, m_tensorWorkspace); for (int quadraturePoint = 0; quadraturePoint < integrationRule.GetNPoints(); ++quadraturePoint) { + if (tensor) { + double trace = 0.0; + for (int row = 0; row < 3; ++row) for (int column = 0; column < 3; ++column) + trace += data.inverseElementJacobians(quadraturePoint, row * 3 + column) * + m_tensorDisplacement(quadraturePoint, 4 * column + row + 1); + m_quadratureDisplacementAction(quadraturePoint) = data.weightedResidual(quadraturePoint) * trace; + MFEM_VERIFY(std::isfinite(m_quadratureDisplacementAction(quadraturePoint)), + "Prepared barotropic closure displacement action encountered a non-finite quadrature value."); + continue; + } + if (useFastPath && dimension == 3) { + const double trace = displacement_trace_3d( + directionDofs, data.displacementBasis->GetGradients(quadraturePoint), + data.inverseElementJacobians, quadraturePoint); + m_quadratureDisplacementAction(quadraturePoint) = data.weightedResidual(quadraturePoint) * trace; + MFEM_VERIFY(std::isfinite(m_quadratureDisplacementAction(quadraturePoint)), + "Prepared barotropic closure displacement action encountered a non-finite quadrature value."); + continue; + } mfem::MultAtB( directionDofs, data.displacementBasis->GetGradients(quadraturePoint), m_referenceDisplacementJacobian @@ -930,7 +1019,9 @@ namespace mean_field::operators { } m_elementDisplacementAction.SetSize(data.densityDofs.Size()); - data.densityBasis->GetValues().MultTranspose(m_quadratureDisplacementAction, m_elementDisplacementAction); + if (useTensorPath && data.densityBasis->HasTensorEvaluation3D()) + data.densityBasis->ApplyTransposeTensor3D(m_quadratureDisplacementAction, m_elementDisplacementAction, m_tensorWorkspace); + else data.densityBasis->GetValues().MultTranspose(m_quadratureDisplacementAction, m_elementDisplacementAction); if (data.densityDofTransformation != nullptr) { data.densityDofTransformation->TransformDual(m_elementDisplacementAction); diff --git a/libmeanfield/impl/operators/prepared_gravity_displacement_force.cpp b/libmeanfield/impl/operators/prepared_gravity_displacement_force.cpp index 5d4bb37..b0a30ee 100644 --- a/libmeanfield/impl/operators/prepared_gravity_displacement_force.cpp +++ b/libmeanfield/impl/operators/prepared_gravity_displacement_force.cpp @@ -1,5 +1,7 @@ module; +#include "interior_displacement_contractions.hpp" + #include #include #include @@ -184,7 +186,7 @@ namespace mean_field::operators { std::expected< void, kernels::GravityDisplacementForceRejection> - PreparedGravityDisplacementForceOperator::TryPrepareElementData() { + PreparedGravityDisplacementForceOperator::TryPrepareElementData(const bool assembleResidual) { m_elements.clear(); m_elements.reserve(m_fem.mesh->GetNE()); @@ -206,8 +208,14 @@ namespace mean_field::operators { mfem::Vector elementCompactification; mfem::Vector densityShape; mfem::Vector baseGravityReferenceValue; + mfem::Vector baseGravityCellValue; mfem::DenseMatrix gravityGradientShape; + if (assembleResidual) { + m_localAction.SetSize(m_fem.displacementFes->GetVSize()); + m_localAction = 0.0; + } + mfem::Vector residualComponent; const int dimension = m_domainMapper.GetDimension(); for (int elementId = 0; elementId < m_fem.mesh->GetNE(); ++elementId) { @@ -262,6 +270,11 @@ namespace mean_field::operators { data.meshPiolaJacobians.SetSize(data.integrationRule->GetNPoints(), dimension * dimension); } + data.fastApplication = dimension == 3 && m_fem.displacementFes->GetOrdering() == mfem::Ordering::byNODES && + displacementElement.GetMapType() == mfem::FiniteElement::VALUE && + displacementElement.GetDerivType() == mfem::FiniteElement::GRAD && + data.gravityReferenceTable != nullptr; + const mapping::ElementDisplacementData displacementData = mapping::ElementDisplacementDataFromElementVDofs(displacementElement, elementBaseDisplacement); const mapping::ElementCompactificationData compactificationData( @@ -280,6 +293,7 @@ namespace mean_field::operators { densityShape.SetSize(densityElement.GetDof()); gravityGradientShape.SetSize(gravityGradientElement.GetDof(), dimension); baseGravityReferenceValue.SetSize(dimension); + if (assembleResidual) m_quadratureForce.SetSize(quadraturePointCount, dimension); for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { const mfem::IntegrationPoint &integrationPoint = data.integrationRule->IntPoint(quadraturePoint); @@ -297,14 +311,26 @@ namespace mean_field::operators { for (int dof = 0; dof < densityElement.GetDof(); ++dof) { densityShape(dof) = data.densityReferenceTable->GetValues()(quadraturePoint, dof); } - gravityGradientElement.CalcVShape(*transformation, gravityGradientShape); - gravityGradientShape.MultTranspose(elementBaseGravityGradient, baseGravityReferenceValue); + const auto *meshGeometry = m_domainMapper.GetFixedMeshGeometry(*transformation, integrationPoint); + const double meshWeight = meshGeometry ? meshGeometry->weight : transformation->Weight(); + if (assembleResidual && meshGeometry && data.gravityReferenceTable) { + baseGravityCellValue.SetSize(dimension); + data.gravityReferenceTable->GetValues(quadraturePoint).MultTranspose( + elementBaseGravityGradient, baseGravityCellValue); + meshGeometry->jacobian.Mult(baseGravityCellValue, baseGravityReferenceValue); + baseGravityReferenceValue /= meshWeight; + } else { + gravityGradientElement.CalcVShape(*transformation, gravityGradientShape); + gravityGradientShape.MultTranspose(elementBaseGravityGradient, baseGravityReferenceValue); + } data.baseDensityValues(quadraturePoint) = elementBaseDensity * densityShape; - data.referenceWeights(quadraturePoint) = integrationPoint.weight * transformation->Weight(); + data.referenceWeights(quadraturePoint) = integrationPoint.weight * meshWeight; - const mfem::DenseMatrix &inverseMeshJacobian = transformation->InverseJacobian(); - const mfem::DenseMatrix &meshJacobian = transformation->Jacobian(); - const double inverseMeshWeight = 1.0 / transformation->Weight(); + const mfem::DenseMatrix &inverseMeshJacobian = + meshGeometry ? meshGeometry->inverseJacobian : transformation->InverseJacobian(); + const mfem::DenseMatrix &meshJacobian = + meshGeometry ? meshGeometry->jacobian : transformation->Jacobian(); + const double inverseMeshWeight = 1.0 / meshWeight; for (int row = 0; row < dimension; ++row) { data.baseGravityReferenceValues(quadraturePoint, row) = baseGravityReferenceValue(row); for (int column = 0; column < dimension; ++column) { @@ -335,8 +361,40 @@ namespace mean_field::operators { } } } + + if (assembleResidual) { + for (int row = 0; row < dimension; ++row) { + double mapped = 0.0; + for (int column = 0; column < dimension; ++column) + mapped += mappingContext.mapping.mapping_jacobian(row, column) * + baseGravityReferenceValue(column); + m_quadratureForce(quadraturePoint, row) = + data.referenceWeights(quadraturePoint) * data.baseDensityValues(quadraturePoint) * mapped; + } + } + } + + if (assembleResidual) { + const int ndof = displacementElement.GetDof(); + MFEM_VERIFY(data.displacementDofs.Size() == ndof * dimension, + "Prepared force residual has an invalid displacement element size."); + m_elementAction.SetSize(data.displacementDofs.Size()); + residualComponent.SetSize(ndof); + for (int component = 0; component < dimension; ++component) { + data.displacementReferenceTable->GetValues().MultTranspose( + m_quadratureForce.Data() + component * quadraturePointCount, residualComponent.GetData()); + for (int dof = 0; dof < ndof; ++dof) + m_elementAction(vector_dof_index(m_fem.displacementFes->GetOrdering(), + dof, component, ndof, dimension)) = residualComponent(dof); + } + if (!vector_is_finite(m_elementAction)) return std::unexpected(non_finite_rejection()); + if (data.displacementDofTransformation != nullptr) + data.displacementDofTransformation->TransformDual(m_elementAction); + m_localAction.AddElementVector(data.displacementDofs, m_elementAction); } } + if (assembleResidual && !vector_is_finite(m_localAction)) + return std::unexpected(non_finite_rejection()); return {}; } @@ -352,6 +410,17 @@ namespace mean_field::operators { PreparedGravityDisplacementForceReport, kernels::GravityDisplacementForceRejection> PreparedGravityDisplacementForceOperator::TryPrepare() { + return TryPrepareImpl(true); + } + + PreparedGravityDisplacementForceReport PreparedGravityDisplacementForceOperator::PrepareReference() { + auto result = TryPrepareImpl(false); + if (!result.has_value()) throw_rejection(result.error()); + return std::move(result).value(); + } + + std::expected + PreparedGravityDisplacementForceOperator::TryPrepareImpl(const bool useFastPreparation) { MFEM_VERIFY( m_gravityContext.IsPrepared(), "PreparedGravityDisplacementForceOperator requires the shared " "gravity linearization context to be prepared first." @@ -359,20 +428,24 @@ namespace mean_field::operators { const context::gravity_field::GravityFieldRevisions &requestedRevisions = m_gravityContext.GetRevisions(); - if (m_isPrepared && relevant_revisions_match(requestedRevisions, m_preparedRevisions)) { + if (m_isPrepared && m_preparedWithFastResidual == useFastPreparation && + relevant_revisions_match(requestedRevisions, m_preparedRevisions)) { return {}; } m_isPrepared = false; - /* - * Build the reusable element plan before assembling the residual. - * This pass stops at the first invalid mapped quadrature point, so a - * rejected line-search candidate need not traverse the full stateless - * residual kernel. Synchronize before proceeding so every rank takes - * the same branch. - */ - const auto elementResult = TryPrepareElementData(); + const bool inputsFinite = vector_is_finite(m_gravityContext.GetDensityTrue()) && + vector_is_finite(m_gravityContext.GetGravityGradientTrue()) && + vector_is_finite(m_gravityContext.GetGeometryContext().GetDisplacementTrue()); + const auto inputStatus = synchronize_rejection( + inputsFinite ? std::optional{} : std::optional{non_finite_rejection()}, + m_fem.mesh->GetComm()); + if (!inputStatus.has_value()) return std::unexpected(inputStatus.error()); + + // The production path assembles local residual contributions while + // preparing the geometry. Synchronize validity before the true-DOF sum. + const auto elementResult = TryPrepareElementData(useFastPreparation); const std::optional localElementRejection = elementResult.has_value() ? std::optional{} : std::optional{elementResult.error()}; auto synchronizedElement = synchronize_rejection(localElementRejection, m_fem.mesh->GetComm()); @@ -380,12 +453,14 @@ namespace mean_field::operators { return std::unexpected(synchronizedElement.error()); } - auto residualResult = kernels::try_apply_gravity_displacement_force_residual( - m_fem, m_domainMapper, m_gravityContext.GetDensityTrue(), m_gravityContext.GetGravityGradientTrue(), - m_gravityContext.GetGeometryContext().GetDisplacementTrue(), m_actionTrue - ); - if (!residualResult.has_value()) { - return std::unexpected(residualResult.error()); + if (useFastPreparation) { + local_to_true(*m_fem.displacementFes, m_localAction, m_actionTrue); + } else { + auto residualResult = kernels::try_apply_gravity_displacement_force_residual( + m_fem, m_domainMapper, m_gravityContext.GetDensityTrue(), m_gravityContext.GetGravityGradientTrue(), + m_gravityContext.GetGeometryContext().GetDisplacementTrue(), m_actionTrue + ); + if (!residualResult.has_value()) return std::unexpected(residualResult.error()); } m_cachedResidual.SetSize(m_gravityContext.GetDisplacementMap().reduced_size()); m_gravityContext.GetDisplacementMap().gather(m_actionTrue, m_cachedResidual); @@ -397,6 +472,7 @@ namespace mean_field::operators { if (!synchronized.has_value()) { return std::unexpected(synchronized.error()); } + m_preparedWithFastResidual = useFastPreparation; m_preparedRevisions = requestedRevisions; ++m_residualPreparationCount; m_isPrepared = true; @@ -471,7 +547,8 @@ namespace mean_field::operators { const mfem::Vector &densityVariationTrue, const mfem::Vector &displacementVariationTrue, const mfem::Vector &gravityGradientVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + const bool useFastPath, const bool useTensorPath ) const { true_to_local(*m_fem.densityFes, densityVariationTrue, m_densityVariationLocal); true_to_local(*m_fem.gravityFluxFes, gravityGradientVariationTrue, m_gravityGradientVariationLocal); @@ -513,6 +590,60 @@ namespace mean_field::operators { const mfem::DenseMatrix &directionDofs = directionData.GetDofMatrix(); const int scalarDisplacementDofCount = displacementElement.GetDof(); + if (useFastPath && data.fastApplication) { + const int nq = data.integrationRule->GetNPoints(); + m_quadratureDensityVariation.SetSize(nq); + data.densityReferenceTable->GetValues().Mult(m_elementDensityVariation, m_quadratureDensityVariation); + m_quadratureForce.SetSize(nq, 3); + double *force = m_quadratureForce.Data(); + const bool tensorDisplacement = useTensorPath && data.displacementReferenceTable->HasTensorEvaluation3D(); + const bool tensorGravity = useTensorPath && data.gravityReferenceTable->HasTensorEvaluation3D(); + if (tensorDisplacement) + data.displacementReferenceTable->EvaluateTensor3D(directionDofs, m_tensorDisplacement, m_tensorWorkspace); + if (tensorGravity) + data.gravityReferenceTable->EvaluateTensor3D(m_elementGravityGradientVariation, m_tensorGravity, m_tensorWorkspace); + for (int q = 0; q < nq; ++q) { + double base[3], cellVariation[3], meshVariation[3], material[3], mapped[3]; + double referenceDirection[3], geometryVariation[3]; + for (int c = 0; c < 3; ++c) base[c] = data.baseGravityReferenceValues(q, c); + if (tensorGravity) { + for (int c = 0; c < 3; ++c) cellVariation[c] = m_tensorGravity(q, c); + } else vector_basis_value_3d(data.gravityReferenceTable->GetValues(q), + m_elementGravityGradientVariation, cellVariation); + cached_matrix_vector_3d(data.meshPiolaJacobians, q, cellVariation, meshVariation); + const double rho = data.baseDensityValues(q); + const double drho = m_quadratureDensityVariation(q); + for (int c = 0; c < 3; ++c) material[c] = drho * base[c] + rho * meshVariation[c]; + cached_matrix_vector_3d(data.mappingJacobians, q, material, mapped); + cached_matrix_vector_3d(data.inverseMeshJacobians, q, base, referenceDirection); + if (tensorDisplacement) { + for (int c = 0; c < 3; ++c) { + geometryVariation[c] = 0.0; + for (int axis = 0; axis < 3; ++axis) + geometryVariation[c] += m_tensorDisplacement(q, 4*c + 1 + axis) * referenceDirection[axis]; + } + } else displacement_directional_value_3d(directionDofs, + data.displacementReferenceTable->GetGradients(q), referenceDirection, geometryVariation); + for (int c = 0; c < 3; ++c) + force[q + c * nq] = data.referenceWeights(q) * (mapped[c] + rho * geometryVariation[c]); + } + m_elementAction.SetSize(data.displacementDofs.Size()); + double *element = m_elementAction.HostWrite(); + if (tensorDisplacement) { + data.displacementReferenceTable->ApplyTransposeTensor3D( + m_quadratureForce, m_tensorElementDual, m_tensorWorkspace); + for (int i = 0; i < m_elementAction.Size(); ++i) element[i] = m_tensorElementDual.Data()[i]; + } else { + for (int c = 0; c < 3; ++c) + data.displacementReferenceTable->GetValues().MultTranspose( + force + c * nq, element + c * scalarDisplacementDofCount); + } + if (data.displacementDofTransformation) + data.displacementDofTransformation->TransformDual(m_elementAction); + m_localAction.AddElementVector(data.displacementDofs, m_elementAction); + continue; + } + m_densityShape.SetSize(densityElement.GetDof()); m_displacementShape.SetSize(scalarDisplacementDofCount); m_gravityGradientShape.SetSize(gravityGradientElement.GetDof(), dimension); @@ -628,6 +759,52 @@ namespace mean_field::operators { ++m_completeJacobianStatistics.applications; } + void PreparedGravityDisplacementForceOperator::ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + const mfem::Vector &gravityGradientVariation, + mfem::Vector &action + ) const { + VerifyPrepared(); + + m_densityVariationTrue.SetSize(m_gravityContext.GetDensityMap().full_size()); + m_gravityGradientVariationTrue.SetSize(m_gravityContext.GetGravityGradientMap().full_size()); + m_displacementVariationTrue.SetSize(m_gravityContext.GetDisplacementMap().full_size()); + m_gravityContext.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_gravityContext.GetGravityGradientMap().scatter(gravityGradientVariation, m_gravityGradientVariationTrue); + m_gravityContext.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + + ApplyPreparedCompleteJacobianActionTrue( + m_densityVariationTrue, m_displacementVariationTrue, m_gravityGradientVariationTrue, m_actionTrue, false + ); + action.SetSize(m_gravityContext.GetDisplacementMap().reduced_size()); + m_gravityContext.GetDisplacementMap().gather(m_actionTrue, action); + + } + + void PreparedGravityDisplacementForceOperator::ApplyCompleteJacobianActionCachedReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + const mfem::Vector &gravityGradientVariation, + mfem::Vector &action + ) const { + VerifyPrepared(); + + m_densityVariationTrue.SetSize(m_gravityContext.GetDensityMap().full_size()); + m_gravityGradientVariationTrue.SetSize(m_gravityContext.GetGravityGradientMap().full_size()); + m_displacementVariationTrue.SetSize(m_gravityContext.GetDisplacementMap().full_size()); + m_gravityContext.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_gravityContext.GetGravityGradientMap().scatter(gravityGradientVariation, m_gravityGradientVariationTrue); + m_gravityContext.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + + ApplyPreparedCompleteJacobianActionTrue( + m_densityVariationTrue, m_displacementVariationTrue, m_gravityGradientVariationTrue, m_actionTrue, true, false + ); + action.SetSize(m_gravityContext.GetDisplacementMap().reduced_size()); + m_gravityContext.GetDisplacementMap().gather(m_actionTrue, action); + + } + bool PreparedGravityDisplacementForceOperator::IsPrepared() const noexcept { if (!m_isPrepared || !m_gravityContext.IsPrepared()) { return false; diff --git a/libmeanfield/impl/operators/prepared_gravity_source.cpp b/libmeanfield/impl/operators/prepared_gravity_source.cpp index 20f5afb..167c56f 100644 --- a/libmeanfield/impl/operators/prepared_gravity_source.cpp +++ b/libmeanfield/impl/operators/prepared_gravity_source.cpp @@ -345,6 +345,11 @@ namespace mean_field::operators { } } + void PreparedMappedGravitySourceOperator::PrepareReference(const mfem::Vector &displacement) { + auto result = TryPrepareImpl(displacement, PreparationMode::linearization, false); + if (!result.has_value()) throwGravitySourcePreparationRejection(result.error()); + } + void PreparedMappedGravitySourceOperator::PreparePrimal(const mfem::Vector &displacement) { MEAN_FIELD_PROFILE_SCOPE_WARMUP("PreparedMappedGravitySourceOperator::Prepare primal", 0); auto result = TryPrepareImpl(displacement, PreparationMode::primal); @@ -366,7 +371,7 @@ namespace mean_field::operators { GravitySourcePreparationResult PreparedMappedGravitySourceOperator::TryPrepareImpl( const mfem::Vector &displacement, - const PreparationMode mode + const PreparationMode mode, const bool useCachedMeshWeight ) { MFEM_VERIFY( displacement.Size() == m_displacement_map.reduced_size(), @@ -513,7 +518,12 @@ namespace mean_field::operators { transformation.SetIntPoint(&integration_point); - const double quadrature_value = integration_point.weight * transformation.Weight() * coefficient_value; + // EvaluateVolume already cached the computational mesh geometry. + // Resetting IntPoint above invalidates MFEM's local weight cache. + const auto *fixed = useCachedMeshWeight ? + m_domain_mapper.GetFixedMeshGeometry(transformation, integration_point) : nullptr; + const double meshWeight = fixed ? fixed->weight : transformation.Weight(); + const double quadrature_value = integration_point.weight * meshWeight * coefficient_value; if (!std::isfinite(quadrature_value) || quadrature_value <= 0.0) { localNonFiniteQuadrature = true; @@ -608,9 +618,27 @@ namespace mean_field::operators { } void PreparedMappedGravitySourceOperator::MultDisplacementVariationTrue( - const mfem::Vector &densityTrue, - const mfem::Vector &displacementVariationTrue, + const mfem::Vector &densityTrue, const mfem::Vector &displacementVariationTrue, mfem::Vector &actionVariationTrue + ) const { + MultDisplacementVariationTrueImpl(densityTrue, displacementVariationTrue, actionVariationTrue, true); + } + void PreparedMappedGravitySourceOperator::MultDisplacementVariationTrueReference( + const mfem::Vector &densityTrue, const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue + ) const { + MultDisplacementVariationTrueImpl(densityTrue, displacementVariationTrue, actionVariationTrue, false); + } + int PreparedMappedGravitySourceOperator::GetTensorVariationElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + count += data.displacement_reference && data.displacement_reference->HasTensorEvaluation3D(); + return count; + } + + void PreparedMappedGravitySourceOperator::MultDisplacementVariationTrueImpl( + const mfem::Vector &densityTrue, const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue, const bool useTensorPath ) const { MFEM_VERIFY( m_is_prepared, @@ -667,10 +695,15 @@ namespace mean_field::operators { m_reference_displacement_jacobian.SetSize(dimension, dimension); m_quadrature_variation_action.SetSize(data.integration_rule->GetNPoints()); - data.GetDensityBasis().Mult(m_element_density, m_quadrature_variation_action); + if (useTensorPath && data.density_reference && data.density_reference->HasTensorEvaluation3D()) + data.density_reference->EvaluateValuesTensor3D(m_element_density, m_quadrature_variation_action, m_tensorWorkspace); + else data.GetDensityBasis().Mult(m_element_density, m_quadrature_variation_action); + const bool tensor = useTensorPath && data.displacement_reference && + data.displacement_reference->HasTensorEvaluation3D(); + if (tensor) data.displacement_reference->EvaluateTensor3D(direction_dofs, m_tensorSamples, m_tensorWorkspace); for (int quadrature_point = 0; quadrature_point < data.integration_rule->GetNPoints(); ++quadrature_point) { - mfem::MultAtB( + if (!tensor) mfem::MultAtB( direction_dofs, data.displacement_reference->GetGradients(quadrature_point), m_reference_displacement_jacobian ); @@ -680,7 +713,8 @@ namespace mean_field::operators { for (int column = 0; column < dimension; ++column) { logarithmic_jacobian_variation += data.inverse_element_jacobians(quadrature_point, row * dimension + column) * - m_reference_displacement_jacobian(column, row); + (tensor ? m_tensorSamples(quadrature_point, 4*column+row+1) : + m_reference_displacement_jacobian(column, row)); } } @@ -693,7 +727,9 @@ namespace mean_field::operators { } m_element_variation_action.SetSize(data.potential_dofs.Size()); - data.GetPotentialBasis().MultTranspose(m_quadrature_variation_action, m_element_variation_action); + if (useTensorPath && data.potential_reference && data.potential_reference->HasTensorEvaluation3D()) + data.potential_reference->ApplyTransposeTensor3D(m_quadrature_variation_action, m_element_variation_action, m_tensorWorkspace); + else data.GetPotentialBasis().MultTranspose(m_quadrature_variation_action, m_element_variation_action); if (data.potential_dof_transformation != nullptr) { data.potential_dof_transformation->TransformDual(m_element_variation_action); diff --git a/libmeanfield/impl/operators/prepared_hdiv_mass.cpp b/libmeanfield/impl/operators/prepared_hdiv_mass.cpp index 100f747..2d82160 100644 --- a/libmeanfield/impl/operators/prepared_hdiv_mass.cpp +++ b/libmeanfield/impl/operators/prepared_hdiv_mass.cpp @@ -9,6 +9,7 @@ module; #include #include #include +#include #include @@ -16,6 +17,7 @@ module mean_field; import :fem.reference_tables; import :operators.prepared_hdiv_mass; +import :mapping.kelvin; namespace { using DomainSchema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema; @@ -304,6 +306,124 @@ namespace { } } + // Keep MFEM's PA assembly, restrictions, signed RT ordering, and diagonal. + // Only the CPU element contraction changes. Scratch is sized to the actual + // rule, and the two domain forms visit only their marked elements. + class TensorHDivMassIntegrator final : public mfem::VectorFEMassIntegrator { + public: + TensorHDivMassIntegrator(mfem::MatrixCoefficient &coefficient, const mfem::Array &marker) + : mfem::VectorFEMassIntegrator(coefficient), m_marker(marker) {} + + void AssemblePA(const mfem::FiniteElementSpace &space) override { + mfem::VectorFEMassIntegrator::AssemblePA(space); + m_activeElements.clear(); + m_supported = !mfem::Device::Allows(~mfem::Backend::CPU) && + dim == 3 && trial_fetype == mfem::FiniteElement::DIV && + test_fetype == trial_fetype && dofs1D == dofs1Dtest; + if (!m_supported) return; + for (int e = 0; e < space.GetNE(); ++e) { + const int attribute = space.GetMesh()->GetAttribute(e); + if (attribute > 0 && attribute <= m_marker.Size() && m_marker[attribute - 1]) + m_activeElements.push_back(e); + } + } + + [[nodiscard]] int FastElementCount() const noexcept { + return m_supported ? static_cast(m_activeElements.size()) : 0; + } + [[nodiscard]] bool UseFastApplication() const noexcept { return m_useFast; } + void SetFastApplication(bool value) const noexcept { m_useFast = value; } + + void AddMultPA(const mfem::Vector &x, mfem::Vector &y) const override { + if (!m_useFast || !m_supported) { + mfem::VectorFEMassIntegrator::AddMultPA(x, y); + return; + } + // The current RT2 / nine-point-per-axis rule is specialized; other + // supported CPU orders use exactly the same algorithm at runtime. + if (dofs1D == 4 && quad1D == 9) Apply<4, 9>(x, y); + else Apply<0, 0>(x, y); + } + + private: + template + void Apply(const mfem::Vector &xVector, mfem::Vector &yVector) const { + const int d = D == 0 ? dofs1D : D, q = Q == 0 ? quad1D : Q; + const int nq = q*q*q, nd = 3*d*(d-1)*(d-1), entries = symmetric ? 6 : 9; + MFEM_VERIFY(xVector.Size() == ne*nd && yVector.Size() == ne*nd, + "H(div) PA element vectors have incompatible sizes."); + m_workspace.SetSize(3*nq + q*d*d + q*q*d); + double *values = m_workspace.HostWrite(), *sx = values + 3*nq, *sxy = sx + q*d*d; + const double *x = xVector.HostRead(), *op = pa_data.HostRead(); + double *y = yVector.HostReadWrite(); + const double *bc = mapsC->B.HostRead(), *bo = mapsO->B.HostRead(); + for (const int e : m_activeElements) { + int offset = e*nd; + for (int c = 0; c < 3; ++c) { + const int dx = c == 0 ? d : d-1, dy = c == 1 ? d : d-1, dz = c == 2 ? d : d-1; + const double *bx = c == 0 ? bc : bo, *by = c == 1 ? bc : bo, *bz = c == 2 ? bc : bo; + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int a = 0; a < q; ++a) { + double v = 0.0; + for (int i = 0; i < dx; ++i) v += bx[a + q*i]*x[offset + i + dx*(j + dy*k)]; + sx[a + q*(j + dy*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int b = 0; b < q; ++b) for (int a = 0; a < q; ++a) { + double v = 0.0; + for (int j = 0; j < dy; ++j) v += by[b + q*j]*sx[a + q*(j + dy*k)]; + sxy[a + q*(b + q*k)] = v; + } + for (int z = 0; z < q; ++z) for (int b = 0; b < q; ++b) for (int a = 0; a < q; ++a) { + double v = 0.0; + for (int k = 0; k < dz; ++k) v += bz[z + q*k]*sxy[a + q*(b + q*k)]; + values[a + q*(b + q*z) + nq*c] = v; + } + offset += dx*dy*dz; + } + const double *metric = op + e*nq*entries; + for (int p = 0; p < nq; ++p) { + const double u = values[p], v = values[p+nq], w = values[p+2*nq]; + const double a00 = metric[p], a01 = metric[p+nq], a02 = metric[p+2*nq]; + const double a10 = symmetric ? a01 : metric[p+3*nq]; + const double a11 = metric[p+(symmetric ? 3 : 4)*nq]; + const double a12 = metric[p+(symmetric ? 4 : 5)*nq]; + const double a20 = symmetric ? a02 : metric[p+6*nq]; + const double a21 = symmetric ? a12 : metric[p+7*nq]; + const double a22 = metric[p+(symmetric ? 5 : 8)*nq]; + values[p] = a00*u + a01*v + a02*w; + values[p+nq] = a10*u + a11*v + a12*w; + values[p+2*nq] = a20*u + a21*v + a22*w; + } + offset = e*nd; + for (int c = 0; c < 3; ++c) { + const int dx = c == 0 ? d : d-1, dy = c == 1 ? d : d-1, dz = c == 2 ? d : d-1; + const double *bx = c == 0 ? bc : bo, *by = c == 1 ? bc : bo, *bz = c == 2 ? bc : bo; + for (int k = 0; k < dz; ++k) for (int b = 0; b < q; ++b) for (int a = 0; a < q; ++a) { + double v = 0.0; + for (int z = 0; z < q; ++z) v += bz[z + q*k]*values[a + q*(b + q*z) + nq*c]; + sxy[a + q*(b + q*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int a = 0; a < q; ++a) { + double v = 0.0; + for (int b = 0; b < q; ++b) v += by[b + q*j]*sxy[a + q*(b + q*k)]; + sx[a + q*(j + dy*k)] = v; + } + for (int k = 0; k < dz; ++k) for (int j = 0; j < dy; ++j) for (int i = 0; i < dx; ++i) { + double v = 0.0; + for (int a = 0; a < q; ++a) v += bx[a + q*i]*sx[a + q*(j + dy*k)]; + y[offset + i + dx*(j + dy*k)] += v; + } + offset += dx*dy*dz; + } + } + } + + mfem::Array m_marker; + std::vector m_activeElements; + mutable mfem::Vector m_workspace; + bool m_supported{false}; + mutable bool m_useFast{true}; + }; + class FrozenMappedHDivMassCoefficient final : public mfem::MatrixCoefficient { public: FrozenMappedHDivMassCoefficient( @@ -520,6 +640,11 @@ namespace mean_field::operators { mfem::Vector elementDisplacement; mfem::Vector elementCompactification; mapping::VolumeMappingContext mappingContext; + mfem::DenseMatrix inverseMeshJacobian(3), compactificationGradients; + mfem::Vector coordinateGradient(3), scaleGradient(3); + const auto *kelvin = dynamic_cast( + &m_domain_mapper.GetExteriorMap() + ); for (int elementId = 0; elementId < m_fem.mesh->GetNE(); ++elementId) { m_variationElements.emplace_back(); @@ -566,6 +691,24 @@ namespace mean_field::operators { data.integrationRule->GetNPoints(), frozen_mapping_width(m_domain_mapper.GetDimension()) ); + const bool compactified = m_domain_mapper.IsCompactifiedElement(*transformation); + std::shared_ptr compactificationTable; + if (dimension == 3 && data.gravityReferenceTable != nullptr && + displacementElement.GetMapType() == mfem::FiniteElement::VALUE && + displacementElement.GetDerivType() == mfem::FiniteElement::GRAD && + displacementElement.GetDim() == 3 && + (!compactified || (kelvin != nullptr && + compactificationElement.GetDerivType() == mfem::FiniteElement::GRAD))) { + data.displacementReferenceTable = + m_fem.GetReferenceTables().GetScalarTable(displacementElement, *data.integrationRule); + data.tangentFactors.SetSize(12, data.integrationRule->GetNPoints()); + if (compactified) { + compactificationTable = m_fem.GetReferenceTables().GetScalarTable( + compactificationElement, *data.integrationRule + ); + } + } + const mapping::ElementDisplacementData displacementData = mapping::ElementDisplacementDataFromElementVDofs(displacementElement, data.baseDisplacement); const mapping::ElementCompactificationData compactificationData( @@ -584,11 +727,42 @@ namespace mean_field::operators { return status; } freeze_mapping_context(mappingContext, quadraturePoint, data.frozenMappingData); + if (data.displacementReferenceTable != nullptr) { + // Use the same fixed-mesh inverse as EvaluateVolumeVariation. + mfem::Mult(mappingContext.quadrature.J_inv, mappingContext.mapping.mapping_jacobian, + inverseMeshJacobian); + double scale = 1.0; + scaleGradient = 0.0; + if (compactified) { + const auto &values = compactificationTable->GetValues(); + double coordinate = 0.0; + for (int dof = 0; dof < data.compactification.Size(); ++dof) + coordinate += values(quadraturePoint, dof) * data.compactification(dof); + compactificationGradients.SetSize(compactificationElement.GetDof(), dimension); + mfem::Mult(compactificationTable->GetGradients(quadraturePoint), inverseMeshJacobian, + compactificationGradients); + compactificationGradients.MultTranspose(data.compactification, coordinateGradient); + const auto factorStatus = kelvin->EvaluateJacobianVariationFactors( + coordinate, coordinateGradient, scale, scaleGradient + ); + if (factorStatus != mapping::MappingStatus::valid) return factorStatus; + } + double *factors = data.tangentFactors.GetColumn(quadraturePoint); + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + const double value = scale * inverseMeshJacobian(row, column); + if (!std::isfinite(value)) return mapping::MappingStatus::non_finite_result; + factors[3 * row + column] = value; + } + factors[9 + row] = scaleGradient(row); + } + } if (data.gravityReferenceTable != nullptr) { // CalcVShape_RT = reference_shape * J_mesh^T / Weight. // Cache only this small factor, never the mapped basis. - const double meshWeight = transformation->Weight(); - const mfem::DenseMatrix &meshJacobian = transformation->Jacobian(); + const auto *meshGeometry = m_domain_mapper.GetFixedMeshGeometry(*transformation, integrationPoint); + const double meshWeight = meshGeometry ? meshGeometry->weight : transformation->Weight(); + const mfem::DenseMatrix &meshJacobian = meshGeometry ? meshGeometry->jacobian : transformation->Jacobian(); const double inverseMeshWeight = 1.0 / meshWeight; data.referenceWeights(quadraturePoint) = integrationPoint.weight * meshWeight; for (int row = 0; row < dimension; ++row) { @@ -693,8 +867,11 @@ namespace mean_field::operators { m_stellar_mass_form->SetAssemblyLevel(mfem::AssemblyLevel::PARTIAL); m_vacuum_mass_form->SetAssemblyLevel(mfem::AssemblyLevel::PARTIAL); - auto stellar_integrator = std::make_unique(*m_stellar_mass_coefficient); - auto vacuum_integrator = std::make_unique(*m_vacuum_mass_coefficient); + auto stellar_integrator = std::make_unique(*m_stellar_mass_coefficient, m_stellar_marker); + auto vacuum_integrator = std::make_unique(*m_vacuum_mass_coefficient, m_vacuum_marker); + auto *stellarIntegratorView = stellar_integrator.get(); + auto *vacuumIntegratorView = vacuum_integrator.get(); + m_fastMassElements = 0; m_fem.quadratureFactory->configure_gravity_hdiv_mass( *stellar_integrator, quadrature::QuadratureRole::discretization, stellar_element, stellar_transformation, @@ -731,6 +908,7 @@ namespace mean_field::operators { return preparationResult; } + m_fastMassElements = stellarIntegratorView->FastElementCount() + vacuumIntegratorView->FastElementCount(); m_has_variation_data = mode == PreparationMode::linearization; m_is_prepared = true; ++m_preparation_count; @@ -791,10 +969,71 @@ namespace mean_field::operators { } } + void PreparedMappedHDivMassOperator::MultReference( + const mfem::Vector &gravityGradient, mfem::Vector &action + ) const { + MFEM_VERIFY(m_is_prepared && m_stellar_mass_form && m_vacuum_mass_form, + "The H(div) mass reference requires a prepared operator."); + const auto *stellar = dynamic_cast((*m_stellar_mass_form->GetDBFI())[0]); + const auto *vacuum = dynamic_cast((*m_vacuum_mass_form->GetDBFI())[0]); + MFEM_VERIFY(stellar && vacuum, "The H(div) mass forms have unexpected integrators."); + struct RestoreMode { + const TensorHDivMassIntegrator *integrator; + bool previous; + ~RestoreMode() { integrator->SetFastApplication(previous); } + } restoreStellar{stellar, stellar->UseFastApplication()}, restoreVacuum{vacuum, vacuum->UseFastApplication()}; + stellar->SetFastApplication(false); + vacuum->SetFastApplication(false); + Mult(gravityGradient, action); + } + void PreparedMappedHDivMassOperator::MultDisplacementVariationTrue( const mfem::Vector &gravityGradientTrue, const mfem::Vector &displacementVariationTrue, mfem::Vector &actionVariationTrue + ) const { + MEAN_FIELD_PROFILE_SCOPE("PreparedMappedHDivMassOperator::MultDisplacementVariationTrue"); + MultDisplacementVariationTrueImpl(gravityGradientTrue, displacementVariationTrue, actionVariationTrue, true, true); + } + + void PreparedMappedHDivMassOperator::MultDisplacementVariationTrueReference( + const mfem::Vector &gravityGradientTrue, + const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue + ) const { + MEAN_FIELD_PROFILE_SCOPE("PreparedMappedHDivMassOperator::MultDisplacementVariationTrueReference"); + MultDisplacementVariationTrueImpl(gravityGradientTrue, displacementVariationTrue, actionVariationTrue, false, false); + } + + void PreparedMappedHDivMassOperator::MultDisplacementVariationTrueCachedReference( + const mfem::Vector &gravityGradientTrue, const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue + ) const { + MultDisplacementVariationTrueImpl(gravityGradientTrue, displacementVariationTrue, actionVariationTrue, true, false); + } + + int PreparedMappedHDivMassOperator::GetTensorVariationElementCount() const noexcept { + if (!m_is_prepared || !m_has_variation_data) return 0; + int count = 0; + for (const auto &data : m_variationElements) + if (data.displacementReferenceTable && data.displacementReferenceTable->HasTensorEvaluation3D() && + data.gravityReferenceTable && data.gravityReferenceTable->HasTensorEvaluation3D()) ++count; + return count; + } + + int PreparedMappedHDivMassOperator::GetFastVariationElementCount() const noexcept { + if (!m_is_prepared || !m_has_variation_data) return 0; + int count = 0; + for (const auto &data : m_variationElements) + if (data.displacementReferenceTable != nullptr) ++count; + return count; + } + + void PreparedMappedHDivMassOperator::MultDisplacementVariationTrueImpl( + const mfem::Vector &gravityGradientTrue, + const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue, + const bool useFastPath, const bool useTensorPath ) const { MFEM_VERIFY( m_is_prepared, "PreparedMappedHDivMassOperator must be prepared before applying a displacement variation." @@ -837,6 +1076,11 @@ namespace mean_field::operators { data.displacementDofTransformation->InvTransformPrimal(m_elementDisplacementVariation); } + if (useFastPath && data.displacementReferenceTable != nullptr) { + AddFastElementVariation(data, useTensorPath); + continue; + } + const mfem::FiniteElement &gravityGradientElement = *m_fem.gravityFluxFes->GetFE(data.elementId); const mfem::FiniteElement &displacementElement = *m_fem.displacementFes->GetFE(data.elementId); const mfem::FiniteElement &compactificationElement = *m_fem.compactificationFes->GetFE(data.elementId); @@ -924,6 +1168,111 @@ namespace mean_field::operators { local_to_true(*m_fem.gravityFluxFes, m_localVariationAction, actionVariationTrue); } + void PreparedMappedHDivMassOperator::AddFastElementVariation( + const ElementVariationData &data, const bool useTensorPath + ) const { + // GetElementVDofs always returns component blocks, independently of the + // global FES ordering. Signed DOFs and primal transforms were applied by + // the caller; preserve the dual transform and scatter below. + const int dofs = data.displacementReferenceTable->GetDofCount(); + MFEM_VERIFY(m_elementDisplacementVariation.Size() == 3 * dofs, + "Prepared H(div) tangent has an incompatible displacement layout."); + const auto &values = data.displacementReferenceTable->GetValues(); + const bool compactified = data.frozenMappingData(0, 47) != 0.0; + m_elementVariationAction.SetSize(m_elementGravityGradient.Size()); + m_elementVariationAction = 0.0; + m_gravityReferenceCellValue.SetSize(3); + m_referenceCellDual.SetSize(3); + const bool tensorDisplacement = useTensorPath && data.displacementReferenceTable->HasTensorEvaluation3D(); + const bool tensorGravity = useTensorPath && data.gravityReferenceTable->HasTensorEvaluation3D(); + if (tensorDisplacement) { + const mfem::DenseMatrix direction(m_elementDisplacementVariation.GetData(), dofs, 3); + data.displacementReferenceTable->EvaluateTensor3D(direction, m_tensorDisplacement, m_tensorWorkspace); + } + if (tensorGravity) { + data.gravityReferenceTable->EvaluateTensor3D(m_elementGravityGradient, m_tensorGravity, m_tensorWorkspace); + m_tensorDual.SetSize(data.integrationRule->GetNPoints(), 3); + } + + for (int q = 0; q < data.integrationRule->GetNPoints(); ++q) { + const auto &gradients = data.displacementReferenceTable->GetGradients(q); + const double *factors = data.tangentFactors.GetColumn(q); + double deltaJ[3][3]{}; + for (int component = 0; component < 3; ++component) { + const double *coefficients = m_elementDisplacementVariation.GetData() + component * dofs; + double value = 0.0; + double referenceGradient[3]{}; + if (tensorDisplacement) { + value = compactified ? m_tensorDisplacement(q, 4*component) : 0.0; + for (int axis = 0; axis < 3; ++axis) + referenceGradient[axis] = m_tensorDisplacement(q, 4*component + 1 + axis); + } else { + for (int dof = 0; dof < dofs; ++dof) { + const double coefficient = coefficients[dof]; + if (compactified) value += coefficient * values(q, dof); + for (int axis = 0; axis < 3; ++axis) + referenceGradient[axis] += coefficient * gradients(dof, axis); + } + } + for (int column = 0; column < 3; ++column) { + deltaJ[component][column] = value * factors[9 + column]; + for (int axis = 0; axis < 3; ++axis) + deltaJ[component][column] += referenceGradient[axis] * factors[3 * axis + column]; + MFEM_VERIFY(std::isfinite(deltaJ[component][column]), + "Prepared H(div) tangent produced a non-finite mapping derivative."); + } + } + + const auto &referenceShape = data.gravityReferenceTable->GetValues(q); + if (tensorGravity) { + for (int c = 0; c < 3; ++c) m_gravityReferenceCellValue(c) = m_tensorGravity(q, c); + } else referenceShape.MultTranspose(m_elementGravityGradient, m_gravityReferenceCellValue); + double piola[3][3], jacobian[3][3]; + double gravity[3]{}, mappedGravity[3]{}, mappedVariation[3]{}; + double trace = 0.0; + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + piola[row][column] = data.meshPiolaJacobians(q, 3 * row + column); + jacobian[row][column] = data.frozenMappingData(q, 18 + 3 * row + column); + gravity[row] += piola[row][column] * m_gravityReferenceCellValue(column); + trace += data.frozenMappingData(q, 27 + 3 * row + column) * deltaJ[column][row]; + } + } + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + mappedGravity[row] += jacobian[row][column] * gravity[column]; + mappedVariation[row] += deltaJ[row][column] * gravity[column]; + } + } + // M = J^T J / det(J). Apply delta M directly to the three-vector: + // [delta J^T (Jg) + J^T(delta J g - tr(J^-1 delta J) Jg)] / det(J). + const double inverseDeterminant = 1.0 / data.frozenMappingData(q, 45); + double massAction[3]{}; + for (int column = 0; column < 3; ++column) { + for (int row = 0; row < 3; ++row) { + massAction[column] += deltaJ[row][column] * mappedGravity[row] + + jacobian[row][column] * (mappedVariation[row] - trace * mappedGravity[row]); + } + massAction[column] *= inverseDeterminant; + MFEM_VERIFY(std::isfinite(massAction[column]), + "Prepared H(div) tangent produced a non-finite mass action."); + } + for (int column = 0; column < 3; ++column) { + double dual = 0.0; + for (int row = 0; row < 3; ++row) dual += piola[row][column] * massAction[row]; + m_referenceCellDual(column) = dual; + } + if (tensorGravity) { + for (int c = 0; c < 3; ++c) m_tensorDual(q, c) = data.referenceWeights(q) * m_referenceCellDual(c); + } else referenceShape.AddMult(m_referenceCellDual, m_elementVariationAction, data.referenceWeights(q)); + } + if (tensorGravity) + data.gravityReferenceTable->ApplyTransposeTensor3D(m_tensorDual, m_elementVariationAction, m_tensorWorkspace); + if (data.gravityGradientDofTransformation != nullptr) + data.gravityGradientDofTransformation->TransformDual(m_elementVariationAction); + m_localVariationAction.AddElementVector(data.gravityGradientDofs, m_elementVariationAction); + } + void PreparedMappedHDivMassOperator::AssembleDiagonal(mfem::Vector &diagonal) const { mfem::Vector true_diagonal; AssembleTrueDiagonal(true_diagonal); diff --git a/libmeanfield/impl/operators/prepared_hydrostatic_equilibrium.cpp b/libmeanfield/impl/operators/prepared_hydrostatic_equilibrium.cpp index 89b669b..620c71d 100644 --- a/libmeanfield/impl/operators/prepared_hydrostatic_equilibrium.cpp +++ b/libmeanfield/impl/operators/prepared_hydrostatic_equilibrium.cpp @@ -11,6 +11,7 @@ module; #include #include +#include "interior_displacement_contractions.hpp" module mean_field; @@ -384,7 +385,30 @@ namespace mean_field::operators { const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, const physics::RigidRotation &rotation ) { - const bool wasPrepared = m_isPrepared; + return TryPrepareImpl(state, dependencies, rotation, true); + } + HydrostaticEquilibriumPreparationResult PreparedHydrostaticEquilibriumOperator::TryPrepareReference( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation + ) { + return TryPrepareImpl(state, dependencies, rotation, false); + } + PreparedHydrostaticEquilibriumReport PreparedHydrostaticEquilibriumOperator::PrepareReference( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation + ) { + auto result = TryPrepareReference(state, dependencies, rotation); + if (!result.has_value()) throwHydrostaticEquilibriumPreparationRejection(result.error()); + return std::move(result).value(); + } + HydrostaticEquilibriumPreparationResult PreparedHydrostaticEquilibriumOperator::TryPrepareImpl( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation, const bool useTensorPreparation + ) { + const bool wasPrepared = m_isPrepared && m_preparedWithTensorPreparation == useTensorPreparation; const bool rotationObjectChanged = !wasPrepared || !m_context.IsPrepared() || dependencies.rotation != m_context.GetDependencies().rotation; @@ -409,7 +433,7 @@ namespace mean_field::operators { } if (report.contextReport.preparedGeometryState || !wasPrepared) { - const auto mappingFailure = synchronize_mapping_failure(PrepareGeometry(), m_fem.mesh->GetComm()); + const auto mappingFailure = synchronize_mapping_failure(PrepareGeometry(useTensorPreparation), m_fem.mesh->GetComm()); if (mappingFailure.has_value()) { const auto reason = *mappingFailure == mapping::MappingStatus::non_positive_determinant ? HydrostaticEquilibriumPreparationRejectionReason::inverted_geometry @@ -419,7 +443,7 @@ namespace mean_field::operators { ); } - if (synchronize_non_finite_failure(PrepareAlgebraicJacobianBlocks(), m_fem.mesh->GetComm())) { + if (synchronize_non_finite_failure(PrepareAlgebraicJacobianBlocks(useTensorPreparation), m_fem.mesh->GetComm())) { return std::unexpected( HydrostaticEquilibriumPreparationRejection{ .reason = HydrostaticEquilibriumPreparationRejectionReason::non_finite_geometry, @@ -441,7 +465,7 @@ namespace mean_field::operators { } if (report.contextReport.preparedBaseState || !wasPrepared) { - if (synchronize_non_finite_failure(PrepareBaseState(), m_fem.mesh->GetComm())) { + if (synchronize_non_finite_failure(PrepareBaseState(useTensorPreparation), m_fem.mesh->GetComm())) { return std::unexpected( HydrostaticEquilibriumPreparationRejection{ .reason = HydrostaticEquilibriumPreparationRejectionReason::non_finite_residual @@ -480,6 +504,7 @@ namespace mean_field::operators { ++m_residualPreparationCount; } + m_preparedWithTensorPreparation = useTensorPreparation; m_isPrepared = true; return report; } @@ -529,10 +554,18 @@ namespace mean_field::operators { data.enthalpyReferenceTable = referenceTables.GetScalarTable(enthalpyElement, *data.integrationRule); data.gravityPotentialReferenceTable = referenceTables.GetScalarTable(gravityPotentialElement, *data.integrationRule); + const auto &displacementElement = *m_fem.displacementFes->GetFE(elementId); + if (m_fem.mesh->Dimension() == 3 && + displacementElement.GetMapType() == mfem::FiniteElement::VALUE && + displacementElement.GetDerivType() == mfem::FiniteElement::GRAD && + !m_domainMapper.IsCompactifiedElement(*transformation)) { + data.displacementReferenceTable = + referenceTables.GetScalarTable(displacementElement, *data.integrationRule); + } } } - std::optional PreparedHydrostaticEquilibriumOperator::PrepareGeometry() { + std::optional PreparedHydrostaticEquilibriumOperator::PrepareGeometry(const bool useTensorPreparation) { mfem::Vector displacementLocal; true_to_local(*m_fem.displacementFes, m_context.GetDisplacementTrue(), displacementLocal); @@ -544,6 +577,8 @@ namespace mean_field::operators { mfem::Vector elementDisplacement; mfem::Vector elementCompactification; + mfem::Vector sampleValue(3), tensorWorkspace; + mfem::DenseMatrix sampleGradient(3), tensorSamples; for (ElementPAData &data : m_elements) { mfem::ElementTransformation *transformation = m_fem.mesh->GetElementTransformation(data.elementId); @@ -590,14 +625,27 @@ namespace mean_field::operators { data.quadratureWeights.SetSize(quadraturePointCount); data.baseMappingContexts.SetSize(quadraturePointCount, m_fem.mesh->Dimension()); + if (data.displacementReferenceTable != nullptr) + data.displacementInverseJacobians.SetSize(9, quadraturePointCount); + + const bool tensor = useTensorPreparation && data.displacementReferenceTable && + data.displacementReferenceTable->HasTensorEvaluation3D(); + if (tensor) data.displacementReferenceTable->EvaluateTensor3D( + data.baseDisplacementData->GetDofMatrix(), tensorSamples, tensorWorkspace); for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { const mfem::IntegrationPoint &integrationPoint = data.integrationRule->IntPoint(quadraturePoint); transformation->SetIntPoint(&integrationPoint); + if (tensor) for (int c = 0; c < 3; ++c) { + sampleValue(c) = tensorSamples(quadraturePoint, 4*c); + for (int a = 0; a < 3; ++a) + sampleGradient(c,a) = tensorSamples(quadraturePoint, 4*c+a+1); + } + const mapping::DomainMapper::ReferenceDisplacementSample sample{sampleValue, sampleGradient}; const mapping::MappingStatus mappingStatus = m_domainMapper.EvaluateVolume( - mappingData, *transformation, integrationPoint, workspace, mappingContext + mappingData, *transformation, integrationPoint, workspace, mappingContext, tensor ? &sample : nullptr ); if (mappingStatus != mapping::MappingStatus::valid) { @@ -615,6 +663,16 @@ namespace mean_field::operators { data.baseMappingContexts.Store(quadraturePoint, mappingContext); data.quadratureWeights(quadraturePoint) = quadratureWeight; + if (data.displacementInverseJacobians.Width() != 0) { + if (mappingContext.mapping.compactified) { + data.displacementInverseJacobians.SetSize(0, 0); + } else { + double *inverse = data.displacementInverseJacobians.Data() + 9 * quadraturePoint; + for (int c = 0; c < 3; ++c) + for (int r = 0; r < 3; ++r) + inverse[r + 3 * c] = mappingContext.quadrature.J_inv(r, c); + } + } for (int component = 0; component < m_fem.mesh->Dimension(); ++component) { const double position = mappingContext.mapping.physical_position(component); @@ -631,7 +689,8 @@ namespace mean_field::operators { return std::nullopt; } - bool PreparedHydrostaticEquilibriumOperator::PrepareAlgebraicJacobianBlocks() { + bool PreparedHydrostaticEquilibriumOperator::PrepareAlgebraicJacobianBlocks(const bool useTensorPreparation) { + mfem::Vector dual, column, workspace; for (ElementPAData &data : m_elements) { const mfem::DenseMatrix &enthalpyBasis = data.GetEnthalpyBasis(); const mfem::DenseMatrix &gravityPotentialBasis = data.GetGravityPotentialBasis(); @@ -658,7 +717,21 @@ namespace mean_field::operators { data.gravityPotentialJacobian = 0.0; data.bernoulliConstantJacobian = 0.0; - for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { + if (useTensorPreparation && data.enthalpyReferenceTable->HasTensorEvaluation3D()) { + dual.SetSize(quadraturePointCount); + auto assemble = [&](const mfem::DenseMatrix &basis, mfem::DenseMatrix &matrix) { + for (int j = 0; j < basis.Width(); ++j) { + for (int q = 0; q < quadraturePointCount; ++q) + dual(q) = data.quadratureWeights(q)*basis(q,j); + data.enthalpyReferenceTable->ApplyTransposeTensor3D(dual, column, workspace); + for (int i = 0; i < enthalpyDofCount; ++i) matrix(i,j) = column(i); + } + }; + assemble(enthalpyBasis, data.enthalpyJacobian); + assemble(gravityPotentialBasis, data.gravityPotentialJacobian); + for (int q = 0; q < quadraturePointCount; ++q) dual(q) = -data.quadratureWeights(q); + data.enthalpyReferenceTable->ApplyTransposeTensor3D(dual, data.bernoulliConstantJacobian, workspace); + } else for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { const double quadratureWeight = data.quadratureWeights(quadraturePoint); for (int testDof = 0; testDof < enthalpyDofCount; ++testDof) { @@ -738,7 +811,7 @@ namespace mean_field::operators { return false; } - bool PreparedHydrostaticEquilibriumOperator::PrepareBaseState() { + bool PreparedHydrostaticEquilibriumOperator::PrepareBaseState(const bool useTensorPreparation) { mfem::Vector enthalpyLocal; mfem::Vector gravityPotentialLocal; @@ -750,6 +823,7 @@ namespace mean_field::operators { mfem::Vector elementGravityPotential; mfem::Vector quadratureEnthalpy; mfem::Vector quadratureGravityPotential; + mfem::Vector workspace; for (ElementPAData &data : m_elements) { enthalpyLocal.GetSubVector(data.enthalpyDofs, elementEnthalpy); @@ -770,9 +844,12 @@ namespace mean_field::operators { quadratureGravityPotential.SetSize(quadraturePointCount); - data.GetEnthalpyBasis().Mult(elementEnthalpy, quadratureEnthalpy); - - data.GetGravityPotentialBasis().Mult(elementGravityPotential, quadratureGravityPotential); + if (useTensorPreparation && data.enthalpyReferenceTable->HasTensorEvaluation3D()) + data.enthalpyReferenceTable->EvaluateValuesTensor3D(elementEnthalpy, quadratureEnthalpy, workspace); + else data.GetEnthalpyBasis().Mult(elementEnthalpy, quadratureEnthalpy); + if (useTensorPreparation && data.gravityPotentialReferenceTable->HasTensorEvaluation3D()) + data.gravityPotentialReferenceTable->EvaluateValuesTensor3D(elementGravityPotential, quadratureGravityPotential, workspace); + else data.GetGravityPotentialBasis().Mult(elementGravityPotential, quadratureGravityPotential); MFEM_VERIFY( data.rotationPotential.Size() == quadraturePointCount, "Prepared hydrostatic base state has stale " @@ -1156,6 +1233,38 @@ namespace mean_field::operators { void PreparedHydrostaticEquilibriumOperator::ApplyDisplacementJacobianAction( const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, true, true); + } + + void PreparedHydrostaticEquilibriumOperator::ApplyDisplacementJacobianActionReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, false, false); + } + + void PreparedHydrostaticEquilibriumOperator::ApplyDisplacementJacobianActionCachedReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, true, false); + } + + int PreparedHydrostaticEquilibriumOperator::GetTensorDisplacementElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + count += data.displacementInverseJacobians.Width() != 0 && data.displacementReferenceTable != nullptr && + data.displacementReferenceTable->HasTensorEvaluation3D(); + return count; + } + + int PreparedHydrostaticEquilibriumOperator::GetFastDisplacementElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) count += data.displacementInverseJacobians.Width() != 0; + return count; + } + + void PreparedHydrostaticEquilibriumOperator::ApplyDisplacementJacobianActionImpl( + const mfem::Vector &displacementVariation, mfem::Vector &action, const bool useFastPath, const bool useTensorPath ) const { VerifyPrepared(); @@ -1226,8 +1335,42 @@ namespace mean_field::operators { ); weightedQuadratureVariation.SetSize(quadraturePointCount); + const bool tensor = useFastPath && useTensorPath && data.displacementInverseJacobians.Width() != 0 && + data.displacementReferenceTable->HasTensorEvaluation3D(); + if (tensor) data.displacementReferenceTable->EvaluateTensor3D( + directionData.GetDofMatrix(), m_tensorDisplacementValuesAndGradients, m_tensorDisplacementWorkspace); for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { + if (tensor) { + const double *inverse = data.displacementInverseJacobians.Data() + 9 * quadraturePoint; + double trace = 0.0, position = 0.0; + for (int c = 0; c < 3; ++c) { + position += data.rotationGradient(quadraturePoint, c) * + m_tensorDisplacementValuesAndGradients(quadraturePoint, 4 * c); + for (int derivative = 0; derivative < 3; ++derivative) + trace += inverse[derivative + 3 * c] * + m_tensorDisplacementValuesAndGradients(quadraturePoint, 4 * c + 1 + derivative); + } + const double value = data.quadratureWeights(quadraturePoint) * + (data.hydrostaticImbalance(quadraturePoint) * trace - position); + MFEM_VERIFY(std::isfinite(value), "Prepared hydrostatic tensor action is non-finite."); + weightedQuadratureVariation(quadraturePoint) = value; + continue; + } + if (useFastPath && data.displacementInverseJacobians.Width() != 0) { + const double weight = data.quadratureWeights(quadraturePoint); + double positionCoefficient[3]; + for (int c = 0; c < 3; ++c) + positionCoefficient[c] = -weight * data.rotationGradient(quadraturePoint, c); + const double value = apply_interior_functional( + directionData.GetDofMatrix(), data.displacementReferenceTable->GetGradients(quadraturePoint), + data.displacementReferenceTable->GetValues(), quadraturePoint, + data.displacementInverseJacobians.Data() + 9 * quadraturePoint, + weight * data.hydrostaticImbalance(quadraturePoint), positionCoefficient); + MFEM_VERIFY(std::isfinite(value), "Prepared hydrostatic displacement action is non-finite."); + weightedQuadratureVariation(quadraturePoint) = value; + continue; + } const mfem::IntegrationPoint &integrationPoint = data.integrationRule->IntPoint(quadraturePoint); data.baseMappingContexts.Load(quadraturePoint, mappingContext); @@ -1279,7 +1422,7 @@ namespace mean_field::operators { action.SetSize(m_context.GetEnthalpyMap().reduced_size()); m_context.GetEnthalpyMap().gather(m_fullEnthalpyAction, action); - ++m_displacementJacobianStatistics.applications; + if (useFastPath && useTensorPath) ++m_displacementJacobianStatistics.applications; } void PreparedHydrostaticEquilibriumOperator::ApplyCompleteJacobianAction( diff --git a/libmeanfield/impl/operators/prepared_mass_normalization.cpp b/libmeanfield/impl/operators/prepared_mass_normalization.cpp index b4f5920..486da56 100644 --- a/libmeanfield/impl/operators/prepared_mass_normalization.cpp +++ b/libmeanfield/impl/operators/prepared_mass_normalization.cpp @@ -9,6 +9,7 @@ module; #include #include +#include "interior_displacement_contractions.hpp" module mean_field; @@ -321,6 +322,7 @@ namespace mean_field::operators { const bool updateTargetMass = !m_isPrepared || dependencies.targetMass != m_preparedDependencies.targetMass || state.targetMass != m_targetMass; + if (refreshGeometry || refreshDensity) m_jacobianRowsReady = false; m_isPrepared = false; PreparedMassNormalizationReport report; @@ -603,7 +605,83 @@ namespace mean_field::operators { ++m_residualApplicationCount; } - double PreparedMassNormalizationOperator::EvaluateDensityActionLocal(const mfem::Vector &densityVariation) const { + bool PreparedMassNormalizationOperator::PrepareJacobianRows() const { + VerifyPrepared(); + if (m_jacobianRowsReady) return m_jacobianRowsSupported; + m_jacobianRowsSupported = false; + const int dimension = m_fem.mesh->Dimension(); + for (const ElementPAData &data : m_elements) { + const auto &element = *m_fem.displacementFes->GetFE(data.elementId); + if (dimension < 1 || dimension > 3 || element.GetMapType() != mfem::FiniteElement::VALUE || + element.GetDerivType() != mfem::FiniteElement::GRAD || + m_domainMapper.IsCompactifiedElement(*m_fem.mesh->GetElementTransformation(data.elementId))) { + m_jacobianRowsReady = true; + return false; + } + } + m_densityJacobianLocal.SetSize(m_fem.densityFes->GetVSize()); + m_displacementJacobianLocal.SetSize(m_fem.displacementFes->GetVSize()); + m_densityJacobianLocal = 0.0; + m_displacementJacobianLocal = 0.0; + mfem::Vector densityRow, displacementRow; + mapping::VolumeMappingContext mappingContext; + constexpr double zeroPosition[3]{0.0, 0.0, 0.0}; + for (const ElementPAData &data : m_elements) { + densityRow.SetSize(data.densityDofs.Size()); + data.densityBasis->GetValues().MultTranspose(data.quadratureWeights, densityRow); + displacementRow.SetSize(data.displacementDofs.Size()); + displacementRow = 0.0; + const auto basis = m_fem.GetReferenceTables().GetScalarTable( + *m_fem.displacementFes->GetFE(data.elementId), *data.integrationRule); + for (int q = 0; q < data.integrationRule->GetNPoints(); ++q) { + data.mappingContexts.Load(q, mappingContext); + add_interior_functional_row( + basis->GetGradients(q), basis->GetValues(), q, mappingContext.quadrature.J_inv, + data.density(q) * data.quadratureWeights(q), zeroPosition, displacementRow); + } + if (data.densityDofTransformation) data.densityDofTransformation->TransformDual(densityRow); + if (data.displacementDofTransformation) data.displacementDofTransformation->TransformDual(displacementRow); + m_densityJacobianLocal.AddElementVector(data.densityDofs, densityRow); + m_displacementJacobianLocal.AddElementVector(data.displacementDofs, displacementRow); + } + validate_finite_vector(m_densityJacobianLocal, "Prepared mass density row is non-finite."); + validate_finite_vector(m_displacementJacobianLocal, "Prepared mass displacement row is non-finite."); + m_jacobianRowsSupported = true; + m_jacobianRowsReady = true; + ++m_jacobianRowPreparationCount; + return true; + } + + std::uint64_t PreparedMassNormalizationOperator::GetJacobianRowPreparationCount() const noexcept { + return m_jacobianRowPreparationCount; + } + + double PreparedMassNormalizationOperator::EvaluateDensityActionLocal(const mfem::Vector &direction) const { + if (!PrepareJacobianRows()) return EvaluateDensityActionLocalReference(direction); + true_to_local(*m_fem.densityFes, direction, m_densityVariationLocal); + return m_densityJacobianLocal * m_densityVariationLocal; + } + + double PreparedMassNormalizationOperator::EvaluateDisplacementActionLocal(const mfem::Vector &direction) const { + if (!PrepareJacobianRows()) return EvaluateDisplacementActionLocalReference(direction); + true_to_local(*m_fem.displacementFes, direction, m_displacementVariationLocal); + return m_displacementJacobianLocal * m_displacementVariationLocal; + } + + void PreparedMassNormalizationOperator::ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + VerifyPrepared(); + validate_finite_vector(densityVariation, "Mass-normalization density direction is non-finite."); + validate_finite_vector(displacementVariation, "Mass-normalization displacement direction is non-finite."); + m_gravityContext.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_gravityContext.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + action.SetSize(1); + action(0) = GlobalSum(EvaluateDensityActionLocalReference(m_densityVariationTrue) + + EvaluateDisplacementActionLocalReference(m_displacementVariationTrue)); + } + + double PreparedMassNormalizationOperator::EvaluateDensityActionLocalReference(const mfem::Vector &densityVariation) const { MFEM_VERIFY( densityVariation.Size() == m_fem.densityFes->GetTrueVSize(), "Mass-normalization density action received a vector with the " @@ -635,7 +713,7 @@ namespace mean_field::operators { return localAction; } - double PreparedMassNormalizationOperator::EvaluateDisplacementActionLocal( + double PreparedMassNormalizationOperator::EvaluateDisplacementActionLocalReference( const mfem::Vector &displacementVariation ) const { MFEM_VERIFY( @@ -898,8 +976,18 @@ namespace mean_field::operators { VerifyPrepared(); MFEM_VERIFY(std::isfinite(residualDual), "Mass-normalization transpose action received a non-finite dual."); - AssembleDensityTransposeAction(residualDual, densityDual); - AssembleDisplacementTransposeAction(residualDual, displacementDual); + if (PrepareJacobianRows()) { + mfem::Vector trueDual; + local_to_true(*m_fem.densityFes, m_densityJacobianLocal, trueDual); + densityDual = m_gravityContext.GetDensityMap().gather(trueDual); + densityDual *= residualDual; + local_to_true(*m_fem.displacementFes, m_displacementJacobianLocal, trueDual); + displacementDual = m_gravityContext.GetDisplacementMap().gather(trueDual); + displacementDual *= residualDual; + } else { + AssembleDensityTransposeAction(residualDual, densityDual); + AssembleDisplacementTransposeAction(residualDual, displacementDual); + } ++m_actionStatistics.transposeApplications; } diff --git a/libmeanfield/impl/operators/prepared_pressure_force.cpp b/libmeanfield/impl/operators/prepared_pressure_force.cpp index ec4b918..164d40a 100644 --- a/libmeanfield/impl/operators/prepared_pressure_force.cpp +++ b/libmeanfield/impl/operators/prepared_pressure_force.cpp @@ -1,5 +1,8 @@ module; +#include "interior_displacement_contractions.hpp" + +#include #include #include #include @@ -406,13 +409,42 @@ namespace mean_field::operators { PreparedPressureForceOperator::TryPrepare( const context::pressure_force::PressureForceStateView &state, const context::pressure_force::PressureForceDependencies &dependencies + ) { + return TryPrepareImpl(state, dependencies, true); + } + + PreparedPressureForceReport PreparedPressureForceOperator::PrepareReference( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies + ) { + auto result = TryPrepareImpl(state, dependencies, false, false); + if (!result.has_value()) throw_rejection(result.error()); + return std::move(result).value(); + } + + PreparedPressureForceReport PreparedPressureForceOperator::PrepareCachedReference( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies + ) { + auto result = TryPrepareImpl(state, dependencies, true, false); + if (!result.has_value()) throw_rejection(result.error()); + return std::move(result).value(); + } + + std::expected + PreparedPressureForceOperator::TryPrepareImpl( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies, + const bool useFastPreparation, const bool useTensorPreparation ) { const bool wasPrepared = m_isPrepared; + const bool implementationChanged = m_preparedWithFastMaterialAssembly != useFastPreparation || + m_preparedWithTensorPreparation != useTensorPreparation; PreparedPressureForceReport report; report.contextReport = m_context.Prepare(state, dependencies); - if (!report.contextReport.DidAnyWork() && wasPrepared) { + if (!report.contextReport.DidAnyWork() && wasPrepared && !implementationChanged) { return report; } @@ -433,16 +465,16 @@ namespace mean_field::operators { PrepareStaticPlan(); } - if (report.contextReport.preparedGeometryState || !wasPrepared) { - const auto globalGeometryFailure = synchronize_rejection(PrepareGeometry(), m_fem.enthalpyFes->GetComm()); + if (report.contextReport.preparedGeometryState || !wasPrepared || implementationChanged) { + const auto globalGeometryFailure = synchronize_rejection(PrepareGeometry(useTensorPreparation), m_fem.enthalpyFes->GetComm()); if (globalGeometryFailure.has_value()) { return std::unexpected(*globalGeometryFailure); } } std::optional localMaterialFailure; - if (report.contextReport.preparedMaterialState || !wasPrepared) { - localMaterialFailure = PrepareMaterialState(); + if (report.contextReport.preparedMaterialState || !wasPrepared || implementationChanged) { + localMaterialFailure = PrepareMaterialState(useFastPreparation, useTensorPreparation); } const auto globalMaterialFailure = synchronize_rejection(localMaterialFailure, m_fem.enthalpyFes->GetComm()); @@ -450,7 +482,7 @@ namespace mean_field::operators { return std::unexpected(*globalMaterialFailure); } - if (report.contextReport.preparedMaterialState || !wasPrepared) { + if (report.contextReport.preparedMaterialState || !wasPrepared || implementationChanged) { FinalizeDisplacementJacobianPreparation(); const auto globalAssemblyFailure = @@ -482,6 +514,8 @@ namespace mean_field::operators { "supported displacement size." ); + m_preparedWithTensorPreparation = useTensorPreparation; + m_preparedWithFastMaterialAssembly = useFastPreparation; m_isPrepared = true; return report; @@ -564,7 +598,7 @@ namespace mean_field::operators { } } - std::optional PreparedPressureForceOperator::PrepareGeometry() { + std::optional PreparedPressureForceOperator::PrepareGeometry(const bool useTensorPreparation) { mfem::Vector displacementLocal; true_to_local(*m_fem.displacementFes, m_baseDisplacementTrue, displacementLocal); @@ -574,6 +608,8 @@ namespace mean_field::operators { mfem::Vector elementDisplacement; mfem::Vector elementCompactification; + mfem::Vector sampleValue(3), tensorWorkspace; + mfem::DenseMatrix sampleGradient(3), tensorSamples; for (ElementPAData &data : m_elements) { MFEM_VERIFY(data.integrationRule != nullptr, "Prepared pressure-force geometry has no integration rule."); @@ -626,13 +662,24 @@ namespace mean_field::operators { data.physicalTestGradients.resize(quadraturePointCount); + const bool tensor = useTensorPreparation && data.displacementReferenceTable && + data.displacementReferenceTable->HasTensorEvaluation3D(); + if (tensor) data.displacementReferenceTable->EvaluateTensor3D( + data.baseDisplacementData->GetDofMatrix(), tensorSamples, tensorWorkspace); + for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { const mfem::IntegrationPoint &integrationPoint = data.integrationRule->IntPoint(quadraturePoint); transformation->SetIntPoint(&integrationPoint); + if (tensor) for (int c = 0; c < 3; ++c) { + sampleValue(c) = tensorSamples(quadraturePoint, 4*c); + for (int a = 0; a < 3; ++a) + sampleGradient(c,a) = tensorSamples(quadraturePoint, 4*c+a+1); + } + const mapping::DomainMapper::ReferenceDisplacementSample sample{sampleValue, sampleGradient}; const mapping::MappingStatus mappingStatus = m_domainMapper.EvaluateVolume( - mappingData, *transformation, integrationPoint, workspace, mappingContext + mappingData, *transformation, integrationPoint, workspace, mappingContext, tensor ? &sample : nullptr ); if (mappingStatus != mapping::MappingStatus::valid) { @@ -675,7 +722,9 @@ namespace mean_field::operators { return std::nullopt; } - std::optional PreparedPressureForceOperator::PrepareMaterialState() { + std::optional PreparedPressureForceOperator::PrepareMaterialState( + const bool useFastPreparation, const bool useTensorPreparation + ) { mfem::Vector enthalpyLocal; true_to_local(*m_fem.enthalpyFes, m_baseEnthalpyTrue, enthalpyLocal); @@ -687,6 +736,8 @@ namespace mean_field::operators { const mfem::Ordering::Type displacementOrdering = m_fem.displacementFes->GetOrdering(); std::optional materialFailure; + mfem::DenseMatrix weightedGradients; + mfem::DenseMatrix tensorFactors, tensorInverse; for (ElementPAData &data : m_elements) { enthalpyLocal.GetSubVector(data.enthalpyDofs, elementEnthalpy); @@ -717,7 +768,9 @@ namespace mean_field::operators { quadratureEnthalpy.SetSize(quadraturePointCount); - enthalpyBasis.Mult(elementEnthalpy, quadratureEnthalpy); + if (useTensorPreparation && data.enthalpyReferenceTable->HasTensorEvaluation3D()) + data.enthalpyReferenceTable->EvaluateValuesTensor3D(elementEnthalpy, quadratureEnthalpy, m_tensorWorkspace); + else enthalpyBasis.Mult(elementEnthalpy, quadratureEnthalpy); data.pressure.SetSize(quadraturePointCount); @@ -796,14 +849,72 @@ namespace mean_field::operators { data.elementResidual(vectorDof) -= residualContribution; - for (int enthalpyDof = 0; enthalpyDof < enthalpyDofCount; ++enthalpyDof) { - const double jacobianContribution = - pressureDerivative * weightedTestGradient * enthalpyBasis(quadraturePoint, enthalpyDof); - if (!std::isfinite(jacobianContribution)) { - materialFailure = non_finite_rejection(); - continue; + if (!useFastPreparation) { + for (int enthalpyDof = 0; enthalpyDof < enthalpyDofCount; ++enthalpyDof) { + const double jacobianContribution = pressureDerivative * weightedTestGradient * + enthalpyBasis(quadraturePoint, enthalpyDof); + if (!std::isfinite(jacobianContribution)) { + materialFailure = non_finite_rejection(); + continue; + } + data.enthalpyJacobian(vectorDof, enthalpyDof) -= jacobianContribution; + } + } + } + } + } + + if (useFastPreparation && useTensorPreparation && + data.displacementReferenceTable->HasTensorEvaluation3D() && !materialFailure.has_value()) { + // Build each enthalpy column by a tensor gradient transpose. + // These are the same -w P'(h) grad(test) B_h entries. + tensorFactors.SetSize(quadraturePointCount, 9); + m_tensorDuals.SetSize(quadraturePointCount, 9); + for (int q = 0; q < quadraturePointCount; ++q) { + data.baseMappingContexts.LoadInverseJacobian(q, tensorInverse); + const double w = -data.pressureDerivative(q)*data.quadratureWeights(q); + for (int c = 0; c < 3; ++c) for (int a = 0; a < 3; ++a) + tensorFactors(q, 3*c+a) = w*tensorInverse(a,c); + } + for (int col = 0; col < enthalpyDofCount; ++col) { + for (int a = 0; a < 9; ++a) for (int q = 0; q < quadraturePointCount; ++q) + m_tensorDuals(q,a) = tensorFactors(q,a)*enthalpyBasis(q,col); + data.displacementReferenceTable->ApplyGradientTransposeTensor3D( + m_tensorDuals, m_tensorElement, m_tensorWorkspace); + for (int c = 0; c < 3; ++c) for (int i = 0; i < scalarDisplacementDofCount; ++i) + data.enthalpyJacobian(i+c*scalarDisplacementDofCount,col) = m_tensorElement(i,c); + } + } else if (useFastPreparation && !materialFailure.has_value()) { + // J_h = -sum_q w_q P'(h_q) grad(test)_q B_h(q). + // Pack once, then accumulate small contiguous output tiles. + // The old loop is retained above as the independent reference. + weightedGradients.SetSize(displacementDofCount, quadraturePointCount); + for (int q = 0; q < quadraturePointCount; ++q) { + for (int c = 0; c < dimension; ++c) { + for (int i = 0; i < scalarDisplacementDofCount; ++i) { + const int row = vector_dof_index( + displacementOrdering, i, c, scalarDisplacementDofCount, dimension); + weightedGradients(row, q) = -data.pressureDerivative(q) * + (data.quadratureWeights(q) * data.physicalTestGradients[q](i, c)); + } + } + } + const double *g = weightedGradients.Data(); + const double *b = enthalpyBasis.Data(); + double *j = data.enthalpyJacobian.Data(); + constexpr int rowBlock = 32, columnBlock = 8; + for (int colBegin = 0; colBegin < enthalpyDofCount; colBegin += columnBlock) { + const int colEnd = std::min(colBegin + columnBlock, enthalpyDofCount); + for (int rowBegin = 0; rowBegin < displacementDofCount; rowBegin += rowBlock) { + const int rowEnd = std::min(rowBegin + rowBlock, displacementDofCount); + for (int q = 0; q < quadraturePointCount; ++q) { + const double *gradient = g + q * displacementDofCount; + for (int col = colBegin; col < colEnd; ++col) { + const double basis = b[q + col * quadraturePointCount]; + double *column = j + col * displacementDofCount; + for (int row = rowBegin; row < rowEnd; ++row) + column[row] += gradient[row] * basis; } - data.enthalpyJacobian(vectorDof, enthalpyDof) -= jacobianContribution; } } } @@ -938,8 +1049,32 @@ namespace mean_field::operators { } void PreparedPressureForceOperator::ApplyDisplacementJacobianAction( - const mfem::Vector &displacementVariation, - mfem::Vector &action + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, true); + } + + void PreparedPressureForceOperator::ApplyDisplacementJacobianActionReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, false, false); + } + + void PreparedPressureForceOperator::ApplyDisplacementJacobianActionCachedReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const { + ApplyDisplacementJacobianActionImpl(displacementVariation, action, true, false); + } + + int PreparedPressureForceOperator::GetTensorElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + count += data.displacementReferenceTable->HasTensorEvaluation3D(); + return count; + } + + void PreparedPressureForceOperator::ApplyDisplacementJacobianActionImpl( + const mfem::Vector &displacementVariation, mfem::Vector &action, const bool useFastPath, const bool useTensorPath ) const { VerifyPrepared(); @@ -1017,7 +1152,39 @@ namespace mean_field::operators { matrixTemporary.SetSize(dimension, dimension); physicalTestGradientVariation.SetSize(scalarDisplacementDofCount, dimension); - for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { + const bool tensor = useTensorPath && data.displacementReferenceTable->HasTensorEvaluation3D(); + if (tensor) { + data.displacementReferenceTable->EvaluateTensor3D(directionDofs, m_tensorSamples, m_tensorWorkspace); + m_tensorDuals.SetSize(quadraturePointCount, 9); + for (int q = 0; q < quadraturePointCount; ++q) { + data.baseMappingContexts.LoadInverseJacobian(q, inverseElementJacobian); + double gradient[3][3]{}; + for (int c = 0; c < 3; ++c) for (int j = 0; j < 3; ++j) + for (int a = 0; a < 3; ++a) + gradient[c][j] += m_tensorSamples(q, 4*c+a+1)*inverseElementJacobian(a,j); + const double trace = gradient[0][0]+gradient[1][1]+gradient[2][2]; + const double weight = data.pressure(q)*data.quadratureWeights(q); + // d(-P grad(test) dx): grad_ref(test) J^-1 (du/dx - tr(du/dx) I). + for (int c = 0; c < 3; ++c) for (int a = 0; a < 3; ++a) { + double value = 0.0; + for (int j = 0; j < 3; ++j) + value += inverseElementJacobian(a,j)*(gradient[j][c]-(j==c ? trace : 0.0)); + m_tensorDuals(q, 3*c+a) = weight*value; + } + } + data.displacementReferenceTable->ApplyGradientTransposeTensor3D( + m_tensorDuals, m_tensorElement, m_tensorWorkspace); + for (int c = 0; c < 3; ++c) for (int i = 0; i < scalarDisplacementDofCount; ++i) + elementAction(i+c*scalarDisplacementDofCount) = m_tensorElement(i,c); + } + + for (int quadraturePoint = 0; !tensor && quadraturePoint < quadraturePointCount; ++quadraturePoint) { + if (useFastPath && dimension == 3) { + pressure_displacement_action_3d( + directionDofs, data.physicalTestGradients[quadraturePoint], + data.pressure(quadraturePoint) * data.quadratureWeights(quadraturePoint), elementAction); + continue; + } const mfem::DenseMatrix &referenceTestGradient = data.displacementReferenceTable->GetGradients(quadraturePoint); @@ -1076,7 +1243,7 @@ namespace mean_field::operators { m_displacementMap.gather(m_fullDisplacementAction, action); - ++m_displacementJacobianStatistics.applications; + if (useFastPath) ++m_displacementJacobianStatistics.applications; } void PreparedPressureForceOperator::ApplyCompleteJacobianAction( diff --git a/libmeanfield/impl/operators/prepared_rotation_displacement_force.cpp b/libmeanfield/impl/operators/prepared_rotation_displacement_force.cpp index ee9dccf..9c8887b 100644 --- a/libmeanfield/impl/operators/prepared_rotation_displacement_force.cpp +++ b/libmeanfield/impl/operators/prepared_rotation_displacement_force.cpp @@ -1,5 +1,7 @@ module; +#include "interior_displacement_contractions.hpp" + #include #include #include @@ -14,6 +16,7 @@ module mean_field; import :operators.kernels.rotational_displacement_force; import :operators.prepared_rotational_displacement_force; +import :fem.reference_tables; namespace { using DomainSchema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema; @@ -194,7 +197,7 @@ namespace mean_field::operators { std::expected< void, kernels::RotationalDisplacementForceRejection> - PreparedRotationalDisplacementForceOperator::TryPrepareElementData() { + PreparedRotationalDisplacementForceOperator::TryPrepareElementData(const bool assembleResidual) { MFEM_VERIFY(m_rotation.has_value(), "Prepared rotational force has no frozen rotation state."); m_elements.clear(); @@ -214,7 +217,20 @@ namespace mean_field::operators { mfem::Vector densityShape; mfem::Vector potentialGradient; + if (assembleResidual) { + m_localAction.SetSize(m_fem.displacementFes->GetVSize()); + m_localAction = 0.0; + } + mfem::Vector residualComponent; const int dimension = m_domainMapper.GetDimension(); + // Rigid rotation has a position-independent acceleration derivative. + m_centrifugalJacobian.SetSize(dimension, dimension); + mfem::Vector unit(dimension), derivative(dimension); + for (int c = 0; c < dimension; ++c) { + unit = 0.0; unit(c) = 1.0; + m_rotation->potential_gradient_directional_derivative(unit, derivative); + for (int r = 0; r < dimension; ++r) m_centrifugalJacobian(r, c) = -derivative(r); + } for (int elementId = 0; elementId < m_fem.mesh->GetNE(); ++elementId) { mfem::ElementTransformation *transformation = m_fem.mesh->GetElementTransformation(elementId); @@ -249,6 +265,11 @@ namespace mean_field::operators { const mfem::FiniteElement &displacementElement = *m_fem.displacementFes->GetFE(elementId); const mfem::FiniteElement &compactificationElement = *m_fem.compactificationFes->GetFE(elementId); data.integrationRule = &get_rotation_force_rule(m_fem, *transformation); + data.densityReferenceTable = m_fem.GetReferenceTables().GetScalarTable(densityElement, *data.integrationRule); + data.displacementReferenceTable = m_fem.GetReferenceTables().GetScalarTable(displacementElement, *data.integrationRule); + data.fastApplication = dimension == 3 && m_fem.displacementFes->GetOrdering() == mfem::Ordering::byNODES && + displacementElement.GetMapType() == mfem::FiniteElement::VALUE && + displacementElement.GetDerivType() == mfem::FiniteElement::GRAD; const mapping::ElementDisplacementData displacementData = mapping::ElementDisplacementDataFromElementVDofs(displacementElement, elementBaseDisplacement); @@ -266,6 +287,7 @@ namespace mean_field::operators { data.quadratureWeights.SetSize(quadraturePointCount); densityShape.SetSize(densityElement.GetDof()); potentialGradient.SetSize(dimension); + if (assembleResidual) m_quadratureForce.SetSize(quadraturePointCount, dimension); for (int quadraturePoint = 0; quadraturePoint < quadraturePointCount; ++quadraturePoint) { const mfem::IntegrationPoint &integrationPoint = data.integrationRule->IntPoint(quadraturePoint); @@ -304,8 +326,36 @@ namespace mean_field::operators { } } } + + if (assembleResidual) { + const double weightedDensity = + data.quadratureWeights(quadraturePoint) * data.baseDensityValues(quadraturePoint); + for (int component = 0; component < dimension; ++component) + m_quadratureForce(quadraturePoint, component) = -weightedDensity * potentialGradient(component); + } + } + + if (assembleResidual) { + const int ndof = displacementElement.GetDof(); + MFEM_VERIFY(data.displacementDofs.Size() == ndof * dimension, + "Prepared force residual has an invalid displacement element size."); + m_elementAction.SetSize(data.displacementDofs.Size()); + residualComponent.SetSize(ndof); + for (int component = 0; component < dimension; ++component) { + data.displacementReferenceTable->GetValues().MultTranspose( + m_quadratureForce.Data() + component * quadraturePointCount, residualComponent.GetData()); + for (int dof = 0; dof < ndof; ++dof) + m_elementAction(vector_dof_index(m_fem.displacementFes->GetOrdering(), + dof, component, ndof, dimension)) = residualComponent(dof); + } + if (!vector_is_finite(m_elementAction)) return std::unexpected(non_finite_rejection()); + if (data.displacementDofTransformation != nullptr) + data.displacementDofTransformation->TransformDual(m_elementAction); + m_localAction.AddElementVector(data.displacementDofs, m_elementAction); } } + if (assembleResidual && !vector_is_finite(m_localAction)) + return std::unexpected(non_finite_rejection()); return {}; } @@ -328,15 +378,36 @@ namespace mean_field::operators { const context::rotational_displacement_force::RotationalDisplacementForceStateView &state, const context::rotational_displacement_force::RotationalDisplacementForceDependencies &dependencies, const physics::RigidRotation &rotation + ) { + return TryPrepareImpl(state, dependencies, rotation, true); + } + + PreparedRotationalDisplacementForceReport PreparedRotationalDisplacementForceOperator::PrepareReference( + const context::rotational_displacement_force::RotationalDisplacementForceStateView &state, + const context::rotational_displacement_force::RotationalDisplacementForceDependencies &dependencies, + const physics::RigidRotation &rotation + ) { + auto result = TryPrepareImpl(state, dependencies, rotation, false); + if (!result.has_value()) throw_rejection(result.error()); + return std::move(result).value(); + } + + std::expected + PreparedRotationalDisplacementForceOperator::TryPrepareImpl( + const context::rotational_displacement_force::RotationalDisplacementForceStateView &state, + const context::rotational_displacement_force::RotationalDisplacementForceDependencies &dependencies, + const physics::RigidRotation &rotation, + const bool useFastPreparation ) { const bool wasPrepared = m_isPrepared; + const bool implementationChanged = m_preparedWithFastResidual != useFastPreparation; const bool rotationChanged = !m_context.IsPrepared() || dependencies.rotation != m_context.GetDependencies().rotation; PreparedRotationalDisplacementForceReport report; report.contextReport = m_context.Prepare(state, dependencies); - if (!report.contextReport.DidAnyWork() && wasPrepared) { + if (!report.contextReport.DidAnyWork() && wasPrepared && !implementationChanged) { return report; } @@ -352,29 +423,38 @@ namespace mean_field::operators { "rotation state." ); - if (report.contextReport.preparedBaseState || !wasPrepared) { - auto residualResult = kernels::try_apply_rotational_displacement_force_residual( - m_fem, m_domainMapper, *m_rotation, m_context.GetBaseDensityTrue(), m_context.GetDisplacementTrue(), - m_actionTrue - ); - if (!residualResult.has_value()) { - return std::unexpected(residualResult.error()); + if (report.contextReport.preparedBaseState || !wasPrepared || implementationChanged) { + const bool inputsFinite = vector_is_finite(m_context.GetBaseDensityTrue()) && + vector_is_finite(m_context.GetDisplacementTrue()); + const auto inputStatus = synchronize_rejection( + inputsFinite ? std::optional{} : std::optional{non_finite_rejection()}, + m_fem.mesh->GetComm()); + if (!inputStatus.has_value()) return std::unexpected(inputStatus.error()); + + // Retain the original two-pass path for an independent residual + // comparison. The production path evaluates the geometry once. + if (!useFastPreparation) { + auto residualResult = kernels::try_apply_rotational_displacement_force_residual( + m_fem, m_domainMapper, *m_rotation, m_context.GetBaseDensityTrue(), m_context.GetDisplacementTrue(), + m_actionTrue); + if (!residualResult.has_value()) return std::unexpected(residualResult.error()); } + const auto elementResult = TryPrepareElementData(useFastPreparation); + const std::optional localRejection = elementResult.has_value() + ? std::optional{} : std::optional{elementResult.error()}; + const auto elementStatus = synchronize_rejection(localRejection, m_fem.mesh->GetComm()); + if (!elementStatus.has_value()) return std::unexpected(elementStatus.error()); + + if (useFastPreparation) local_to_true(*m_fem.displacementFes, m_localAction, m_actionTrue); m_cachedResidual.SetSize(m_context.GetDisplacementMap().reduced_size()); m_context.GetDisplacementMap().gather(m_actionTrue, m_cachedResidual); + const auto residualStatus = synchronize_rejection( + vector_is_finite(m_cachedResidual) ? std::optional{} + : std::optional{non_finite_rejection()}, + m_fem.mesh->GetComm()); + if (!residualStatus.has_value()) return std::unexpected(residualStatus.error()); - const auto elementResult = TryPrepareElementData(); - std::optional localRejection = elementResult.has_value() - ? std::optional{} - : std::optional{elementResult.error()}; - if (!vector_is_finite(m_cachedResidual)) { - localRejection = non_finite_rejection(); - } - auto synchronized = synchronize_rejection(localRejection, m_fem.mesh->GetComm()); - if (!synchronized.has_value()) { - return std::unexpected(synchronized.error()); - } - + m_preparedWithFastResidual = useFastPreparation; ++m_residualPreparationCount; report.preparedResidual = true; } @@ -437,7 +517,8 @@ namespace mean_field::operators { void PreparedRotationalDisplacementForceOperator::ApplyPreparedCompleteJacobianActionTrue( const mfem::Vector &densityVariationTrue, const mfem::Vector &displacementVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + const bool useFastPath, const bool useTensorPath ) const { true_to_local(*m_fem.densityFes, densityVariationTrue, m_densityVariationLocal); true_to_local(*m_fem.displacementFes, displacementVariationTrue, m_displacementVariationLocal); @@ -466,6 +547,61 @@ namespace mean_field::operators { const mfem::DenseMatrix &directionDofs = directionData.GetDofMatrix(); const int scalarDisplacementDofCount = displacementElement.GetDof(); + if (useFastPath && data.fastApplication) { + const int nq = data.integrationRule->GetNPoints(); + m_quadratureDensityVariation.SetSize(nq); + if (useTensorPath && data.densityReferenceTable->HasTensorEvaluation3D()) + data.densityReferenceTable->EvaluateValuesTensor3D(m_elementDensityVariation, m_quadratureDensityVariation, m_tensorWorkspace); + else data.densityReferenceTable->GetValues().Mult(m_elementDensityVariation, m_quadratureDensityVariation); + m_quadraturePositionVariation.SetSize(nq, 3); + m_quadratureForce.SetSize(nq, 3); + double *position = m_quadraturePositionVariation.Data(); + double *force = m_quadratureForce.Data(); + const double *u = directionDofs.Data(); + const auto &values = data.displacementReferenceTable->GetValues(); + const bool tensor = useTensorPath && data.displacementReferenceTable->HasTensorEvaluation3D(); + if (tensor) { + data.displacementReferenceTable->EvaluateTensor3D(directionDofs, m_tensorDisplacement, m_tensorWorkspace); + for (int c = 0; c < 3; ++c) for (int q = 0; q < nq; ++q) + position[q + c * nq] = m_tensorDisplacement(q, 4 * c); + } else { + for (int c = 0; c < 3; ++c) + values.Mult(u + c * scalarDisplacementDofCount, position + c * nq); + } + for (int q = 0; q < nq; ++q) { + double trace = 0.0; + if (tensor) { + for (int row = 0; row < 3; ++row) for (int column = 0; column < 3; ++column) + trace += data.inverseElementJacobians(q, row * 3 + column) * + m_tensorDisplacement(q, 4 * column + row + 1); + } else trace = displacement_trace_3d(directionDofs, + data.displacementReferenceTable->GetGradients(q), data.inverseElementJacobians, q); + const double rho = data.baseDensityValues(q); + const double densityWeight = m_quadratureDensityVariation(q) + rho * trace; + for (int c = 0; c < 3; ++c) { + const double da = m_centrifugalJacobian(c, 0) * position[q] + + m_centrifugalJacobian(c, 1) * position[q + nq] + + m_centrifugalJacobian(c, 2) * position[q + 2 * nq]; + force[q + c * nq] = data.quadratureWeights(q) * + (densityWeight * data.centrifugalAccelerations(q, c) + rho * da); + } + } + m_elementAction.SetSize(data.displacementDofs.Size()); + double *element = m_elementAction.HostWrite(); + if (tensor) { + data.displacementReferenceTable->ApplyTransposeTensor3D(m_quadratureForce, m_tensorElement, m_tensorWorkspace); + for (int c = 0; c < 3; ++c) for (int i = 0; i < scalarDisplacementDofCount; ++i) + element[i + c * scalarDisplacementDofCount] = m_tensorElement(i, c); + } else { + for (int c = 0; c < 3; ++c) + values.MultTranspose(force + c * nq, element + c * scalarDisplacementDofCount); + } + if (data.displacementDofTransformation) + data.displacementDofTransformation->TransformDual(m_elementAction); + m_localAction.AddElementVector(data.displacementDofs, m_elementAction); + continue; + } + m_densityShape.SetSize(densityElement.GetDof()); m_displacementShape.SetSize(scalarDisplacementDofCount); m_referenceDisplacementDShape.SetSize(scalarDisplacementDofCount, dimension); @@ -546,6 +682,50 @@ namespace mean_field::operators { ++m_completeJacobianStatistics.applications; } + void PreparedRotationalDisplacementForceOperator::ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + mfem::Vector &action + ) const { + VerifyPrepared(); + + m_densityVariationTrue.SetSize(m_context.GetDensityMap().full_size()); + m_displacementVariationTrue.SetSize(m_context.GetDisplacementMap().full_size()); + m_context.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_context.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + + ApplyPreparedCompleteJacobianActionTrue(m_densityVariationTrue, m_displacementVariationTrue, m_actionTrue, false); + action.SetSize(m_context.GetDisplacementMap().reduced_size()); + m_context.GetDisplacementMap().gather(m_actionTrue, action); + + } + + void PreparedRotationalDisplacementForceOperator::ApplyCompleteJacobianActionCachedReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + mfem::Vector &action + ) const { + VerifyPrepared(); + + m_densityVariationTrue.SetSize(m_context.GetDensityMap().full_size()); + m_displacementVariationTrue.SetSize(m_context.GetDisplacementMap().full_size()); + m_context.GetDensityMap().scatter(densityVariation, m_densityVariationTrue); + m_context.GetDisplacementMap().scatter(displacementVariation, m_displacementVariationTrue); + + ApplyPreparedCompleteJacobianActionTrue(m_densityVariationTrue, m_displacementVariationTrue, m_actionTrue, true, false); + action.SetSize(m_context.GetDisplacementMap().reduced_size()); + m_context.GetDisplacementMap().gather(m_actionTrue, action); + + } + + int PreparedRotationalDisplacementForceOperator::GetTensorElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + count += data.fastApplication && data.densityReferenceTable->HasTensorEvaluation3D() && + data.displacementReferenceTable->HasTensorEvaluation3D(); + return count; + } + bool PreparedRotationalDisplacementForceOperator::IsPrepared() const noexcept { return m_isPrepared && m_rotation.has_value() && m_context.MatchesDependencies(m_preparedDependencies); } diff --git a/libmeanfield/impl/preconditioning/gravity_field.cpp b/libmeanfield/impl/preconditioning/gravity_field.cpp index bd5de2f..5685103 100644 --- a/libmeanfield/impl/preconditioning/gravity_field.cpp +++ b/libmeanfield/impl/preconditioning/gravity_field.cpp @@ -3,6 +3,7 @@ module; #include #include #include +#include #include module mean_field; @@ -41,9 +42,33 @@ namespace mean_field::preconditioning { return assembled; } + const mfem::HypreParMatrix &PreparedGravityDivergenceCache::Get(const fem::FEM &f) { + if (!f.mesh || !f.gravityFluxFes || !f.gravityPotentialFes || !f.quadratureFactory) + throw std::invalid_argument("Fixed divergence caching requires a complete gravity discretization."); + const auto *mesh = f.mesh.get(); + const auto *flux = f.gravityFluxFes.get(), *potential = f.gravityPotentialFes.get(); + const int localChanged = !m_matrix || m_mesh != mesh || m_flux != flux || m_potential != potential || + m_policy != f.quadratureFactory.get() || m_meshSequence != mesh->GetSequence() || + m_nodesSequence != mesh->GetNodesSequence() || m_fluxSequence != flux->GetSequence() || + m_potentialSequence != potential->GetSequence(); + int changed = 0; + if (MPI_Allreduce(&localChanged, &changed, 1, MPI_INT, MPI_MAX, f.mesh->GetComm()) != MPI_SUCCESS) + throw std::runtime_error("Could not synchronize fixed divergence cache validity."); + if (changed) { + auto matrix = assembleGravityDivergenceSurrogate(f); + m_matrix = std::move(matrix); + m_mesh = mesh; m_flux = flux; m_potential = potential; m_policy = f.quadratureFactory.get(); + m_meshSequence = mesh->GetSequence(); m_nodesSequence = mesh->GetNodesSequence(); + m_fluxSequence = flux->GetSequence(); m_potentialSequence = potential->GetSequence(); + ++m_builds; + } + return *m_matrix; + } + std::unique_ptr assembleGravityPotentialSchurSurrogate( const fem::FEM &f, - const mfem::Vector &trueMassDiagonal + const mfem::Vector &trueMassDiagonal, + PreparedGravityDivergenceCache *divergenceCache ) { if (f.gravityFluxFes == nullptr || trueMassDiagonal.Size() != f.gravityFluxFes->GetTrueVSize()) { throw std::invalid_argument( @@ -62,12 +87,14 @@ namespace mean_field::preconditioning { inverseMassDiagonal(index) = 1.0 / entry; } - std::unique_ptr divergence = assembleGravityDivergenceSurrogate(f); - std::unique_ptr inverseMassDivergenceTranspose(divergence->Transpose()); + std::unique_ptr uncachedDivergence; + if (!divergenceCache) uncachedDivergence = assembleGravityDivergenceSurrogate(f); + const mfem::HypreParMatrix &divergence = divergenceCache ? divergenceCache->Get(f) : *uncachedDivergence; + std::unique_ptr inverseMassDivergenceTranspose(divergence.Transpose()); inverseMassDivergenceTranspose->ScaleRows(inverseMassDiagonal); std::unique_ptr schur( - mfem::ParMult(divergence.get(), inverseMassDivergenceTranspose.get()) + mfem::ParMult(&divergence, inverseMassDivergenceTranspose.get()) ); if (schur == nullptr) { throw std::runtime_error("MFEM did not assemble the gravity potential-Schur surrogate."); diff --git a/libmeanfield/interface/deformation/domain_deformation.cppm b/libmeanfield/interface/deformation/domain_deformation.cppm index 4198bbe..529df2e 100644 --- a/libmeanfield/interface/deformation/domain_deformation.cppm +++ b/libmeanfield/interface/deformation/domain_deformation.cppm @@ -23,6 +23,7 @@ export import :deformation.radial_extensions; export import :deformation.surface_prescription; export import :deformation.vacuum_extension; export import :fem; +import :fem.reference_tables; export import :field.mfem; export import :utils.domain; @@ -141,8 +142,7 @@ export namespace mean_field::deformation { m_interiorSurfacePullbackWorkspace(surfaceDisplacementSize()), m_vacuumSurfacePullbackWorkspace(surfaceDisplacementSize()), m_surfacePullbackWorkspace(surfaceDisplacementSize()), - m_parameterPullbackWorkspace(parameterCount()), - m_volumeGridFunctionWorkspace(std::make_unique(&volumeDisplacementSpace)) { + m_parameterPullbackWorkspace(parameterCount()) { validateCompatibility(surfaceScalarSpace, volumeDisplacementSpace, logicalReferenceMesh); compileOwnership(); @@ -322,55 +322,8 @@ export namespace mean_field::deformation { requireCurrentDiscretization(); requireVolumeSize(volumeDisplacement); - m_volumeGridFunctionWorkspace->SetFromTrueDofs(volumeDisplacement); - mfem::Mesh *mesh = m_volumeDisplacementSpace->GetMesh(); - double localMinimumDeterminant = std::numeric_limits::infinity(); - int localGeometryIsFinite = 1; - - for (int element = 0; element < mesh->GetNE(); ++element) { - mfem::ElementTransformation *transformation = mesh->GetElementTransformation(element); - const mfem::FiniteElement *finiteElement = m_volumeDisplacementSpace->GetFE(element); - // Positivity is a pointwise geometry requirement, not an - // integration-accuracy requirement. A rule only slightly - // above the displacement order can miss a narrow negative - // region of the determinant even when a downstream physics - // rule samples it. The determinant of a d-dimensional - // degree-p deformation gradient can vary at substantially - // higher order, so inspect at a conservative d*p scale. - const int geometryInspectionOrder = - std::max(finiteElement->GetOrder() + 2, 2 * spatialDimension() * finiteElement->GetOrder()); - const mfem::IntegrationRule &rule = - mfem::IntRules.Get(transformation->GetGeometryType(), geometryInspectionOrder); - - for (int point = 0; point < rule.GetNPoints(); ++point) { - transformation->SetIntPoint(&rule.IntPoint(point)); - mfem::DenseMatrix deformationGradient; - m_volumeGridFunctionWorkspace->GetVectorGradient(*transformation, deformationGradient); - for (int component = 0; component < spatialDimension(); ++component) { - deformationGradient(component, component) += 1.0; - } - const double determinant = deformationGradient.Det(); - if (!std::isfinite(determinant)) { - localGeometryIsFinite = 0; - } else { - localMinimumDeterminant = std::min(localMinimumDeterminant, determinant); - } - } - } - - double globalMinimumDeterminant = 0.0; - int globalGeometryIsFinite = 0; - MPI_Allreduce( - &localMinimumDeterminant, &globalMinimumDeterminant, 1, MPI_DOUBLE, MPI_MIN, - m_volumeDisplacementSpace->GetComm() - ); - MPI_Allreduce( - &localGeometryIsFinite, &globalGeometryIsFinite, 1, MPI_INT, MPI_MIN, - m_volumeDisplacementSpace->GetComm() - ); - if (globalGeometryIsFinite == 0) { - globalMinimumDeterminant = std::numeric_limits::quiet_NaN(); - } + const double globalMinimumDeterminant = + m_geometryInspector.Inspect(*m_volumeDisplacementSpace, volumeDisplacement); ++m_actionStatistics.geometryInspections; return {.minimumJacobianDeterminant = globalMinimumDeterminant}; } @@ -579,7 +532,7 @@ export namespace mean_field::deformation { mutable mfem::Vector m_vacuumSurfacePullbackWorkspace; mutable mfem::Vector m_surfacePullbackWorkspace; mutable mfem::Vector m_parameterPullbackWorkspace; - mutable std::unique_ptr m_volumeGridFunctionWorkspace; + mutable fem::PreparedGeometryInspector m_geometryInspector; }; template < diff --git a/libmeanfield/interface/fem/reference_tables.cppm b/libmeanfield/interface/fem/reference_tables.cppm index 2686e8f..11ee7eb 100644 --- a/libmeanfield/interface/fem/reference_tables.cppm +++ b/libmeanfield/interface/fem/reference_tables.cppm @@ -1,5 +1,7 @@ module; +#include +#include #include #include @@ -19,7 +21,37 @@ export namespace mean_field::fem { [[nodiscard]] int GetDofCount() const; [[nodiscard]] int GetDimension() const; + // Available only for a verified nodal H1/L2 hexahedron and Cartesian quadrature + // ordering (x fastest). Results have columns [value, dx, dy, dz] for + // each input component. Workspace is caller-owned; tables stay immutable. + [[nodiscard]] bool HasTensorEvaluation3D() const noexcept; + void EvaluateTensor3D( + const mfem::DenseMatrix &elementDofs, + mfem::DenseMatrix &valuesAndGradients, + mfem::Vector &workspace + ) const; + + // Transpose of the reference gradient. Input columns are [dx,dy,dz] + // per output component. Callers supply all physical factors and weights. + void ApplyGradientTransposeTensor3D(const mfem::DenseMatrix &quadratureDuals, + mfem::DenseMatrix &elementDuals, + mfem::Vector &workspace) const; + + // Value-only scalar contractions avoid forming unused gradients. + void EvaluateValuesTensor3D(const mfem::Vector &elementDofs, mfem::Vector &values, + mfem::Vector &workspace) const; + void ApplyTransposeTensor3D(const mfem::Vector &quadratureDuals, mfem::Vector &elementDuals, + mfem::Vector &workspace) const; + + // Apply the transpose of the value basis to quadrature duals. No weights + // are introduced here; callers supply their already weighted duals. + void ApplyTransposeTensor3D( + const mfem::DenseMatrix &quadratureDuals, mfem::DenseMatrix &elementDuals, + mfem::Vector &workspace + ) const; + private: + void PrepareTensorEvaluation3D(const mfem::FiniteElement &element, const mfem::IntegrationRule &rule); friend class ReferenceTableCache; ScalarReferenceTable( const mfem::FiniteElement &element, @@ -29,6 +61,11 @@ export namespace mean_field::fem { mfem::DenseMatrix m_values; std::vector m_gradients; int m_dimension; + int m_tensorDofsPerAxis{0}; + int m_tensorPointsPerAxis{0}; + mfem::Array m_tensorDofMap; + std::array m_tensorValues; + std::array m_tensorGradients; }; class VectorReferenceTable { @@ -37,8 +74,16 @@ export namespace mean_field::fem { [[nodiscard]] int GetPointCount() const; [[nodiscard]] int GetDofCount() const; [[nodiscard]] int GetDimension() const; + [[nodiscard]] bool HasTensorEvaluation3D() const noexcept; + void EvaluateTensor3D( + const mfem::Vector &elementDofs, mfem::DenseMatrix &values, mfem::Vector &workspace + ) const; + void ApplyTransposeTensor3D( + const mfem::DenseMatrix &quadratureDuals, mfem::Vector &elementDuals, mfem::Vector &workspace + ) const; private: + void PrepareTensorEvaluation3D(const mfem::FiniteElement &element, const mfem::IntegrationRule &rule); friend class ReferenceTableCache; VectorReferenceTable( const mfem::FiniteElement &element, @@ -48,6 +93,9 @@ export namespace mean_field::fem { std::vector m_values; int m_dof_count; int m_dimension; + int m_tensorClosedDofs{0}, m_tensorPointsPerAxis{0}; + mfem::Array m_tensorDofMap; + std::array, 3> m_tensorValues; }; // Owned by one discretization, never process-global. Finite elements must @@ -75,4 +123,29 @@ export namespace mean_field::fem { struct Storage; std::unique_ptr m_storage; }; + // The production mapped-geometry inspection, independently benchmarkable. + // Keeps the original conservative rule and checks every volume element. + // Mesh/FES/node sequences invalidate the fixed inverse-Jacobian cache; + // raw node edits require Mesh::NodesUpdated() or ClearCache(). Displacement + // is always re-evaluated. Like prepared operators, scratch is not thread-safe. + class PreparedGeometryInspector { + public: + PreparedGeometryInspector(); + ~PreparedGeometryInspector(); + PreparedGeometryInspector(const PreparedGeometryInspector &) = delete; + PreparedGeometryInspector &operator=(const PreparedGeometryInspector &) = delete; + PreparedGeometryInspector(PreparedGeometryInspector &&) noexcept; + PreparedGeometryInspector &operator=(PreparedGeometryInspector &&) noexcept; + + // Collective minimum of det(I + grad(u)); NaN if any sampled value is + // non-finite. reference=true retains GridFunction::GetVectorGradient. + [[nodiscard]] double Inspect(mfem::ParFiniteElementSpace &space, + const mfem::Vector &displacement, bool reference = false) const; + void ClearCache() const; + [[nodiscard]] int GetTensorElementCount() const noexcept; + [[nodiscard]] std::uint64_t GetGeometryBuildCount() const noexcept; + private: + struct Storage; + std::unique_ptr m_storage; + }; } // namespace mean_field::fem diff --git a/libmeanfield/interface/mapping/compactification/kelvin.cppm b/libmeanfield/interface/mapping/compactification/kelvin.cppm index b5a669a..4b434eb 100644 --- a/libmeanfield/interface/mapping/compactification/kelvin.cppm +++ b/libmeanfield/interface/mapping/compactification/kelvin.cppm @@ -34,6 +34,16 @@ export namespace mean_field::mapping::compactification { [[nodiscard]] double GetReferenceInfinityRadius() const noexcept; [[nodiscard]] double GetCoordinateTolerance() const noexcept; + // Factors in delta J = scale * grad(delta u) + delta u (grad scale)^T. + // They depend only on the fixed compactification coordinate, so prepared + // operators can compute them once instead of evaluating the full map. + [[nodiscard]] MappingStatus EvaluateJacobianVariationFactors( + double coordinate, + const mfem::Vector &coordinateGradient, + double &scale, + mfem::Vector &scaleGradient + ) const; + private: struct RadialFactors { double coordinate; diff --git a/libmeanfield/interface/mapping/domain_mapper.cppm b/libmeanfield/interface/mapping/domain_mapper.cppm index 7f915d6..ced4496 100644 --- a/libmeanfield/interface/mapping/domain_mapper.cppm +++ b/libmeanfield/interface/mapping/domain_mapper.cppm @@ -1,6 +1,8 @@ module; #include "mean_field.h" +#include +#include export module mean_field:mapping.domain_mapper; export import :mapping.types; @@ -108,8 +110,35 @@ export namespace mean_field::mapping { DomainMapper(const DomainMapper &) = delete; DomainMapper &operator=(const DomainMapper &) = delete; - DomainMapper(DomainMapper &&) = default; - DomainMapper &operator=(DomainMapper &&) = default; + ~DomainMapper(); + DomainMapper(DomainMapper &&) noexcept; + DomainMapper &operator=(DomainMapper &&) noexcept; + + struct FixedMeshGeometry { + mfem::Vector position; + mfem::DenseMatrix jacobian, inverseJacobian; + double weight{0.0}; + }; + struct GeometryCacheStatistics { + std::uint64_t hits{0}, misses{0}; + std::size_t points{0}; + }; + // Only computational-mesh geometry is cached, never displacement or + // compactification state. MFEM mesh/node sequence changes invalidate it. + // After raw coordinate edits call Mesh::NodesUpdated(), as required by + // MFEM's own geometric-factor cache, or ClearGeometryCache(). A mesh + // must outlive its cached entries; clear before destroying/replacing it. + // Like MFEM element transformations, this cache is not thread-safe. + void SetGeometryCacheEnabled(bool enabled) const; + [[nodiscard]] bool IsGeometryCacheEnabled() const noexcept; + void ClearGeometryCache() const; + [[nodiscard]] GeometryCacheStatistics GetGeometryCacheStatistics() const; + // Sets the transformation's integration point. nullptr means use MFEM + // directly (disabled cache, non-mesh transformation, or unsupported data). + // Returned data remains valid until cache invalidation or mapper destruction. + [[nodiscard]] const FixedMeshGeometry *GetFixedMeshGeometry( + mfem::ElementTransformation &transformation, const mfem::IntegrationPoint &point + ) const; [[nodiscard]] MappingStatus EvaluatePoint( const ElementMappingData &element_data, @@ -119,12 +148,19 @@ export namespace mean_field::mapping { MappingPointContext &context ) const; + // A sample from the same element DOFs and integration point. Its + // gradient is with respect to finite-element coordinates, not the mesh. + struct ReferenceDisplacementSample { + const mfem::Vector &value; + const mfem::DenseMatrix &gradient; + }; [[nodiscard]] MappingStatus EvaluateVolume( const ElementMappingData &element_data, mfem::ElementTransformation &transformation, const mfem::IntegrationPoint &integration_point, Workspace &workspace, - VolumeMappingContext &context + VolumeMappingContext &context, + const ReferenceDisplacementSample *displacementSample = nullptr ) const; [[nodiscard]] MappingStatus EvaluateFace( @@ -172,6 +208,12 @@ export namespace mean_field::mapping { [[nodiscard]] const compactification::ExteriorDomainMap &GetExteriorMap() const noexcept; private: + [[nodiscard]] MappingStatus EvaluatePointImpl( + const ElementMappingData &element_data, mfem::ElementTransformation &transformation, + const mfem::IntegrationPoint &integration_point, Workspace &workspace, + MappingPointContext &context, const FixedMeshGeometry *meshGeometry, + const ReferenceDisplacementSample *displacementSample = nullptr + ) const; void ValidateElementData(const ElementMappingData &element_data) const; void EvaluateField( @@ -214,6 +256,8 @@ export namespace mean_field::mapping { FaceElementSide side ); + struct GeometryCache; + mutable std::unique_ptr m_geometryCache; utils::DomainMapperOptions m_options; std::unique_ptr m_exterior_map; }; diff --git a/libmeanfield/interface/normalization/stellar_equilibrium.cppm b/libmeanfield/interface/normalization/stellar_equilibrium.cppm index 5600cbe..05d7cfd 100644 --- a/libmeanfield/interface/normalization/stellar_equilibrium.cppm +++ b/libmeanfield/interface/normalization/stellar_equilibrium.cppm @@ -1,6 +1,7 @@ module; #include +#include #include #include #include @@ -22,9 +23,45 @@ export import :utils.domain; namespace mean_field::normalization::detail { using DomainSchema = utils::domain::CoreEnvelopeVacuumDomainSchema; + [[nodiscard]] inline bool CanAssembleExactPartialDiagonal(mfem::ParFiniteElementSpace &space, bool hdiv) { + // For hanging nodes, diag(P^T A P) contains cross terms. MFEM's PA + // diagonal is approximate there, so retain assembled exact diagonals. + int eligible = space.GetMesh()->Conforming() && !space.IsVariableOrder() && space.GetVDim() == 1; + for (int e = 0; eligible && e < space.GetNE(); ++e) { + const auto *fe = space.GetFE(e); + eligible = fe->GetGeomType() == mfem::Geometry::CUBE && + (hdiv ? dynamic_cast(fe) != nullptr : + dynamic_cast(fe) != nullptr); + } + int allEligible = 0; + if (MPI_Allreduce(&eligible, &allEligible, 1, MPI_INT, MPI_MIN, space.GetComm()) != MPI_SUCCESS) + throw std::runtime_error("Could not choose a collective normalization assembly path."); + return allEligible != 0; + } + + [[nodiscard]] inline mfem::Vector AssembleMassDiagonal( + mfem::ParBilinearForm &mass, mfem::ParFiniteElementSpace &space, bool partial + ) { + if (partial) { + mass.SetAssemblyLevel(mfem::AssemblyLevel::PARTIAL); + mass.Assemble(); + mfem::Vector diagonal(space.GetTrueVSize()); + mass.AssembleDiagonal(diagonal); + return diagonal; + } + mass.Assemble(); + mass.Finalize(); + std::unique_ptr matrix(mass.ParallelAssemble()); + if (!matrix) throw std::runtime_error("Reference Riesz mass assembly failed."); + mfem::Vector diagonal; + matrix->GetDiag(diagonal); + return diagonal; + } + [[nodiscard]] inline mfem::Vector AssembleScalarMassDiagonal( mfem::ParFiniteElementSpace &space, - mfem::Array *domainMarker = nullptr + mfem::Array *domainMarker = nullptr, + bool reference = false ) { mfem::ParBilinearForm mass(&space); if (domainMarker == nullptr) { @@ -32,34 +69,80 @@ namespace mean_field::normalization::detail { } else { mass.AddDomainIntegrator(new mfem::MassIntegrator(), *domainMarker); } - mass.Assemble(); - mass.Finalize(); - std::unique_ptr matrix(mass.ParallelAssemble()); - if (matrix == nullptr) { - throw std::runtime_error("Reference scalar Riesz mass assembly failed."); - } - mfem::Vector diagonal; - matrix->GetDiag(diagonal); - return diagonal; + return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, false)); } - [[nodiscard]] inline mfem::Vector AssembleHDivMassDiagonal(mfem::ParFiniteElementSpace &space) { + [[nodiscard]] inline mfem::Vector AssembleHDivMassDiagonal(mfem::ParFiniteElementSpace &space, bool reference = false) { mfem::ParBilinearForm mass(&space); mass.AddDomainIntegrator(new mfem::VectorFEMassIntegrator()); - mass.Assemble(); - mass.Finalize(); - std::unique_ptr matrix(mass.ParallelAssemble()); - if (matrix == nullptr) { - throw std::runtime_error("Reference H(div) Riesz mass assembly failed."); + return AssembleMassDiagonal(mass, space, !reference && CanAssembleExactPartialDiagonal(space, true)); + } + + [[nodiscard]] inline bool CanAssembleDirectBoundaryDiagonal( + mfem::ParFiniteElementSpace &space, int attribute + ) { + int eligible = !space.Nonconforming() && !space.IsVariableOrder() && space.GetVDim() == 1 && + dynamic_cast(space.FEColl()) != nullptr; + mfem::Array dofs; + for (int b = 0; eligible && b < space.GetNBE(); ++b) { + if (space.GetBdrAttribute(b) != attribute) continue; + // A general DOF transformation would require transforming the + // full element matrix before extracting its diagonal. + eligible = space.GetBdrElementVDofs(b, dofs) == nullptr; } - mfem::Vector diagonal; - matrix->GetDiag(diagonal); - return diagonal; + int allEligible = 0; + if (MPI_Allreduce(&eligible, &allEligible, 1, MPI_INT, MPI_MIN, space.GetComm()) != MPI_SUCCESS) + throw std::runtime_error("Could not choose a collective surface normalization assembly path."); + return allEligible != 0; + } + + [[nodiscard]] inline mfem::Vector AssembleDirectBoundaryDiagonal( + mfem::ParFiniteElementSpace &space, int attribute + ) { + mfem::Vector localDiagonal(space.GetVSize()); + localDiagonal = 0.0; + mfem::Array dofs; + mfem::Vector shape, elementDiagonal; + // GetNBE includes tagged internal interfaces. MFEM's boundary PA face + // restriction uses FaceType::Boundary and omits the star/vacuum interface. + for (int b = 0; b < space.GetNBE(); ++b) { + if (space.GetBdrAttribute(b) != attribute) continue; + const auto &element = *space.GetBE(b); + auto &transformation = *space.GetBdrElementTransformation(b); + space.GetBdrElementVDofs(b, dofs); + shape.SetSize(element.GetDof()); + elementDiagonal.SetSize(element.GetDof()); + elementDiagonal = 0.0; + // Identical rule and physical shapes to MassIntegrator's default + // element assembly; only the diagonal of w * shape * shape^T is formed. + const auto &rule = mfem::MassIntegrator::GetRule(element, element, transformation); + for (int q = 0; q < rule.GetNPoints(); ++q) { + const auto &point = rule.IntPoint(q); + transformation.SetIntPoint(&point); + element.CalcPhysShape(transformation, shape); + const double weight = transformation.Weight() * point.weight; + for (int i = 0; i < element.GetDof(); ++i) + elementDiagonal(i) += weight * shape(i) * shape(i); + } + for (int i = 0; i < dofs.Size(); ++i) { + const int dof = dofs[i] < 0 ? -1 - dofs[i] : dofs[i]; + localDiagonal(dof) += elementDiagonal(i); // orientation signs are squared + } + } + mfem::Vector trueDiagonal(space.GetTrueVSize()); + // Conforming H1 prolongation only copies scalar DOFs, so summing the + // local diagonal gives diag(P^T A P) exactly, including shared DOFs. + if (const auto *prolongation = space.GetProlongationMatrix()) + prolongation->MultTranspose(localDiagonal, trueDiagonal); + else + trueDiagonal = localDiagonal; + return trueDiagonal; } [[nodiscard]] inline mfem::Vector AssembleSurfaceMassDiagonal( const fem::FEM &finiteElements, - const field::ScalarBoundaryDofMap &surfaceMap + const field::ScalarBoundaryDofMap &surfaceMap, + bool reference = false ) { mfem::Array marker(finiteElements.mesh->bdr_attributes.Max()); marker = 0; @@ -69,16 +152,12 @@ namespace mean_field::normalization::detail { } marker[attribute - 1] = 1; + if (!reference && CanAssembleDirectBoundaryDiagonal(*finiteElements.surfaceDeformationFes, attribute)) + return surfaceMap.gather(AssembleDirectBoundaryDiagonal(*finiteElements.surfaceDeformationFes, attribute)); + mfem::ParBilinearForm mass(finiteElements.surfaceDeformationFes.get()); mass.AddBoundaryIntegrator(new mfem::MassIntegrator(), marker); - mass.Assemble(); - mass.Finalize(); - std::unique_ptr matrix(mass.ParallelAssemble()); - if (matrix == nullptr) { - throw std::runtime_error("Reference surface Riesz mass assembly failed."); - } - mfem::Vector ambientDiagonal; - matrix->GetDiag(ambientDiagonal); + auto ambientDiagonal = AssembleMassDiagonal(mass, *finiteElements.surfaceDeformationFes, false); return surfaceMap.gather(ambientDiagonal); } @@ -100,6 +179,19 @@ namespace mean_field::normalization::detail { } // namespace mean_field::normalization::detail export namespace mean_field::normalization { + // Isolated verification/profiling entry points. reference=true retains the + // full assembled true-DOF diagonal; production uses exact PA for supported + // volume spaces and direct marked-boundary quadrature for supported surfaces. + [[nodiscard]] inline mfem::Vector AssembleReferenceScalarMassDiagonal( + mfem::ParFiniteElementSpace &space, mfem::Array *domainMarker = nullptr, bool reference = false + ) { return detail::AssembleScalarMassDiagonal(space, domainMarker, reference); } + [[nodiscard]] inline mfem::Vector AssembleReferenceHDivMassDiagonal( + mfem::ParFiniteElementSpace &space, bool reference = false + ) { return detail::AssembleHDivMassDiagonal(space, reference); } + [[nodiscard]] inline mfem::Vector AssembleReferenceSurfaceMassDiagonal( + const fem::FEM &finiteElements, const field::ScalarBoundaryDofMap &surfaceMap, bool reference = false + ) { return detail::AssembleSurfaceMassDiagonal(finiteElements, surfaceMap, reference); } + /* * Runtime preparation paired with the compile-time normalization plan. * The operator compiler is the authority for which blocks a specification diff --git a/libmeanfield/interface/operators/prepared_angular_momentum.cppm b/libmeanfield/interface/operators/prepared_angular_momentum.cppm index 1674d65..2c04d84 100644 --- a/libmeanfield/interface/operators/prepared_angular_momentum.cppm +++ b/libmeanfield/interface/operators/prepared_angular_momentum.cppm @@ -156,6 +156,16 @@ export namespace mean_field::operators { void BuildResidual(mfem::Vector &residual) const; + // Rows differentiate I_axis; changing Omega alone reuses both rows. + [[nodiscard]] bool PrepareJacobianRows() const; + [[nodiscard]] std::uint64_t GetJacobianRowPreparationCount() const noexcept; + void ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + double angularVelocityVariation, + mfem::Vector &action + ) const; + void ApplyDensityJacobianAction( const mfem::Vector &densityVariation, mfem::Vector &action @@ -223,6 +233,8 @@ export namespace mean_field::operators { [[nodiscard]] double EvaluateDensityMomentActionLocal(const mfem::Vector &densityVariation) const; [[nodiscard]] double EvaluateDisplacementMomentActionLocal(const mfem::Vector &displacementVariation) const; + [[nodiscard]] double EvaluateDensityMomentActionLocalReference(const mfem::Vector &densityVariation) const; + [[nodiscard]] double EvaluateDisplacementMomentActionLocalReference(const mfem::Vector &displacementVariation) const; [[nodiscard]] double CylindricalRadiusSquared(const mfem::Vector &physicalPosition) const noexcept; [[nodiscard]] double CylindricalRadiusSquaredVariation( const mfem::Vector &physicalPosition, @@ -238,6 +250,11 @@ export namespace mean_field::operators { std::vector m_elements; AngularMomentumDependencies m_preparedDependencies; mfem::Vector m_cachedResidual; + mutable mfem::Vector m_densityJacobianLocal; + mutable mfem::Vector m_displacementJacobianLocal; + mutable bool m_jacobianRowsReady{false}; + mutable bool m_jacobianRowsSupported{false}; + mutable std::uint64_t m_jacobianRowPreparationCount{0}; mutable mfem::Vector m_densityVariationTrue; mutable mfem::Vector m_displacementVariationTrue; mutable mfem::Vector m_densityVariationLocal; diff --git a/libmeanfield/interface/operators/prepared_barotropic_closure_operator.cppm b/libmeanfield/interface/operators/prepared_barotropic_closure_operator.cppm index a5dcbac..e73f1ff 100644 --- a/libmeanfield/interface/operators/prepared_barotropic_closure_operator.cppm +++ b/libmeanfield/interface/operators/prepared_barotropic_closure_operator.cppm @@ -82,6 +82,20 @@ export namespace mean_field::operators { mfem::Vector &action ) const override; + // Supported-coordinate coupling actions; omit identically absent blocks. + void ApplyEnthalpyJacobianAction(const mfem::Vector &enthalpyVariation, mfem::Vector &action, + bool useTensorPath = true) const; + void ApplyDisplacementJacobianAction(const mfem::Vector &displacementVariation, mfem::Vector &action, + bool useTensorPath = true) const; + // Retained complete action for isolation/reference comparisons. + void MultReference(const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action) const; + + // Previous optimized pointwise actions, sharing exactly the same state. + void MultCachedReference(const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action) const; + [[nodiscard]] int GetTensorElementCount() const noexcept; + void BuildResidual(mfem::Vector &residual) const; // Exact diagonal of the prepared density-to-closure block, expressed @@ -118,15 +132,19 @@ export namespace mean_field::operators { void VerifyPrepared() const; + void MultImpl(const mfem::Vector &densityVariation, const mfem::Vector &enthalpyVariation, + const mfem::Vector &displacementVariation, mfem::Vector &action, bool useFastPath, bool useTensorPath) const; + void ApplyThermodynamicActionFull( - const mfem::Vector &densityVariationTrue, + const mfem::Vector *densityVariationTrue, const mfem::Vector &enthalpyVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, bool useTensorPath = true ) const; void ApplyDisplacementActionFull( const mfem::Vector &displacementVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + bool useFastPath = true, bool useTensorPath = true ) const; struct ElementPAData { @@ -180,6 +198,8 @@ export namespace mean_field::operators { mutable mfem::Vector m_quadratureDisplacementAction; mutable mfem::Vector m_elementDisplacementAction; mutable mfem::DenseMatrix m_referenceDisplacementJacobian; + mutable mfem::DenseMatrix m_tensorDisplacement; + mutable mfem::Vector m_tensorWorkspace; std::uint64_t m_preparationCount{0}; bool m_isPrepared{false}; diff --git a/libmeanfield/interface/operators/prepared_gravity_displacement_force.cppm b/libmeanfield/interface/operators/prepared_gravity_displacement_force.cppm index 651d4ee..8ea6c0d 100644 --- a/libmeanfield/interface/operators/prepared_gravity_displacement_force.cppm +++ b/libmeanfield/interface/operators/prepared_gravity_displacement_force.cppm @@ -66,6 +66,9 @@ export namespace mean_field::operators { kernels::GravityDisplacementForceRejection> TryPrepare(); + // Retained two-pass preparation for isolation benchmarks and validation. + PreparedGravityDisplacementForceReport PrepareReference(); + void BuildResidual(mfem::Vector &residual) const; void ApplyDensityJacobianAction( @@ -90,6 +93,31 @@ export namespace mean_field::operators { mfem::Vector &action ) const; + void ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + const mfem::Vector &gravityGradientVariation, + mfem::Vector &action + ) const; + + void ApplyCompleteJacobianActionCachedReference( + const mfem::Vector &densityVariation, const mfem::Vector &displacementVariation, + const mfem::Vector &gravityGradientVariation, mfem::Vector &action + ) const; + [[nodiscard]] int GetTensorElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) + if (data.fastApplication && data.displacementReferenceTable->HasTensorEvaluation3D() && + data.gravityReferenceTable && data.gravityReferenceTable->HasTensorEvaluation3D()) ++count; + return count; + } + + [[nodiscard]] int GetFastElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) count += data.fastApplication; + return count; + } + [[nodiscard]] bool IsPrepared() const noexcept; [[nodiscard]] std::uint64_t GetResidualPreparationCount() const noexcept; @@ -124,16 +152,20 @@ export namespace mean_field::operators { [[nodiscard]] std::expected< void, kernels::GravityDisplacementForceRejection> - TryPrepareElementData(); + TryPrepareElementData(bool assembleResidual); + [[nodiscard]] std::expected + TryPrepareImpl(bool useFastPreparation); void ApplyPreparedCompleteJacobianActionTrue( const mfem::Vector &densityVariationTrue, const mfem::Vector &displacementVariationTrue, const mfem::Vector &gravityGradientVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + bool useFastPath = true, bool useTensorPath = true ) const; struct ElementPAData { int elementId{-1}; + bool fastApplication{false}; mfem::Array densityDofs; mfem::Array gravityGradientDofs; mfem::Array displacementDofs; @@ -168,6 +200,8 @@ export namespace mean_field::operators { mutable mfem::Vector m_gravityGradientVariationLocal; mutable mfem::Vector m_displacementVariationLocal; mutable mfem::Vector m_localAction; + mutable mfem::Vector m_quadratureDensityVariation; + mutable mfem::DenseMatrix m_quadratureForce; mutable mfem::Vector m_elementDensityVariation; mutable mfem::Vector m_elementGravityGradientVariation; mutable mfem::Vector m_elementDisplacementVariation; @@ -199,6 +233,9 @@ export namespace mean_field::operators { mutable PreparedGravityDisplacementForceCompleteStatistics m_completeJacobianStatistics; + mutable mfem::DenseMatrix m_tensorDisplacement, m_tensorGravity, m_tensorElementDual; + mutable mfem::Vector m_tensorWorkspace; + bool m_preparedWithFastResidual{true}; bool m_isPrepared{false}; }; diff --git a/libmeanfield/interface/operators/prepared_gravity_source.cppm b/libmeanfield/interface/operators/prepared_gravity_source.cppm index 488495e..e63cc0b 100644 --- a/libmeanfield/interface/operators/prepared_gravity_source.cppm +++ b/libmeanfield/interface/operators/prepared_gravity_source.cppm @@ -38,6 +38,8 @@ export namespace mean_field::operators { void Prepare(const mfem::Vector &displacement); void PreparePrimal(const mfem::Vector &displacement); + // Previous weight-recomputation path for isolated preparation comparisons. + void PrepareReference(const mfem::Vector &displacement); [[nodiscard]] GravitySourcePreparationResult TryPrepare(const mfem::Vector &displacement); [[nodiscard]] GravitySourcePreparationResult TryPreparePrimal(const mfem::Vector &displacement); void Mult( @@ -64,6 +66,11 @@ export namespace mean_field::operators { } } + void MultDisplacementVariationTrueReference( + const mfem::Vector &densityTrue, const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue) const; + [[nodiscard]] int GetTensorVariationElementCount() const noexcept; + void MultTranspose( const mfem::Vector &potential, mfem::Vector &action @@ -109,7 +116,7 @@ export namespace mean_field::operators { [[nodiscard]] GravitySourcePreparationResult TryPrepareImpl( const mfem::Vector &displacement, - PreparationMode mode + PreparationMode mode, bool useCachedMeshWeight = true ); const fem::FEM &m_fem; @@ -137,6 +144,11 @@ export namespace mean_field::operators { mutable mfem::Vector m_element_displacement_variation; mutable mfem::Vector m_quadrature_variation_action; mutable mfem::Vector m_element_variation_action; + void MultDisplacementVariationTrueImpl(const mfem::Vector &densityTrue, + const mfem::Vector &displacementVariationTrue, mfem::Vector &actionVariationTrue, + bool useTensorPath) const; + mutable mfem::DenseMatrix m_tensorSamples; + mutable mfem::Vector m_tensorWorkspace; mutable mfem::DenseMatrix m_reference_displacement_jacobian; mfem::Vector m_displacement_true; diff --git a/libmeanfield/interface/operators/prepared_hdiv_mass.cppm b/libmeanfield/interface/operators/prepared_hdiv_mass.cppm index b6b692d..f4fd28f 100644 --- a/libmeanfield/interface/operators/prepared_hdiv_mass.cppm +++ b/libmeanfield/interface/operators/prepared_hdiv_mass.cppm @@ -44,11 +44,29 @@ export namespace mean_field::operators { const mfem::Vector &gravity_gradient, mfem::Vector &action ) const override; + // Unmodified MFEM partial-assembly application on the same prepared data. + void MultReference(const mfem::Vector &gravityGradient, mfem::Vector &action) const; + [[nodiscard]] int GetFastMassElementCount() const noexcept { return m_fastMassElements; } + void MultDisplacementVariationTrue( const mfem::Vector &gravityGradientTrue, const mfem::Vector &displacementVariationTrue, mfem::Vector &actionVariationTrue ) const; + // Retained prepared implementation for isolated A/B validation and + // profiling. Uses the same prepared state, quadrature and DOF maps. + void MultDisplacementVariationTrueReference( + const mfem::Vector &gravityGradientTrue, + const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue + ) const; + // Previous optimized pointwise path, excluding the new tensor contractions. + void MultDisplacementVariationTrueCachedReference( + const mfem::Vector &gravityGradientTrue, const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue + ) const; + [[nodiscard]] int GetFastVariationElementCount() const noexcept; + [[nodiscard]] int GetTensorVariationElementCount() const noexcept; void AssembleDiagonal(mfem::Vector &diagonal) const override; void AssembleTrueDiagonal(mfem::Vector &diagonal) const; @@ -79,6 +97,11 @@ export namespace mean_field::operators { mfem::Vector compactification; const mfem::IntegrationRule *integrationRule{nullptr}; std::shared_ptr gravityReferenceTable; + std::shared_ptr displacementReferenceTable; + // One contiguous column per quadrature point: scale*J_mesh^-1 + // (row-major, nine entries), followed by grad(scale). 3D VALUE + // displacement with an interior or Kelvin map; empty otherwise. + mfem::DenseMatrix tangentFactors; // Fixed computational-mesh Piola factor, separate from J_map. mfem::DenseMatrix meshPiolaJacobians; mfem::Vector referenceWeights; @@ -86,6 +109,13 @@ export namespace mean_field::operators { }; [[nodiscard]] mapping::MappingStatus PrepareVariationData(); + void MultDisplacementVariationTrueImpl( + const mfem::Vector &gravityGradientTrue, + const mfem::Vector &displacementVariationTrue, + mfem::Vector &actionVariationTrue, + bool useFastPath, bool useTensorPath + ) const; + void AddFastElementVariation(const ElementVariationData &data, bool useTensorPath) const; [[nodiscard]] HDivMassPreparationResult TryPrepareImpl( const mfem::Vector &displacement, PreparationMode mode @@ -129,9 +159,12 @@ export namespace mean_field::operators { mutable mfem::DenseMatrix m_meshPiolaJacobian; mutable mfem::DenseMatrix m_gravityGradientShape; mutable mfem::DenseMatrix m_massTensorVariation; + mutable mfem::DenseMatrix m_tensorDisplacement, m_tensorGravity, m_tensorDual; + mutable mfem::Vector m_tensorWorkspace; std::uint64_t m_preparation_count{0}; bool m_is_prepared{false}; bool m_has_variation_data{false}; bool m_single_rank{true}; + int m_fastMassElements{0}; }; } // namespace mean_field::operators diff --git a/libmeanfield/interface/operators/prepared_hydrostatic_equilibrium_operator.cppm b/libmeanfield/interface/operators/prepared_hydrostatic_equilibrium_operator.cppm index 922426c..bc1b428 100644 --- a/libmeanfield/interface/operators/prepared_hydrostatic_equilibrium_operator.cppm +++ b/libmeanfield/interface/operators/prepared_hydrostatic_equilibrium_operator.cppm @@ -142,6 +142,14 @@ export namespace mean_field::operators { const physics::RigidRotation &rotation ); + HydrostaticEquilibriumPreparationResult TryPrepareReference( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation); + PreparedHydrostaticEquilibriumReport PrepareReference( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation); void BuildResidual(mfem::Vector &residual) const; void ApplyEnthalpyJacobianAction( @@ -179,6 +187,18 @@ export namespace mean_field::operators { mfem::Vector &action ) const; + void ApplyDisplacementJacobianActionReference( + const mfem::Vector &displacementVariation, + mfem::Vector &action + ) const; + // Previous cached pointwise implementation, retained for benchmarking + // the tensor change separately from the older general mapping path. + void ApplyDisplacementJacobianActionCachedReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const; + [[nodiscard]] int GetFastDisplacementElementCount() const noexcept; + [[nodiscard]] int GetTensorDisplacementElementCount() const noexcept; + void ApplyCompleteJacobianAction( const mfem::Vector &enthalpyVariation, const mfem::Vector &gravityPotentialVariation, @@ -244,6 +264,10 @@ export namespace mean_field::operators { // Immutable reference values and gradients are shared by FE/rule. std::shared_ptr enthalpyReferenceTable; std::shared_ptr gravityPotentialReferenceTable; + std::shared_ptr displacementReferenceTable; + // Columns are quadrature points; each holds a column-major inverse + // deformed element Jacobian. Empty for the general mapping path. + mfem::DenseMatrix displacementInverseJacobians; [[nodiscard]] const mfem::DenseMatrix &GetEnthalpyBasis() const { return enthalpyReferenceTable->GetValues(); @@ -274,14 +298,24 @@ export namespace mean_field::operators { mfem::Vector bernoulliConstantJacobian; }; + HydrostaticEquilibriumPreparationResult TryPrepareImpl( + const context::hydrostatic::HydrostaticEquilibriumStateView &state, + const context::hydrostatic::HydrostaticEquilibriumDependencies &dependencies, + const physics::RigidRotation &rotation, bool useTensorPreparation); void PrepareStaticPlan(); - [[nodiscard]] std::optional PrepareGeometry(); - [[nodiscard]] bool PrepareAlgebraicJacobianBlocks(); + [[nodiscard]] std::optional PrepareGeometry(bool useTensorPreparation); + [[nodiscard]] bool PrepareAlgebraicJacobianBlocks(bool useTensorPreparation); [[nodiscard]] bool PrepareRotation(); - [[nodiscard]] bool PrepareBaseState(); + [[nodiscard]] bool PrepareBaseState(bool useTensorPreparation); void FinalizeDisplacementJacobianPreparation(); [[nodiscard]] bool AssembleCachedResidual(); void VerifyPrepared() const; + void ApplyDisplacementJacobianActionImpl( + const mfem::Vector &displacementVariation, mfem::Vector &action, bool useFastPath, bool useTensorPath + ) const; + + mutable mfem::DenseMatrix m_tensorDisplacementValuesAndGradients; + mutable mfem::Vector m_tensorDisplacementWorkspace; const fem::FEM &m_fem; const mapping::DomainMapper &m_domainMapper; @@ -307,6 +341,7 @@ export namespace mean_field::operators { mutable PreparedHydrostaticCompleteJacobianStatistics m_completeJacobianStatistics; + bool m_preparedWithTensorPreparation{true}; bool m_isPrepared{false}; }; diff --git a/libmeanfield/interface/operators/prepared_mass_normalization.cppm b/libmeanfield/interface/operators/prepared_mass_normalization.cppm index 8860d69..625b0c6 100644 --- a/libmeanfield/interface/operators/prepared_mass_normalization.cppm +++ b/libmeanfield/interface/operators/prepared_mass_normalization.cppm @@ -147,6 +147,16 @@ export namespace mean_field::operators { void BuildResidual(mfem::Vector &residual) const; + // Lazy, local-dual row assembly. Invalidated only by density/geometry. + // Returns false when this discretization uses the general fallback. + [[nodiscard]] bool PrepareJacobianRows() const; + [[nodiscard]] std::uint64_t GetJacobianRowPreparationCount() const noexcept; + void ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + mfem::Vector &action + ) const; + void ApplyDensityJacobianAction( const mfem::Vector &densityVariation, mfem::Vector &action @@ -230,6 +240,9 @@ export namespace mean_field::operators { [[nodiscard]] double EvaluateDisplacementActionLocal(const mfem::Vector &displacementVariation) const; + [[nodiscard]] double EvaluateDensityActionLocalReference(const mfem::Vector &densityVariation) const; + [[nodiscard]] double EvaluateDisplacementActionLocalReference(const mfem::Vector &displacementVariation) const; + void AssembleDensityTransposeAction( double residualDual, mfem::Vector &densityDual @@ -250,8 +263,16 @@ export namespace mean_field::operators { MassNormalizationDependencies m_preparedDependencies; mfem::Vector m_cachedResidual; + mutable mfem::Vector m_densityJacobianLocal; + mutable mfem::Vector m_displacementJacobianLocal; + mutable bool m_jacobianRowsReady{false}; + mutable bool m_jacobianRowsSupported{false}; + mutable std::uint64_t m_jacobianRowPreparationCount{0}; + mutable mfem::Vector m_densityVariationTrue; mutable mfem::Vector m_displacementVariationTrue; + mutable mfem::Vector m_densityVariationLocal; + mutable mfem::Vector m_displacementVariationLocal; double m_currentMass{0.0}; double m_targetMass{0.0}; diff --git a/libmeanfield/interface/operators/prepared_pressure_force.cppm b/libmeanfield/interface/operators/prepared_pressure_force.cppm index 6b18fc2..e9a0e67 100644 --- a/libmeanfield/interface/operators/prepared_pressure_force.cppm +++ b/libmeanfield/interface/operators/prepared_pressure_force.cppm @@ -116,6 +116,20 @@ export namespace mean_field::operators { const context::pressure_force::PressureForceDependencies &dependencies ); + // Original quadrature-by-quadrature matrix assembly for validation. + PreparedPressureForceReport PrepareReference( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies + ); + + // Previous blocked matrix preparation and pointwise field evaluation. + PreparedPressureForceReport PrepareCachedReference( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies); + void ApplyDisplacementJacobianActionCachedReference( + const mfem::Vector &displacementVariation, mfem::Vector &action) const; + [[nodiscard]] int GetTensorElementCount() const noexcept; + void BuildResidual(mfem::Vector &residual) const; void ApplyEnthalpyJacobianAction( @@ -128,6 +142,10 @@ export namespace mean_field::operators { mfem::Vector &action ) const; + void ApplyDisplacementJacobianActionReference( + const mfem::Vector &displacementVariation, mfem::Vector &action + ) const; + void ApplyCompleteJacobianAction( const mfem::Vector &enthalpyVariation, const mfem::Vector &displacementVariation, @@ -178,6 +196,8 @@ export namespace mean_field::operators { } private: + void ApplyDisplacementJacobianActionImpl(const mfem::Vector &displacementVariation, + mfem::Vector &action, bool useFastPath, bool useTensorPath = true) const; struct ConstructionData; [[nodiscard]] @@ -237,8 +257,14 @@ export namespace mean_field::operators { }; void PrepareStaticPlan(); - [[nodiscard]] std::optional PrepareGeometry(); - [[nodiscard]] std::optional PrepareMaterialState(); + [[nodiscard]] std::optional PrepareGeometry(bool useTensorPreparation); + [[nodiscard]] std::optional PrepareMaterialState(bool useFastPreparation, bool useTensorPreparation); + [[nodiscard]] std::expected + TryPrepareImpl( + const context::pressure_force::PressureForceStateView &state, + const context::pressure_force::PressureForceDependencies &dependencies, + bool useFastPreparation, bool useTensorPreparation = true + ); void FinalizeDisplacementJacobianPreparation(); @@ -291,6 +317,10 @@ export namespace mean_field::operators { mutable PreparedPressureForceCompleteJacobianStatistics m_completeJacobianStatistics; + mutable mfem::DenseMatrix m_tensorSamples, m_tensorDuals, m_tensorElement; + mutable mfem::Vector m_tensorWorkspace; + bool m_preparedWithTensorPreparation{true}; + bool m_preparedWithFastMaterialAssembly{true}; bool m_isPrepared{false}; }; diff --git a/libmeanfield/interface/operators/prepared_rotation_displacement_force.cppm b/libmeanfield/interface/operators/prepared_rotation_displacement_force.cppm index a4aeb5f..67c3c4a 100644 --- a/libmeanfield/interface/operators/prepared_rotation_displacement_force.cppm +++ b/libmeanfield/interface/operators/prepared_rotation_displacement_force.cppm @@ -4,6 +4,7 @@ module; #include #include #include +#include #include #include @@ -11,6 +12,7 @@ module; export module mean_field:operators.prepared_rotational_displacement_force; export import :fem; +import :fem.reference_tables; export import :mapping.domain_mapper; export import :operators.context.rotational_displacement_force; export import :operators.kernels.rotational_displacement_force; @@ -72,6 +74,13 @@ export namespace mean_field::operators { const physics::RigidRotation &rotation ); + // Retained two-pass preparation for isolation benchmarks and validation. + PreparedRotationalDisplacementForceReport PrepareReference( + const context::rotational_displacement_force::RotationalDisplacementForceStateView &state, + const context::rotational_displacement_force::RotationalDisplacementForceDependencies &dependencies, + const physics::RigidRotation &rotation + ); + void BuildResidual(mfem::Vector &residual) const; void ApplyDensityJacobianAction( @@ -90,6 +99,23 @@ export namespace mean_field::operators { mfem::Vector &action ) const; + void ApplyCompleteJacobianActionReference( + const mfem::Vector &densityVariation, + const mfem::Vector &displacementVariation, + mfem::Vector &action + ) const; + + void ApplyCompleteJacobianActionCachedReference( + const mfem::Vector &densityVariation, const mfem::Vector &displacementVariation, mfem::Vector &action + ) const; + [[nodiscard]] int GetTensorElementCount() const noexcept; + + [[nodiscard]] int GetFastElementCount() const noexcept { + int count = 0; + for (const auto &data : m_elements) count += data.fastApplication; + return count; + } + [[nodiscard]] bool IsPrepared() const noexcept; [[nodiscard]] const context::rotational_displacement_force::RotationalDisplacementForcePreparationStatistics & @@ -124,20 +150,31 @@ export namespace mean_field::operators { [[nodiscard]] std::expected< void, kernels::RotationalDisplacementForceRejection> - TryPrepareElementData(); + TryPrepareElementData(bool assembleResidual); + [[nodiscard]] std::expected + TryPrepareImpl( + const context::rotational_displacement_force::RotationalDisplacementForceStateView &state, + const context::rotational_displacement_force::RotationalDisplacementForceDependencies &dependencies, + const physics::RigidRotation &rotation, + bool useFastPreparation + ); void ApplyPreparedCompleteJacobianActionTrue( const mfem::Vector &densityVariationTrue, const mfem::Vector &displacementVariationTrue, - mfem::Vector &actionTrue + mfem::Vector &actionTrue, + bool useFastPath = true, bool useTensorPath = true ) const; struct ElementPAData { int elementId{-1}; + bool fastApplication{false}; mfem::Array densityDofs; mfem::Array displacementDofs; mfem::DofTransformation *densityDofTransformation{nullptr}; mfem::DofTransformation *displacementDofTransformation{nullptr}; const mfem::IntegrationRule *integrationRule{nullptr}; + std::shared_ptr densityReferenceTable; + std::shared_ptr displacementReferenceTable; mfem::DenseMatrix inverseElementJacobians; mfem::DenseMatrix centrifugalAccelerations; mfem::Vector baseDensityValues; @@ -150,6 +187,7 @@ export namespace mean_field::operators { context::rotational_displacement_force::RotationalDisplacementForceLinearizationContext m_context; std::optional m_rotation; + mfem::DenseMatrix m_centrifugalJacobian; mfem::Vector m_cachedResidual; std::vector m_elements; mutable mfem::Vector m_densityVariationTrue; @@ -158,6 +196,11 @@ export namespace mean_field::operators { mutable mfem::Vector m_densityVariationLocal; mutable mfem::Vector m_displacementVariationLocal; mutable mfem::Vector m_localAction; + mutable mfem::Vector m_quadratureDensityVariation; + mutable mfem::DenseMatrix m_quadratureForce; + mutable mfem::DenseMatrix m_quadraturePositionVariation; + mutable mfem::DenseMatrix m_tensorDisplacement, m_tensorElement; + mutable mfem::Vector m_tensorWorkspace; mutable mfem::Vector m_elementDensityVariation; mutable mfem::Vector m_elementDisplacementVariation; mutable mfem::Vector m_elementAction; @@ -181,6 +224,7 @@ export namespace mean_field::operators { mutable PreparedRotationalDisplacementForceCompleteStatistics m_completeJacobianStatistics; + bool m_preparedWithFastResidual{true}; bool m_isPrepared{false}; }; diff --git a/libmeanfield/interface/preconditioning/gravity_field.cppm b/libmeanfield/interface/preconditioning/gravity_field.cppm index 35c7555..04d948c 100644 --- a/libmeanfield/interface/preconditioning/gravity_field.cppm +++ b/libmeanfield/interface/preconditioning/gravity_field.cppm @@ -411,9 +411,27 @@ export namespace mean_field::preconditioning { [[nodiscard]] std::unique_ptr assembleGravityDivergenceSurrogate(const fem::FEM &f); + // Fixed computational-mesh divergence, owned by one prepared block. + // FES/mesh/node sequences and policy identity invalidate it. Raw node edits + // require Mesh::NodesUpdated(). Clear explicitly if the policy changes in place. + class PreparedGravityDivergenceCache { + public: + [[nodiscard]] const mfem::HypreParMatrix &Get(const fem::FEM &f); + void Clear() noexcept { m_matrix.reset(); } + [[nodiscard]] std::uint64_t GetBuildCount() const noexcept { return m_builds; } + private: + std::unique_ptr m_matrix; + const mfem::Mesh *m_mesh{nullptr}; + const mfem::ParFiniteElementSpace *m_flux{nullptr}, *m_potential{nullptr}; + const quadrature::RuleFactory *m_policy{nullptr}; + long m_meshSequence{-1}, m_nodesSequence{-1}, m_fluxSequence{-1}, m_potentialSequence{-1}; + std::uint64_t m_builds{0}; + }; + [[nodiscard]] std::unique_ptr assembleGravityPotentialSchurSurrogate( const fem::FEM &f, - const mfem::Vector &trueMassDiagonal + const mfem::Vector &trueMassDiagonal, + PreparedGravityDivergenceCache *divergenceCache = nullptr ); struct GravityFieldBlockPreparationReport final { @@ -548,6 +566,7 @@ export namespace mean_field::preconditioning { RefreshMassInverse(geometryContext); report.rebuiltMassInverse = true; + if (report.discretizationChanged) m_fixedDivergence.Clear(); auto potentialSchur = AssemblePotentialSchur(f, geometryContext); m_potentialSchurInverse.Refresh(*potentialSchur); m_potentialSchurSurrogate = std::move(potentialSchur); @@ -586,6 +605,10 @@ export namespace mean_field::preconditioning { return m_factorization; } + [[nodiscard]] std::uint64_t GetDivergenceAssemblyCount() const noexcept { + return m_fixedDivergence.GetBuildCount(); + } + [[nodiscard]] const PreparedGravityFieldBlockStatistics &GetStatistics() const noexcept { return m_statistics; } @@ -632,13 +655,13 @@ export namespace mean_field::preconditioning { } } - [[nodiscard]] static std::unique_ptr AssemblePotentialSchur( + [[nodiscard]] std::unique_ptr AssemblePotentialSchur( const fem::FEM &f, const operators::context::gravity_field::GravityFieldGeometryContext &geometryContext ) { mfem::Vector trueMassDiagonal; geometryContext.GetMassOperator().AssembleTrueDiagonal(trueMassDiagonal); - return assembleGravityPotentialSchurSurrogate(f, trueMassDiagonal); + return assembleGravityPotentialSchurSurrogate(f, trueMassDiagonal, &m_fixedDivergence); } Block m_block; @@ -647,6 +670,7 @@ export namespace mean_field::preconditioning { field::FieldDofMap m_potentialMap; ReducedGravityDivergenceOperator m_divergence; PreparedMassInverse m_massInverse; + PreparedGravityDivergenceCache m_fixedDivergence; std::unique_ptr m_potentialSchurSurrogate; backend::PreparedHypreBoomerAMG m_potentialSchurInverse; ReducedFieldSolverAdapter m_reducedPotentialSchurInverse; diff --git a/libmeanfield/interface/preconditioning/material_surface.cppm b/libmeanfield/interface/preconditioning/material_surface.cppm index 482b946..acceebf 100644 --- a/libmeanfield/interface/preconditioning/material_surface.cppm +++ b/libmeanfield/interface/preconditioning/material_surface.cppm @@ -784,9 +784,7 @@ export namespace mean_field::preconditioning { ) const { VerifyBlock(enthalpy, 2, "enthalpy direction"); VerifyBlock(densityAction, 0, "density action"); - m_operation->GetBarotropicClosureOperator().Mult( - m_zeroDensity, enthalpy, ZeroVolumeDisplacement(), densityAction - ); + m_operation->GetBarotropicClosureOperator().ApplyEnthalpyJacobianAction(enthalpy, densityAction); } void ApplySurfaceToMaterial( @@ -798,8 +796,8 @@ export namespace mean_field::preconditioning { VerifyBlock(densityAction, 0, "density action"); VerifyBlock(enthalpyAction, 2, "enthalpy action"); GenerateDisplacement(surface); - m_operation->GetBarotropicClosureOperator().Mult( - m_zeroDensity, m_zeroEnthalpy, m_volumeDisplacement, densityAction + m_operation->GetBarotropicClosureOperator().ApplyDisplacementJacobianAction( + m_volumeDisplacement, densityAction ); m_operation->GetHydrostaticOperator().ApplyDisplacementJacobianAction(m_volumeDisplacement, enthalpyAction); m_operation->GetSurfaceConstraintOperator().ApplyJacobianRows(m_zeroEnthalpy, enthalpyAction); diff --git a/libmeanfield/interface/solver/newton.cppm b/libmeanfield/interface/solver/newton.cppm index 54e977b..0a5c199 100644 --- a/libmeanfield/interface/solver/newton.cppm +++ b/libmeanfield/interface/solver/newton.cppm @@ -32,6 +32,9 @@ export namespace mean_field::solver::nonlinear { double sufficientDecrease{1.0e-4}; double minimumStepLength{1.0e-8}; int maximumTrials{20}; + // Skipping the predictive scan retains trial mapping validation and + // ordinary backtracking. Useful for small, warm-start Newton steps. + bool geometryPreflight{true}; void Validate() const { if (!std::isfinite(initialStepLength) || initialStepLength <= 0.0) { diff --git a/libmeanfield/interface/solver/stellar_context.cppm b/libmeanfield/interface/solver/stellar_context.cppm index 732a281..4e20623 100644 --- a/libmeanfield/interface/solver/stellar_context.cppm +++ b/libmeanfield/interface/solver/stellar_context.cppm @@ -143,11 +143,12 @@ namespace mean_field::solver::detail { }; std::array minimumRealValues{}; std::array maximumRealValues{}; - const std::array localIntegerValues{ - options.maximumIterations, options.linearSolve.maximumIterations, options.backtracking.maximumTrials + const std::array localIntegerValues{ + options.maximumIterations, options.linearSolve.maximumIterations, options.backtracking.maximumTrials, + static_cast(options.backtracking.geometryPreflight) }; - std::array minimumIntegerValues{}; - std::array maximumIntegerValues{}; + std::array minimumIntegerValues{}; + std::array maximumIntegerValues{}; if (MPI_Allreduce( localRealValues.data(), minimumRealValues.data(), static_cast(localRealValues.size()), MPI_DOUBLE, @@ -341,7 +342,6 @@ export namespace mean_field::solver { ValidateInitialState(); InitializeWorkspaces(); PrepareInitialOperator(); - InitializeGeometryPreflightRules(); physicalInverse = std::unique_ptr{new PhysicalInverseType( PreparePhysicalInverse(std::move(preconditionerPrescription), RequireProblem(storage)) @@ -485,6 +485,10 @@ export namespace mean_field::solver { const double maximumStepSize, const double fractionToBoundarySafety ) { + if (!geometryPreflightRulesInitialized) { + InitializeGeometryPreflightRules(); + geometryPreflightRulesInitialized = true; + } normalizedOperator->DenormalizeState(normalizedCorrection, physicalCorrection); const auto &physicalOperator = Problem().GetPhysicalOperator(); Problem().BuildVolumeDisplacementDirection(physicalCorrection, volumeDisplacementDirection); @@ -513,6 +517,7 @@ export namespace mean_field::solver { mfem::Vector volumeDisplacementDirection; mfem::Vector candidatePhysicalState; std::vector geometryPreflightRules; + bool geometryPreflightRulesInitialized{false}; double acceptedMinimumJacobianDeterminant{std::numeric_limits::quiet_NaN()}; std::unique_ptr normalizedOperator; std::unique_ptr physicalInverse; @@ -1271,31 +1276,33 @@ export namespace mean_field::solver { } const nonlinear::MetricEvaluation previousMetric = acceptedMetric; - const Clock::time_point geometryPreflightStart = Clock::now(); - timings.geometryPreflight = state.EstimateLargestSafeStepSize( - nextLineSearchStepLength, options.backtracking.fractionToBoundarySafety - ); - timings.geometryPreflightSeconds = - std::chrono::duration(Clock::now() - geometryPreflightStart).count(); - diagnostics.totalGeometryPreflightSeconds += timings.geometryPreflightSeconds; - diagnostics.lastGeometryPreflight = timings.geometryPreflight; - if (timings.geometryPreflight->limitedByGeometry) { - ++diagnostics.geometryLimitedIterations; - } - - if (timings.geometryPreflight->stepSize < options.backtracking.minimumStepLength) { - diagnostics.finalResidualNorm = acceptedMetric.residualNorm; - NotifyAfter( - iteration, nonlinear::IterationDisposition::globalization_failure, false, 0.0, 0, - diagnostics.initialResidualNorm, previousMetric, acceptedMetric, linearReport, timings, state + if (options.backtracking.geometryPreflight) { + const Clock::time_point geometryPreflightStart = Clock::now(); + timings.geometryPreflight = state.EstimateLargestSafeStepSize( + nextLineSearchStepLength, options.backtracking.fractionToBoundarySafety ); - return Failure( - state, std::move(diagnostics), StellarEquilibriumFailureReason::globalization_failure, - "The geometry preflight found no orientation-preserving Newton step at or above the " - "configured minimum step length." - ); - } + timings.geometryPreflightSeconds = + std::chrono::duration(Clock::now() - geometryPreflightStart).count(); + diagnostics.totalGeometryPreflightSeconds += timings.geometryPreflightSeconds; + diagnostics.lastGeometryPreflight = timings.geometryPreflight; + if (timings.geometryPreflight->limitedByGeometry) { + ++diagnostics.geometryLimitedIterations; + } + if (timings.geometryPreflight->stepSize < options.backtracking.minimumStepLength) { + diagnostics.finalResidualNorm = acceptedMetric.residualNorm; + NotifyAfter( + iteration, nonlinear::IterationDisposition::globalization_failure, false, 0.0, 0, + diagnostics.initialResidualNorm, previousMetric, acceptedMetric, linearReport, timings, state + ); + return Failure( + state, std::move(diagnostics), StellarEquilibriumFailureReason::globalization_failure, + "The geometry preflight found no orientation-preserving Newton step at or above the " + "configured minimum step length." + ); + } + + } bool accepted = false; double acceptedStepLength = 0.0; int lineSearchTrials = 0; @@ -1303,7 +1310,8 @@ export namespace mean_field::solver { StellarEquilibriumFailureReason rejectionReason = StellarEquilibriumFailureReason::globalization_failure; std::string rejectionMessage = "The backtracking line search found no acceptable Newton step."; - double stepLength = timings.geometryPreflight->stepSize; + double stepLength = timings.geometryPreflight ? + timings.geometryPreflight->stepSize : nextLineSearchStepLength; const Clock::time_point lineSearchStart = Clock::now(); try { diff --git a/sandbox.cpp b/sandbox.cpp index 43450ff..fa72b14 100644 --- a/sandbox.cpp +++ b/sandbox.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -52,6 +53,20 @@ int main( int argc, char **argv ) { + bool geometryPreflight = true; + for (int i = 1; i < argc; ++i) { + const std::string_view option(argv[i]); + if (option == "--skip-preflight") geometryPreflight = false; + else if (option == "--preflight") geometryPreflight = true; + else if (option == "--help") { + std::cout << "Usage: sandbox [--skip-preflight|--preflight]\n" + "Trial geometry validation and backtracking remain enabled.\n"; + return 0; + } else { + std::cerr << "Unknown sandbox option: " << option << '\n'; + return 2; + } + } mfem::Mpi::Init(argc, argv); int exitCode = 0; @@ -77,13 +92,16 @@ int main( constexpr double radius = utils::RADIUS; constexpr double mass = utils::MASS; - constexpr double angularMomentum = 0.1; + constexpr double angularMomentum = 0.00; constexpr double gravitationalConstant = utils::G; const double polytropicConstant = 2.0 * gravitationalConstant * radius * radius / std::numbers::pi_v; + // const double polytropicConstant = 0.3639391; // Use for n=3 test + std::println("K = {}", polytropicConstant); const double centralDensity = std::numbers::pi_v * mass / (4.0 * radius * radius * radius); + //const double centralDensity =12.9351146; // use for n=3 test auto stellarModel = model::StellarModel( eos::Polytrope({.n = 1.0, .K = polytropicConstant}), @@ -201,9 +219,10 @@ int main( .absoluteTolerance = 0.0, .maximumIterations = 200 }, - .backtracking = {} + .backtracking = {.geometryPreflight = geometryPreflight} }); + if (rank == 0) std::cout << "Geometry preflight: " << (geometryPreflight ? "enabled" : "skipped") << '\n'; auto equilibriumSolver = solver::make(context, nonlinearSolver, observer); const auto evaluateStart = std::chrono::steady_clock::now(); auto report = equilibriumSolver.evaluate(); diff --git a/tests/fem/reference_tables.cpp b/tests/fem/reference_tables.cpp index 097042e..2b9de9b 100644 --- a/tests/fem/reference_tables.cpp +++ b/tests/fem/reference_tables.cpp @@ -1,5 +1,9 @@ +#include #include +#include +#include #include +#include #include #include @@ -260,3 +264,295 @@ TEST_CASE( CheckMatrixExactly(retained->GetValues(0), firstExpected); CheckMatrixExactly(retained->GetValues(retained->GetPointCount() - 1), lastExpected); } + +TEST_CASE("Tensor reference evaluation matches native H1 hex values and gradients", + tags::unit &tags::quadrature) { + for (const int order : {1, 2, 4}) { + for (const int basis : {mfem::BasisType::GaussLobatto, mfem::BasisType::ClosedUniform}) { + mfem::H1_FECollection collection(order, 3, basis); + const auto &element = *collection.FiniteElementForGeometry(mfem::Geometry::CUBE); + const auto rule = CopyRule(mfem::IntRules.Get(mfem::Geometry::CUBE, 2 * order + 3)); + const TableCache cache; + const auto table = cache.GetScalarTable(element, rule); + REQUIRE(table->HasTensorEvaluation3D()); + mfem::DenseMatrix dofs(element.GetDof(), 3), actual; + mfem::Vector workspace; + for (int c = 0; c < 3; ++c) for (int i = 0; i < element.GetDof(); ++i) + dofs(i, c) = std::sin(0.37 * (i + 1) + c) + 0.21 * std::cos(0.13 * i - c); + table->EvaluateTensor3D(dofs, actual, workspace); + REQUIRE(actual.Height() == rule.GetNPoints()); + REQUIRE(actual.Width() == 12); + for (int q = 0; q < rule.GetNPoints(); ++q) for (int c = 0; c < 3; ++c) { + for (int derivative = 0; derivative < 4; ++derivative) { + double expected = 0.0; + for (int i = 0; i < element.GetDof(); ++i) + expected += dofs(i, c) * (derivative == 0 ? table->GetValues()(q, i) : + table->GetGradients(q)(i, derivative - 1)); + CHECK(std::abs(actual(q, 4 * c + derivative) - expected) < + 2.0e-12 * std::max(1.0, std::abs(expected))); + } + } + // Reused caller scratch must not retain a previous direction. + dofs = 0.0; + table->EvaluateTensor3D(dofs, actual, workspace); + for (int c = 0; c < actual.Width(); ++c) for (int q = 0; q < actual.Height(); ++q) + CHECK(actual(q, c) == 0.0); + } + } +} + +TEST_CASE("Tensor reference evaluation rejects non-Cartesian rules and owns its data", + tags::unit &tags::quadrature) { + std::shared_ptr retained; + mfem::DenseMatrix input, expected; + { + mfem::H1_FECollection collection(3, 3); + const auto &element = *collection.FiniteElementForGeometry(mfem::Geometry::CUBE); + auto rule = CopyRule(mfem::IntRules.Get(mfem::Geometry::CUBE, 7)); + const TableCache cache; + retained = cache.GetScalarTable(element, rule); + REQUIRE(retained->HasTensorEvaluation3D()); + input.SetSize(element.GetDof(), 1); + input = 1.0; + mfem::Vector scratch; + retained->EvaluateTensor3D(input, expected, scratch); + rule.IntPoint(1).y += 0.001; + CHECK_FALSE(cache.GetScalarTable(element, rule)->HasTensorEvaluation3D()); + const auto &tet = *collection.FiniteElementForGeometry(mfem::Geometry::TETRAHEDRON); + CHECK_FALSE(cache.GetScalarTable(tet, mfem::IntRules.Get(mfem::Geometry::TETRAHEDRON, 7)) + ->HasTensorEvaluation3D()); + } + mfem::DenseMatrix actual; + mfem::Vector scratch; + retained->EvaluateTensor3D(input, actual, scratch); + CheckMatrixExactly(actual, expected); +} + +TEST_CASE("Tensor Reference Transposes Match Dense Assembly And Adjoint Products", tags::unit &tags::quadrature) { + for (int order : {1, 2, 4}) { + mfem::H1_FECollection h1(order, 3); + const auto &fe = *h1.FiniteElementForGeometry(mfem::Geometry::CUBE); + const auto &rule = mfem::IntRules.Get(mfem::Geometry::CUBE, 2*order+3); + TableCache cache; + auto table = cache.GetScalarTable(fe, rule); + REQUIRE(table->HasTensorEvaluation3D()); + mfem::DenseMatrix dual(rule.GetNPoints(), 3), actual; + for (int c = 0; c < 3; ++c) for (int q = 0; q < dual.Height(); ++q) + dual(q,c) = std::sin(0.17*(q+1)*(c+1)); + mfem::Vector workspace, expected(fe.GetDof()); + for (int repeat = 0; repeat < 2; ++repeat) { + if (repeat) dual = 0.0; + table->ApplyTransposeTensor3D(dual, actual, workspace); + for (int c = 0; c < 3; ++c) { + table->GetValues().MultTranspose(dual.GetColumn(c), expected.GetData()); + for (int i = 0; i < fe.GetDof(); ++i) + CHECK(std::abs(actual(i,c)-expected(i)) <= 2.0e-12 * std::max(1.0, expected.Normlinf())); + } + } + } + for (int order : {0, 1, 3}) for (int basis : {mfem::BasisType::GaussLegendre, mfem::BasisType::IntegratedGLL}) { + CAPTURE(order, basis); + mfem::RT_FECollection rt(order, 3, mfem::BasisType::GaussLobatto, basis); + const auto &fe = *rt.FiniteElementForGeometry(mfem::Geometry::CUBE); + auto rule = CopyRule(mfem::IntRules.Get(mfem::Geometry::CUBE, 2*order+5)); + TableCache cache; + auto table = cache.GetVectorTable(fe, rule); + REQUIRE(table->HasTensorEvaluation3D()); + mfem::Vector dofs(fe.GetDof()), assembled, expected(fe.GetDof()), point(3), workspace; + mfem::DenseMatrix values, dual(rule.GetNPoints(), 3); + for (int i = 0; i < dofs.Size(); ++i) dofs(i) = std::sin(0.53*(i+1)); + for (int c = 0; c < 3; ++c) for (int q = 0; q < dual.Height(); ++q) dual(q,c) = std::cos(0.31*(q+1)+c); + for (int repeat = 0; repeat < 2; ++repeat) { + if (repeat) { dofs = 0.0; dual = 0.0; } + table->EvaluateTensor3D(dofs, values, workspace); + expected = 0.0; + double primalProduct = 0.0; + for (int q = 0; q < rule.GetNPoints(); ++q) { + table->GetValues(q).MultTranspose(dofs, point); + for (int c = 0; c < 3; ++c) { + CHECK(std::abs(values(q,c)-point(c)) <= 2.0e-11 * std::max(1.0, point.Normlinf())); + primalProduct += values(q,c)*dual(q,c); + point(c) = dual(q,c); + } + table->GetValues(q).AddMult(point, expected); + } + table->ApplyTransposeTensor3D(dual, assembled, workspace); + mfem::Vector difference(assembled); difference -= expected; + CHECK(difference.Norml2() <= 2.0e-11 * std::max(1.0, expected.Norml2())); + CHECK(std::abs(primalProduct - dofs*assembled) <= 2.0e-11 * std::max(1.0, std::abs(primalProduct))); + } + rule.IntPoint(rule.GetNPoints()-1).x += 0.017; + CHECK_FALSE(cache.GetVectorTable(fe, rule)->HasTensorEvaluation3D()); + } +} + +TEST_CASE("RT Tensor Plans Own Their Factors After Cache And FE Destruction", tags::unit &tags::quadrature) { + std::shared_ptr table; + mfem::Vector dofs, expected; + mfem::DenseMatrix values, dual; + { + mfem::RT_FECollection rt(2, 3); + TableCache cache; + auto rule = CopyRule(mfem::IntRules.Get(mfem::Geometry::CUBE, 9)); + const auto &fe = *rt.FiniteElementForGeometry(mfem::Geometry::CUBE); + table = cache.GetVectorTable(fe, rule); + REQUIRE(table->HasTensorEvaluation3D()); + dofs.SetSize(fe.GetDof()); dofs = 0.3; + dual.SetSize(rule.GetNPoints(), 3); dual = 0.17; + mfem::Vector workspace; + table->ApplyTransposeTensor3D(dual, expected, workspace); + } + mfem::Vector workspace, actual; + table->EvaluateTensor3D(dofs, values, workspace); + table->ApplyTransposeTensor3D(dual, actual, workspace); + actual -= expected; + CHECK(actual.Norml2() == 0.0); +} + + +TEST_CASE("L2 Tensor Values And Transposes Match Dense Reference", tags::unit &tags::quadrature) { + for (int order : {0, 1, 3}) for (int basis : {mfem::BasisType::GaussLegendre, mfem::BasisType::OpenUniform}) { + mfem::L2_FECollection collection(order, 3, basis); + const auto &element = *collection.FiniteElementForGeometry(mfem::Geometry::CUBE); + const auto &rule = mfem::IntRules.Get(mfem::Geometry::CUBE, 2*order+3); + TableCache cache; + const auto table = cache.GetScalarTable(element, rule); + REQUIRE(table->HasTensorEvaluation3D()); + mfem::Vector dofs(element.GetDof()), dual(rule.GetNPoints()), values, expected(rule.GetNPoints()), + transpose, expectedTranspose(element.GetDof()), workspace; + for (int i = 0; i < dofs.Size(); ++i) dofs(i) = std::sin(0.37*(i+1)); + for (int q = 0; q < dual.Size(); ++q) dual(q) = std::cos(0.19*(q+1)); + table->EvaluateValuesTensor3D(dofs, values, workspace); + table->GetValues().Mult(dofs, expected); + mfem::Vector difference(values); difference -= expected; + CHECK(difference.Norml2() <= 2.0e-12*std::max(1.0, expected.Norml2())); + table->ApplyTransposeTensor3D(dual, transpose, workspace); + table->GetValues().MultTranspose(dual, expectedTranspose); + difference = transpose; difference -= expectedTranspose; + CHECK(difference.Norml2() <= 2.0e-12*std::max(1.0, expectedTranspose.Norml2())); + CHECK(std::abs((values*dual) - (dofs*transpose)) <= 2.0e-12*std::max(1.0, std::abs(values*dual))); + dofs = 0.0; dual = 0.0; + table->EvaluateValuesTensor3D(dofs, values, workspace); + table->ApplyTransposeTensor3D(dual, transpose, workspace); + CHECK(values.Norml2() == 0.0); + CHECK(transpose.Norml2() == 0.0); + } +} + +TEST_CASE("Geometry Inspector Reuses And Invalidates Fixed Geometry", tags::unit &tags::geometry) { + for (const auto ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + auto serial = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON); + serial.SetCurvature(2); + mfem::ParMesh mesh(MPI_COMM_WORLD, serial); + mfem::H1_FECollection collection(2, 3); + mfem::ParFiniteElementSpace space(&mesh, &collection, 3, ordering); + mfem::ParGridFunction field(&space); + mfem::VectorFunctionCoefficient displacement(3, [](const mfem::Vector &x, mfem::Vector &u) { + u.SetSize(3); + u(0) = 0.08*x(0) + 0.02*x(1)*x(2); + u(1) = -0.03*x(1) + 0.01*x(0)*x(2); + u(2) = 0.04*x(2); + }); + field.ProjectCoefficient(displacement); + mfem::Vector state; field.GetTrueDofs(state); + mean_field::fem::PreparedGeometryInspector inspector; + auto compare = [&] { + const double fast = inspector.Inspect(space, state); + const double reference = inspector.Inspect(space, state, true); + CHECK(std::isfinite(fast)); + CHECK(std::abs(fast-reference) <= 2.0e-12*std::max(1.0, std::abs(reference))); + return fast; + }; + compare(); + CHECK(inspector.GetTensorElementCount() == mesh.GetNE()); + const auto builds = inspector.GetGeometryBuildCount(); + state *= 0.5; + const double before = compare(); + CHECK(inspector.GetGeometryBuildCount() == builds); + + // Same FE/mesh topology, changed curved computational coordinates. + mfem::VectorFunctionCoefficient stretch(3, [](const mfem::Vector &x, mfem::Vector &y) { + y = x; y(0) = 1.2*x(0); y(1) += 0.04*x(0)*x(2); + }); + mesh.Transform(stretch); + const double after = compare(); + CHECK(std::abs(after-before) > 1.0e-6); + CHECK(inspector.GetGeometryBuildCount() == builds + mesh.GetNE()); + inspector.ClearCache(); + CHECK(std::abs(compare()-after) < 2.0e-14); + CHECK(inspector.GetGeometryBuildCount() == builds + 2*mesh.GetNE()); + + mfem::VectorFunctionCoefficient folded(3, [](const mfem::Vector &x, mfem::Vector &u) { + u.SetSize(3); u = 0.0; u(0) = -2.0*x(0); + }); + field.ProjectCoefficient(folded); field.GetTrueDofs(state); + CHECK(compare() < 0.0); + if (state.Size()) state(0) = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(std::isfinite(inspector.Inspect(space, state))); + CHECK_FALSE(std::isfinite(inspector.Inspect(space, state, true))); + } +} + +TEST_CASE("Geometry Inspector Preserves Tetrahedron Fallback", tags::unit &tags::geometry) { + auto serial = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::TETRAHEDRON); + mfem::ParMesh mesh(MPI_COMM_WORLD, serial); + mfem::H1_FECollection collection(1, 3); + mfem::ParFiniteElementSpace space(&mesh, &collection, 3); + mfem::Vector state(space.GetTrueVSize()); state = 0.0; + mean_field::fem::PreparedGeometryInspector inspector; + CHECK(inspector.Inspect(space, state) == 1.0); + CHECK(inspector.Inspect(space, state, true) == 1.0); + CHECK(inspector.GetTensorElementCount() == 0); + CHECK(inspector.GetGeometryBuildCount() == 0); +} + +TEST_CASE("Tensor Gradient Transposes Match Dense Assembly And Adjoint Products", tags::unit &tags::quadrature) { + const auto check = [](const mfem::FiniteElement &element, const mfem::IntegrationRule &rule) { + TableCache cache; + const auto table = cache.GetScalarTable(element, rule); + REQUIRE(table->HasTensorEvaluation3D()); + constexpr int components = 2; + mfem::DenseMatrix dofs(element.GetDof(), components), dual(rule.GetNPoints(), 3*components), + values, actual, expected(element.GetDof(), components); + mfem::Vector workspace; + for (int c = 0; c < components; ++c) for (int i = 0; i < dofs.Height(); ++i) + dofs(i,c) = std::sin(0.37*(i+1)+c); + for (int c = 0; c < dual.Width(); ++c) for (int q = 0; q < dual.Height(); ++q) + dual(q,c) = std::cos(0.23*(q+1)+0.31*c); + + for (int repeat = 0; repeat < 2; ++repeat) { + if (repeat) dual = 0.0; + table->EvaluateTensor3D(dofs, values, workspace); + table->ApplyGradientTransposeTensor3D(dual, actual, workspace); + expected = 0.0; + double forwardProduct = 0.0, transposeProduct = 0.0, scale = 1.0; + for (int c = 0; c < components; ++c) for (int q = 0; q < rule.GetNPoints(); ++q) + for (int a = 0; a < 3; ++a) { + forwardProduct += values(q,4*c+a+1)*dual(q,3*c+a); + for (int i = 0; i < element.GetDof(); ++i) + expected(i,c) += table->GetGradients(q)(i,a)*dual(q,3*c+a); + } + for (int c = 0; c < components; ++c) for (int i = 0; i < dofs.Height(); ++i) + scale = std::max(scale, std::abs(expected(i,c))); + for (int c = 0; c < components; ++c) for (int i = 0; i < dofs.Height(); ++i) { + CHECK(std::abs(actual(i,c)-expected(i,c)) <= 2.0e-11*scale); + transposeProduct += dofs(i,c)*actual(i,c); + if (repeat) CHECK(actual(i,c) == 0.0); + } + CHECK(std::abs(forwardProduct-transposeProduct) <= + 2.0e-11*std::max(1.0, std::abs(forwardProduct))); + } + }; + for (int order : {1, 2, 4}) { + CAPTURE(order); + mfem::H1_FECollection collection(order, 3); + check(*collection.FiniteElementForGeometry(mfem::Geometry::CUBE), + mfem::IntRules.Get(mfem::Geometry::CUBE, 2*order+3)); + } + for (int order : {0, 1, 3}) for (int basis : {mfem::BasisType::GaussLegendre, mfem::BasisType::OpenUniform}) { + CAPTURE(order, basis); + mfem::L2_FECollection collection(order, 3, basis); + check(*collection.FiniteElementForGeometry(mfem::Geometry::CUBE), + mfem::IntRules.Get(mfem::Geometry::CUBE, 2*order+3)); + } +} diff --git a/tests/mapping/compactification/kelvin.cpp b/tests/mapping/compactification/kelvin.cpp index 48567e1..0b8604f 100644 --- a/tests/mapping/compactification/kelvin.cpp +++ b/tests/mapping/compactification/kelvin.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -102,6 +103,43 @@ namespace { } } // namespace +TEST_CASE("Kelvin Prepared Jacobian Factors Match Full Variation", tags::unit &tags::mapping &tags::kelvin) { + mapping::compactification::KelvinCompactification kelvin({.r_star_ref = 1.3, .r_inf_ref = 3.1}); + const auto position = make_vector(1.1, 0.2, -0.1); + const auto coordinateGradient = make_vector(0.2, 0.03, -0.01); + auto jacobian = make_identity(); + jacobian(0, 1) = 0.07; + const auto direction = make_vector(-0.02, 0.07, 0.04); + auto directionJacobian = make_identity(); + directionJacobian *= 0.03; + directionJacobian(1, 2) = -0.05; + for (const double coordinate : {0.0, 0.4, 0.93, 0.999}) { + const mapping::compactification::ExteriorMapInput input{ + position, position, jacobian, coordinate, coordinateGradient}; + mapping::compactification::ExteriorMapResult base; + REQUIRE(kelvin.Evaluate(input, base) == mapping::MappingStatus::valid); + mapping::compactification::ExteriorMapVariation variation; + REQUIRE(kelvin.EvaluateVariation(input, base, {direction, directionJacobian}, variation) == mapping::MappingStatus::valid); + double scale = 0.0; + mfem::Vector scaleGradient; + REQUIRE(kelvin.EvaluateJacobianVariationFactors(coordinate, coordinateGradient, scale, scaleGradient) == mapping::MappingStatus::valid); + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + const double expected = variation.mapping_jacobian_variation(row, column); + CHECK_THAT(scale * directionJacobian(row, column) + direction(row) * scaleGradient(column), + WithinAbs(expected, 1.0e-12 * std::max(1.0, std::abs(expected)))); + } + } + } + double scale = 0.0; + mfem::Vector gradient; + CHECK(kelvin.EvaluateJacobianVariationFactors(1.0, coordinateGradient, scale, gradient) == mapping::MappingStatus::at_compactified_infinity); + CHECK(kelvin.EvaluateJacobianVariationFactors(-0.1, coordinateGradient, scale, gradient) == mapping::MappingStatus::outside_reference_domain); + auto invalid = coordinateGradient; + invalid(1) = std::numeric_limits::quiet_NaN(); + CHECK(kelvin.EvaluateJacobianVariationFactors(0.4, invalid, scale, gradient) == mapping::MappingStatus::non_finite_input); +} + TEST_CASE( "Kelvin Compactification Validates Its Configuration", tags::unit &tags::mapping &tags::kelvin @@ -719,4 +757,4 @@ TEST_CASE( check_vector(first_a.physical_position, second_a.physical_position, tolerance); check_matrix(first_a.mapping_jacobian, second_a.mapping_jacobian, tolerance); -} \ No newline at end of file +} diff --git a/tests/mapping/domain_mapper.cpp b/tests/mapping/domain_mapper.cpp index 71d5db5..34d894a 100644 --- a/tests/mapping/domain_mapper.cpp +++ b/tests/mapping/domain_mapper.cpp @@ -2870,3 +2870,80 @@ TEST_CASE( CHECK_THROWS_AS(evaluator.Refresh(), std::invalid_argument); CHECK_THROWS_AS(evaluator.EvaluatePoint(*transformation, integration_point, context), std::invalid_argument); } + +TEST_CASE("Fixed Mesh Geometry Cache Reuses Factors And Invalidates On Mesh Updates", "[mapping][geometry-cache]") { + SingleElementFixture fixture; + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute = 3}, make_kelvin_compactification()); + mapping::DomainMapper::Workspace workspace(dimension); + const auto &rule = mfem::IntRules.Get(mfem::Geometry::CUBE, 5); + const auto &point = rule.IntPoint(0); + auto zero = fixture.MakeZeroElementDofs(); + mapping::ElementDisplacementData displacement(fixture.GetElement(), zero); + ElementMappingDataOwner data(displacement); + auto compare = [&](const mfem::IntegrationPoint &ip) { + auto &transformation = *fixture.mesh.GetElementTransformation(0); + mapping::VolumeMappingContext fast, reference; + mapper.SetGeometryCacheEnabled(true); + REQUIRE(mapper.EvaluateVolume(data.Get(), transformation, ip, workspace, fast) == mapping::MappingStatus::valid); + mapper.SetGeometryCacheEnabled(false); + REQUIRE(mapper.EvaluateVolume(data.Get(), transformation, ip, workspace, reference) == mapping::MappingStatus::valid); + check_vector(fast.mapping.physical_position, reference.mapping.physical_position); + check_matrix(fast.mapping.mapping_jacobian, reference.mapping.mapping_jacobian); + check_matrix(fast.quadrature.J_inv, reference.quadrature.J_inv); + CHECK_THAT(fast.quadrature.weight, WithinAbs(reference.quadrature.weight, 1.0e-12)); + mapper.SetGeometryCacheEnabled(true); + return fast.quadrature.weight; + }; + const double originalWeight = compare(point); + CHECK(mapper.GetGeometryCacheStatistics().misses == 1); + compare(point); + CHECK(mapper.GetGeometryCacheStatistics().hits == 1); + auto changedPoint = point; + changedPoint.x += 0.013; + compare(changedPoint); + CHECK(mapper.GetGeometryCacheStatistics().points == 2); + changedPoint.weight *= 0.5; + compare(changedPoint); + CHECK(mapper.GetGeometryCacheStatistics().points == 2); // weights are supplied by the caller + fixture.mesh.Transform([](const mfem::Vector &x, mfem::Vector &y) { + y = x; y(0) *= 1.2; y(1) += 0.17; + }); + const double newWeight = compare(point); + CHECK(newWeight > originalWeight); + CHECK(mapper.GetGeometryCacheStatistics().points == 1); + CHECK(mapper.GetGeometryCacheStatistics().misses == 3); + mapper.ClearGeometryCache(); + CHECK(mapper.GetGeometryCacheStatistics().points == 0); + compare(point); + CHECK(mapper.GetGeometryCacheStatistics().misses == 1); +} + +TEST_CASE("Geometry Cache Does Not Freeze Displacement Or Compactification State", "[mapping][geometry-cache]") { + SingleElementFixture fixture; + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute = 3}, make_kelvin_compactification()); + mapping::DomainMapper::Workspace workspace(dimension); + const auto &point = mfem::IntRules.Get(mfem::Geometry::CUBE, 3).IntPoint(0); + for (double scale : {0.0, 0.02, -0.01}) { + auto &transformation = *fixture.mesh.GetElementTransformation(0); + auto dofs = fixture.MakeZeroElementDofs(); dofs = scale; + mapping::ElementDisplacementData displacement(fixture.GetElement(), dofs); + auto compactificationDofs = make_constant_compactification_dofs(fixture.GetElement(), 0.1 + scale); + mapping::ElementCompactificationData compactification(fixture.GetElement(), compactificationDofs); + mapping::ElementMappingData data{displacement, compactification}; + for (int attribute : {1, 3}) { + transformation.Attribute = attribute; + mapping::VolumeMappingContext fast, reference; + mapper.SetGeometryCacheEnabled(true); + const auto fastStatus = mapper.EvaluateVolume(data, transformation, point, workspace, fast); + mapper.SetGeometryCacheEnabled(false); + const auto referenceStatus = mapper.EvaluateVolume(data, transformation, point, workspace, reference); + REQUIRE(fastStatus == referenceStatus); + if (fastStatus == mapping::MappingStatus::valid) { + check_vector(fast.mapping.physical_position, reference.mapping.physical_position); + check_matrix(fast.mapping.mapping_jacobian, reference.mapping.mapping_jacobian); + check_matrix(fast.quadrature.J_inv, reference.quadrature.J_inv); + } + } + } + CHECK(mapper.GetGeometryCacheStatistics().points == 1); +} diff --git a/tests/normalization/stellar_equilibrium.cpp b/tests/normalization/stellar_equilibrium.cpp index 856e634..8700da3 100644 --- a/tests/normalization/stellar_equilibrium.cpp +++ b/tests/normalization/stellar_equilibrium.cpp @@ -13,6 +13,7 @@ #include #include #include +#include import mean_field; import test_helpers; @@ -997,3 +998,83 @@ TEST_CASE( CHECK(physicalInverse.IsCurrent()); CHECK(scaledInverse.IsCurrent()); } + +TEST_CASE("Direct Reference Mass Diagonals Match Full True DOF Assembly", "[normalization][diagonal][assembly]") { + using namespace mean_field; + auto args = test_utils::setup_args(); + auto f = fem::setup_fem(args.mesh_file, args, 0); + REQUIRE(f.okay()); + auto compare = [](const mfem::Vector &a, const mfem::Vector &b) { + REQUIRE(a.Size() == b.Size()); + mfem::Vector difference(a); difference -= b; + CHECK(difference.Norml2() <= 2.0e-11 * std::max(1.0, b.Norml2())); + }; + compare(normalization::AssembleReferenceHDivMassDiagonal(*f.gravityFluxFes), + normalization::AssembleReferenceHDivMassDiagonal(*f.gravityFluxFes, true)); + for (auto *space : {f.gravityPotentialFes.get(), f.densityFes.get(), f.enthalpyFes.get()}) { + compare(normalization::AssembleReferenceScalarMassDiagonal(*space), + normalization::AssembleReferenceScalarMassDiagonal(*space, nullptr, true)); + mfem::Array marker(f.mesh->attributes.Max()); + marker = 0; marker[f.mesh->GetAttribute(0)-1] = 1; + compare(normalization::AssembleReferenceScalarMassDiagonal(*space, &marker), + normalization::AssembleReferenceScalarMassDiagonal(*space, &marker, true)); + } + auto surfaceMap = field::make_stellar_surface_scalar_dof_map(*f.surfaceDeformationFes); + // The stellar surface is a tagged internal interface, which an exterior-only + // boundary face restriction would omit even though volume diagonals pass. + using Schema = utils::domain::CoreEnvelopeVacuumDomainSchema; + constexpr int surfaceAttribute = Schema::boundary_attribute(); + int localFaceCounts[2] = {0, 0}, globalFaceCounts[2] = {0, 0}; + for (int b = 0; b < f.mesh->GetNBE(); ++b) { + if (f.mesh->GetBdrAttribute(b) != surfaceAttribute) continue; + ++localFaceCounts[0]; + const int face = f.mesh->GetBdrElementFaceIndex(b); + if (f.mesh->GetFaceInformation(face).IsOfFaceType(mfem::FaceType::Interior)) + ++localFaceCounts[1]; + } + REQUIRE(MPI_Allreduce(localFaceCounts, globalFaceCounts, 2, MPI_INT, MPI_SUM, + f.surfaceDeformationFes->GetComm()) == MPI_SUCCESS); + REQUIRE(globalFaceCounts[0] > 0); + REQUIRE(globalFaceCounts[1] == globalFaceCounts[0]); + + const auto surface = normalization::AssembleReferenceSurfaceMassDiagonal(f, surfaceMap); + const auto referenceSurface = normalization::AssembleReferenceSurfaceMassDiagonal(f, surfaceMap, true); + REQUIRE(surface.Size() == referenceSurface.Size()); + for (int i = 0; i < surface.Size(); ++i) { + CAPTURE(i, surface(i), referenceSurface(i)); + CHECK(std::isfinite(surface(i))); + CHECK(surface(i) > 0.0); + CHECK(std::isfinite(referenceSurface(i))); + CHECK(referenceSurface(i) > 0.0); + } + mfem::Vector difference(surface); + difference -= referenceSurface; + const double errorNorm = difference.Norml2(), referenceNorm = referenceSurface.Norml2(); + const double localSquares[2] = {errorNorm * errorNorm, referenceNorm * referenceNorm}; + double globalSquares[2] = {0.0, 0.0}; + REQUIRE(MPI_Allreduce(localSquares, globalSquares, 2, MPI_DOUBLE, MPI_SUM, + f.surfaceDeformationFes->GetComm()) == MPI_SUCCESS); + REQUIRE(globalSquares[1] > 0.0); + CHECK(std::sqrt(globalSquares[0] / globalSquares[1]) <= 2.0e-11); +} + +TEST_CASE("Nonconforming Reference Diagonals Retain Exact Assembled Cross Terms", "[normalization][diagonal][nonconforming]") { + using namespace mean_field; + auto serial = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON); + serial.EnsureNCMesh(); + mfem::Array refine(1); refine[0] = 0; + serial.GeneralRefinement(refine); + REQUIRE(serial.Nonconforming()); + mfem::ParMesh mesh(MPI_COMM_WORLD, serial); + mfem::H1_FECollection h1(2, 3); + mfem::RT_FECollection rt(1, 3); + mfem::ParFiniteElementSpace scalar(&mesh, &h1), flux(&mesh, &rt); + auto actual = normalization::AssembleReferenceScalarMassDiagonal(scalar); + auto expected = normalization::AssembleReferenceScalarMassDiagonal(scalar, nullptr, true); + actual -= expected; + CHECK(actual.Norml2() == 0.0); + actual = normalization::AssembleReferenceHDivMassDiagonal(flux); + expected = normalization::AssembleReferenceHDivMassDiagonal(flux, true); + actual -= expected; + CHECK(actual.Norml2() == 0.0); +} diff --git a/tests/operators/gravity_displacement_force.cpp b/tests/operators/gravity_displacement_force.cpp index d282530..1a40ca6 100644 --- a/tests/operators/gravity_displacement_force.cpp +++ b/tests/operators/gravity_displacement_force.cpp @@ -601,6 +601,15 @@ TEST_CASE( CHECK(preparedOperator.IsPrepared()); CHECK(preparedOperator.GetResidualPreparationCount() == 2); CHECK(preparedOperator.GetResidualApplicationCount() == 1); + mfem::Vector updatedResidual, referenceResidual; + preparedOperator.BuildResidual(updatedResidual); + CHECK(preparedOperator.PrepareReference().DidAnyWork()); + preparedOperator.BuildResidual(referenceResidual); + CHECK(gravity_displacement_force_test_utils::relative_difference( + updatedResidual, referenceResidual, f.mesh->GetComm()) < 2.0e-12); + CHECK_FALSE(preparedOperator.PrepareReference().DidAnyWork()); + CHECK(preparedOperator.Prepare().DidAnyWork()); + CHECK_FALSE(preparedOperator.Prepare().DidAnyWork()); } TEST_CASE( @@ -665,6 +674,19 @@ TEST_CASE( densityDirectionReduced, displacementDirectionReduced, gravityGradientDirectionReduced, completeAction ); + mfem::Vector retainedAction; + preparedOperator.ApplyCompleteJacobianActionReference( + densityDirectionReduced, displacementDirectionReduced, gravityGradientDirectionReduced, retainedAction); + CHECK(preparedOperator.GetFastElementCount() > 0); + CHECK(preparedOperator.GetTensorElementCount() == preparedOperator.GetFastElementCount()); + CHECK(gravity_displacement_force_test_utils::relative_difference( + completeAction, retainedAction, f.mesh->GetComm()) < 2.0e-12); + + preparedOperator.ApplyCompleteJacobianActionCachedReference( + densityDirectionReduced, displacementDirectionReduced, gravityGradientDirectionReduced, retainedAction); + CHECK(gravity_displacement_force_test_utils::relative_difference( + completeAction, retainedAction, f.mesh->GetComm()) < 2.0e-12); + mfem::Vector summedColumns(densityAction); summedColumns += gravityAction; summedColumns += displacementAction; diff --git a/tests/operators/prepared_angular_momentum.cpp b/tests/operators/prepared_angular_momentum.cpp index c56fb10..5ced095 100644 --- a/tests/operators/prepared_angular_momentum.cpp +++ b/tests/operators/prepared_angular_momentum.cpp @@ -312,6 +312,10 @@ TEST_CASE( operation.ApplyCompleteJacobianAction( reducedDensityDirection, reducedDisplacementDirection, angularVelocityDirection, completeAction ); + mfem::Vector preparedReference; + operation.ApplyCompleteJacobianActionReference( + reducedDensityDirection, reducedDisplacementDirection, angularVelocityDirection, preparedReference); + CHECK(angular_momentum_test_utils::relativeError(completeAction(0), preparedReference(0)) < 2.0e-11); CHECK( angular_momentum_test_utils::relativeError( completeAction(0), densityAction(0) + geometryAction(0) + angularVelocityAction(0) @@ -409,10 +413,26 @@ TEST_CASE( operation.Prepare(0.52, dependencies); const auto preparationCount = operation.GetPreparationCount(); const double moment = operation.GetMomentOfInertia(); + CHECK(operation.GetJacobianRowPreparationCount() == 0); + const auto rhoDirection = gravityContext.GetDensityMap().gather( + angular_momentum_test_utils::projectDensityDirection(finiteElements, 0.67)); + const auto uDirection = gravityContext.GetDisplacementMap().gather( + angular_momentum_test_utils::projectDisplacementDirection(finiteElements, -0.53)); + auto checkRows = [&](std::uint64_t expectedBuilds) { + REQUIRE(operation.PrepareJacobianRows()); + CHECK(operation.GetJacobianRowPreparationCount() == expectedBuilds); + mfem::Vector actual, reference; + operation.ApplyCompleteJacobianAction(rhoDirection, uDirection, -0.19, actual); + operation.ApplyCompleteJacobianActionReference(rhoDirection, uDirection, -0.19, reference); + CHECK(angular_momentum_test_utils::relativeError(actual(0), reference(0)) < 2.0e-11); + CHECK(operation.GetJacobianRowPreparationCount() == expectedBuilds); + }; + checkRows(1); const auto repeated = operation.Prepare(0.52, dependencies); CHECK_FALSE(repeated.DidAnyWork()); CHECK(operation.GetPreparationCount() == preparationCount); + checkRows(1); ++gravityPotentialRevision; angular_momentum_test_utils::prepareGravityContext( @@ -420,6 +440,7 @@ TEST_CASE( ); const auto unrelatedPotential = operation.Prepare(0.52, dependencies); CHECK_FALSE(unrelatedPotential.DidAnyWork()); + checkRows(1); const double residualBeforeRotation = angular_momentum_test_utils::residual(operation); ++dependencies.rotation.revision; @@ -429,6 +450,7 @@ TEST_CASE( CHECK_FALSE(rotationOnly.refreshedDensity); CHECK_FALSE(rotationOnly.refreshedGeometry); CHECK(operation.GetMomentOfInertia() == moment); + checkRows(1); CHECK( angular_momentum_test_utils::residual(operation) - residualBeforeRotation == Catch::Approx((0.81 - 0.52) * moment).epsilon(3.0e-15) @@ -443,6 +465,8 @@ TEST_CASE( CHECK(densityOnly.refreshedDensity); CHECK_FALSE(densityOnly.refreshedGeometry); CHECK_FALSE(densityOnly.updatedAngularVelocity); + CHECK(operation.GetJacobianRowPreparationCount() == 1); + checkRows(2); displacement = angular_momentum_test_utils::projectDisplacementDirection(finiteElements, 0.87); ++dependencies.displacement.revision; @@ -453,6 +477,8 @@ TEST_CASE( CHECK(geometryOnly.refreshedGeometry); CHECK_FALSE(geometryOnly.refreshedDensity); CHECK_FALSE(geometryOnly.updatedAngularVelocity); + CHECK(operation.GetJacobianRowPreparationCount() == 2); + checkRows(3); } TEST_CASE( @@ -488,6 +514,8 @@ TEST_CASE( const auto initial = operation.TryPrepare(angularVelocity, dependencies); REQUIRE(initial.has_value()); const std::uint64_t initialPreparationCount = operation.GetPreparationCount(); + REQUIRE(operation.PrepareJacobianRows()); + CHECK(operation.GetJacobianRowPreparationCount() == 1); const std::uint64_t successfulPreparations = initialPreparationCount; @@ -513,6 +541,8 @@ TEST_CASE( const auto recovered = operation.TryPrepare(angularVelocity, dependencies); REQUIRE(recovered.has_value()); CHECK(operation.IsPrepared()); + REQUIRE(operation.PrepareJacobianRows()); + CHECK(operation.GetJacobianRowPreparationCount() == 2); ++dependencies.rotation.revision; const auto nonFiniteAngularVelocity = operation.TryPrepare(std::numeric_limits::quiet_NaN(), dependencies); @@ -527,4 +557,6 @@ TEST_CASE( ++dependencies.rotation.revision; REQUIRE(operation.TryPrepare(angularVelocity, dependencies).has_value()); CHECK(operation.IsPrepared()); + REQUIRE(operation.PrepareJacobianRows()); + CHECK(operation.GetJacobianRowPreparationCount() == 3); } diff --git a/tests/operators/prepared_barotropic_closure.cpp b/tests/operators/prepared_barotropic_closure.cpp index 4979d96..6c64bf6 100644 --- a/tests/operators/prepared_barotropic_closure.cpp +++ b/tests/operators/prepared_barotropic_closure.cpp @@ -526,6 +526,18 @@ namespace prepared_barotropic_closure_test_utils { const MPI_Comm communicator = f.mesh->GetComm(); + mfem::Vector retained, enthalpyOnly, displacementOnly; + preparedOperator.MultReference(densityVariation, enthalpyVariation, displacementVariation, retained); + preparedOperator.ApplyEnthalpyJacobianAction(enthalpyVariation, enthalpyOnly); + preparedOperator.ApplyDisplacementJacobianAction(displacementVariation, displacementOnly); + CHECK(relative_error(preparedCompleteAction, retained, communicator) < 2.0e-12); + CHECK(relative_error(enthalpyOnly, referenceEnthalpyAction, communicator) < 2.0e-12); + CHECK(relative_error(displacementOnly, referenceDisplacementAction, communicator) < 2.0e-12); + preparedOperator.ApplyEnthalpyJacobianAction(zeroEnthalpy, enthalpyOnly); + preparedOperator.ApplyDisplacementJacobianAction(zeroDisplacement, displacementOnly); + CHECK(enthalpyOnly.Norml2() == 0.0); + CHECK(displacementOnly.Norml2() == 0.0); + CHECK(relative_error(preparedResidual, referenceResidual, communicator) < 2.0e-12); CHECK(relative_error(preparedDensityAction, referenceDensityAction, communicator) < 2.0e-12); CHECK(relative_error(preparedEnthalpyAction, referenceEnthalpyAction, communicator) < 2.0e-12); @@ -831,6 +843,16 @@ namespace prepared_barotropic_closure_test_utils { mfem::Vector preparedAction; preparedOperator.BuildResidual(preparedResidual); preparedOperator.Mult(densityVariation, enthalpyVariation, displacementVariation, preparedAction); + CHECK(preparedOperator.GetTensorElementCount() > 0); + mfem::Vector cached, tensorColumn, cachedColumn; + preparedOperator.MultCachedReference(densityVariation, enthalpyVariation, displacementVariation, cached); + CHECK(relative_error(preparedAction, cached, communicator) < 2.0e-11); + preparedOperator.ApplyEnthalpyJacobianAction(enthalpyVariation, tensorColumn); + preparedOperator.ApplyEnthalpyJacobianAction(enthalpyVariation, cachedColumn, false); + CHECK(relative_error(tensorColumn, cachedColumn, communicator) < 2.0e-11); + preparedOperator.ApplyDisplacementJacobianAction(displacementVariation, tensorColumn); + preparedOperator.ApplyDisplacementJacobianAction(displacementVariation, cachedColumn, false); + CHECK(relative_error(tensorColumn, cachedColumn, communicator) < 2.0e-11); mfem::Vector fullReferenceResidual; mean_field::operators::kernels::apply_barotropic_closure( diff --git a/tests/operators/prepared_gravity_source.cpp b/tests/operators/prepared_gravity_source.cpp index 9c63dcf..48dd270 100644 --- a/tests/operators/prepared_gravity_source.cpp +++ b/tests/operators/prepared_gravity_source.cpp @@ -69,6 +69,7 @@ TEST_CASE( REQUIRE(f.okay()); operators::PreparedMappedGravitySourceOperator operation(f, *f.domainMapperStateless); + operators::PreparedMappedGravitySourceOperator oldWeights(f, *f.domainMapperStateless); const mfem::Vector densityTrue = prepared_test::make_deterministic_vector(f.densityFes->GetTrueVSize(), 0.41); const mfem::Vector density = operation.GetDensityMap().gather(densityTrue); const mfem::Vector displacementTrue = prepared_test::make_displacement(f, 0.4); @@ -81,6 +82,21 @@ TEST_CASE( mfem::Vector baselineVariation; operation.Mult(density, baselineAction); operation.MultDisplacementVariationTrue(densityTrue, directionTrue, baselineVariation); + REQUIRE(operation.GetTensorVariationElementCount() > 0); + mfem::Vector pointwiseVariation, zeroDirection(directionTrue), zeroVariation; + operation.MultDisplacementVariationTrueReference(densityTrue, directionTrue, pointwiseVariation); + CHECK(prepared_test::relative_error(baselineVariation, pointwiseVariation, communicator) < 2.0e-12); + zeroDirection = 0.0; + operation.MultDisplacementVariationTrue(densityTrue, zeroDirection, zeroVariation); + CHECK(zeroVariation.Norml2() == 0.0); + operation.MultDisplacementVariationTrue(densityTrue, directionTrue, pointwiseVariation); + CHECK(prepared_test::relative_error(baselineVariation, pointwiseVariation, communicator) < 2.0e-14); + oldWeights.PrepareReference(displacement); + mfem::Vector oldAction, oldVariation; + oldWeights.Mult(density, oldAction); + oldWeights.MultDisplacementVariationTrue(densityTrue, directionTrue, oldVariation); + CHECK(prepared_test::relative_error(baselineAction, oldAction, communicator) < 2.0e-14); + CHECK(prepared_test::relative_error(baselineVariation, oldVariation, communicator) < 2.0e-14); const mfem::Vector primalDisplacementTrue = prepared_test::make_displacement(f, 1.0); operation.PreparePrimal(operation.GetDisplacementMap().gather(primalDisplacementTrue)); diff --git a/tests/operators/prepared_hdiv_mass.cpp b/tests/operators/prepared_hdiv_mass.cpp index 8e1a49d..24ed520 100644 --- a/tests/operators/prepared_hdiv_mass.cpp +++ b/tests/operators/prepared_hdiv_mass.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -12,6 +13,27 @@ using Catch::Matchers::WithinAbs; namespace prepared_test = gravity_prepared_test_utils; namespace { + // Deliberately not a KelvinCompactification dynamic type: the prepared + // operator must keep arbitrary exterior maps on the general derivative. + class DelegatingExteriorMap final : public mapping::compactification::ExteriorDomainMap { + public: + explicit DelegatingExteriorMap(mapping::compactification::options::KelvinCompactificationOptions options) + : kelvin(options) {} + mapping::MappingStatus Evaluate(const mapping::compactification::ExteriorMapInput &input, + mapping::compactification::ExteriorMapResult &result) const override { + return kelvin.Evaluate(input, result); + } + mapping::MappingStatus EvaluateVariation(const mapping::compactification::ExteriorMapInput &input, + const mapping::compactification::ExteriorMapResult &result, + const mapping::compactification::ExteriorMapDirection &direction, + mapping::compactification::ExteriorMapVariation &variation) const override { + return kelvin.EvaluateVariation(input, result, direction, variation); + } + std::string_view GetName() const noexcept override { return "DelegatingExteriorMap"; } + private: + mapping::compactification::KelvinCompactification kelvin; + }; + [[nodiscard]] mfem::Vector make_folding_displacement(const mean_field::fem::FEM &f) { mfem::ParGridFunction field(f.displacementFes.get()); mfem::VectorFunctionCoefficient coefficient( @@ -65,9 +87,15 @@ TEST_CASE( const mfem::Vector displacementTrue = prepared_test::make_displacement(f, scale); const mfem::Vector displacement = preparedOperator.GetDisplacementMap().gather(displacementTrue); preparedOperator.Prepare(displacement); + CHECK(preparedOperator.GetFastVariationElementCount() == f.mesh->GetNE()); + CHECK(preparedOperator.GetTensorVariationElementCount() == f.mesh->GetNE()); preparedOperator.MultDisplacementVariationTrue(first, direction, firstAction); mfem::Vector referenceAction; + preparedOperator.MultDisplacementVariationTrueCachedReference(first, direction, referenceAction); + CHECK(prepared_test::relative_error(firstAction, referenceAction, communicator) < 2.0e-11); + preparedOperator.MultDisplacementVariationTrueReference(first, direction, referenceAction); + CHECK(prepared_test::relative_error(firstAction, referenceAction, communicator) < 2.0e-11); operators::kernels::apply_mapped_hdiv_mass_variation( f, *f.domainMapperStateless, first, displacementTrue, direction, referenceAction ); @@ -85,6 +113,39 @@ TEST_CASE( CHECK(preparedOperator.GetPreparationCount() == 2); } +TEST_CASE("Prepared Hdiv Tangent Retains General Exterior Map Fallback", + tags::gravity_prepared_jacobian_accuracy) { + auto args = test_utils::setup_args(); + auto f = fem::setup_fem(args.mesh_file, args, 0); + REQUIRE(f.okay()); + mapping::DomainMapper mapper(args.domain_mapper_options, std::make_unique(args.kelvin_options)); + operators::PreparedMappedHDivMassOperator prepared(f, mapper); + const auto base = prepared_test::make_displacement(f, 0.7); + const auto direction = prepared_test::make_displacement(f, 0.43); + const auto flux = prepared_test::make_deterministic_vector(f.gravityFluxFes->GetTrueVSize(), 0.31); + prepared.Prepare(prepared.GetDisplacementMap().gather(base)); + int stellarElements = 0; + for (int element = 0; element < f.mesh->GetNE(); ++element) + stellarElements += !mapper.IsCompactifiedElement(*f.mesh->GetElementTransformation(element)); + CHECK(prepared.GetFastVariationElementCount() == stellarElements); + mfem::Vector actual, expected; + prepared.MultDisplacementVariationTrue(flux, direction, actual); + operators::kernels::apply_mapped_hdiv_mass_variation(f, mapper, flux, base, direction, expected); + CHECK(prepared_test::relative_error(actual, expected, f.mesh->GetComm()) < 2.0e-11); + prepared.PreparePrimal(prepared.GetDisplacementMap().gather(base)); + CHECK_FALSE(prepared.HasVariationData()); + CHECK(prepared.GetFastVariationElementCount() == 0); + CHECK(prepared.GetFastMassElementCount() == f.mesh->GetNE()); + mfem::Vector primal, primalReference; + const auto reducedFlux = prepared.GetFluxMap().gather(flux); + prepared.Mult(reducedFlux, primal); + prepared.MultReference(reducedFlux, primalReference); + CHECK(prepared_test::relative_error(primal, primalReference, f.mesh->GetComm()) < 2.0e-11); + prepared.Prepare(prepared.GetDisplacementMap().gather(base)); + prepared.MultDisplacementVariationTrue(flux, direction, actual); + CHECK(prepared_test::relative_error(actual, expected, f.mesh->GetComm()) < 2.0e-11); +} + TEST_CASE( "Prepared Mapped Hdiv Mass Reports Invalid Candidate Geometry Without Unwinding", tags::gravity_prepared_unit &tags::geometry @@ -144,6 +205,13 @@ TEST_CASE( mfem::Vector prepared_action; prepared_operator.Mult(gravity_gradient, prepared_action); + CHECK(prepared_operator.GetFastMassElementCount() == f.mesh->GetNE()); + mfem::Vector mfemAction; + prepared_operator.MultReference(gravity_gradient, mfemAction); + CHECK(prepared_test::relative_error(prepared_action, mfemAction, communicator) < 2.0e-11); + // Reference calls must restore the optimized mode and leave PA data intact. + prepared_operator.Mult(gravity_gradient, mfemAction); + CHECK(prepared_test::relative_error(prepared_action, mfemAction, communicator) < 2.0e-14); mfem::Vector reference_action_true; operators::kernels::apply_mapped_hdiv_mass( f, *f.domainMapperStateless, gravity_gradient_true, displacement_true, reference_action_true diff --git a/tests/operators/prepared_hydrostatic_equilibrium.cpp b/tests/operators/prepared_hydrostatic_equilibrium.cpp index 8f3f993..90a40e0 100644 --- a/tests/operators/prepared_hydrostatic_equilibrium.cpp +++ b/tests/operators/prepared_hydrostatic_equilibrium.cpp @@ -361,3 +361,80 @@ TEST_CASE( CHECK(recovered->preparedResidual); CHECK(preparedOperator.IsPrepared()); } + +TEST_CASE( + "Hydrostatic Tensor Preparation Matches Pointwise Preparation And Rebuilds On Mode Changes", + tags::barotrope_hydrostatic_prepared_residual &tags::unit +) { + const auto args = test_utils::setup_args(); + auto f = mean_field::fem::setup_fem(args.mesh_file, args, 0); + REQUIRE(f.okay()); + using namespace prepared_hydrostatic_test_utils; + mean_field::operators::PreparedHydrostaticEquilibriumOperator fast(f, *f.domainMapperStateless); + mean_field::operators::PreparedHydrostaticEquilibriumOperator reference(f, *f.domainMapperStateless); + auto h = make_enthalpy(f), phi = make_gravity_potential(f); + const auto dh = make_enthalpy(f, 0.53), dp = make_gravity_potential(f, 0.71); + const auto du = field_dof_test_utils::make_supported_displacement(f, 0.31); + const auto rotation = make_rotation(); + auto dependencies = make_dependencies(); + + for (double scale : {0.0, 0.42, 0.73}) { + CAPTURE(scale); + const auto displacement = field_dof_test_utils::make_supported_displacement(f, scale); + h *= 1.01; phi *= 0.99; + ++dependencies.displacement.revision; + ++dependencies.enthalpy.revision; + ++dependencies.gravityPotential.revision; + const auto state = make_state(h, phi, displacement, 0.39); + REQUIRE(fast.Prepare(state, dependencies, rotation).DidAnyWork()); + REQUIRE(reference.PrepareReference(state, dependencies, rotation).DidAnyWork()); + CHECK(fast.GetTensorDisplacementElementCount() > 0); + const auto compare = [&](auto &&apply) { + mfem::Vector a, b; + apply(fast, a); apply(reference, b); + CHECK(gravity_prepared_test_utils::relative_error(a, b, f.mesh->GetComm()) < 2.0e-11); + }; + compare([](auto &op, auto &out) { op.BuildResidual(out); }); + compare([&](auto &op, auto &out) { op.ApplyEnthalpyJacobianAction(dh, out); }); + compare([&](auto &op, auto &out) { op.ApplyGravityPotentialJacobianAction(dp, out); }); + compare([](auto &op, auto &out) { op.ApplyBernoulliConstantJacobianAction(0.13, out); }); + compare([&](auto &op, auto &out) { op.ApplyDisplacementJacobianAction(du, out); }); + compare([&](auto &op, auto &out) { op.ApplyCompleteJacobianAction(dh, dp, 0.13, du, out); }); + compare([](auto &op, auto &out) { op.AssembleEnthalpyJacobianDiagonal(out); }); + CHECK_FALSE(fast.Prepare(state, dependencies, rotation).DidAnyWork()); + CHECK_FALSE(reference.PrepareReference(state, dependencies, rotation).DidAnyWork()); + + CHECK(fast.PrepareReference(state, dependencies, rotation).DidAnyWork()); + compare([](auto &op, auto &out) { op.BuildResidual(out); }); + CHECK_FALSE(fast.PrepareReference(state, dependencies, rotation).DidAnyWork()); + CHECK(fast.Prepare(state, dependencies, rotation).DidAnyWork()); + compare([&](auto &op, auto &out) { op.ApplyCompleteJacobianAction(dh, dp, 0.13, du, out); }); + CHECK_FALSE(fast.Prepare(state, dependencies, rotation).DidAnyWork()); + } + + mfem::ParGridFunction foldedField(f.displacementFes.get()); + mfem::VectorFunctionCoefficient fold(3, [](const mfem::Vector &x, mfem::Vector &u) { + u = x; u *= -2.0; + }); + foldedField.ProjectCoefficient(fold); + mfem::Vector foldedTrue; foldedField.GetTrueDofs(foldedTrue); + const auto folded = fast.GetDisplacementMap().gather(foldedTrue); + ++dependencies.displacement.revision; + const auto rejectedState = make_state(h, phi, folded, 0.39); + const auto rejected = fast.TryPrepare(rejectedState, dependencies, rotation); + const auto referenceRejected = reference.TryPrepareReference(rejectedState, dependencies, rotation); + REQUIRE_FALSE(rejected.has_value()); + REQUIRE_FALSE(referenceRejected.has_value()); + CHECK(rejected.error().reason == referenceRejected.error().reason); + CHECK(rejected.error().mappingStatus == mean_field::mapping::MappingStatus::non_positive_determinant); + CHECK_FALSE(fast.IsPrepared()); + CHECK_FALSE(reference.IsPrepared()); + + const auto recovered = make_state(h, phi, du, 0.39); + ++dependencies.displacement.revision; + REQUIRE(fast.TryPrepare(recovered, dependencies, rotation).has_value()); + REQUIRE(reference.TryPrepareReference(recovered, dependencies, rotation).has_value()); + mfem::Vector a, b; + fast.BuildResidual(a); reference.BuildResidual(b); + CHECK(gravity_prepared_test_utils::relative_error(a, b, f.mesh->GetComm()) < 2.0e-11); +} diff --git a/tests/operators/prepared_hydrostatic_equilibrium_displacement_jacobian.cpp b/tests/operators/prepared_hydrostatic_equilibrium_displacement_jacobian.cpp index 4436ceb..7624cb8 100644 --- a/tests/operators/prepared_hydrostatic_equilibrium_displacement_jacobian.cpp +++ b/tests/operators/prepared_hydrostatic_equilibrium_displacement_jacobian.cpp @@ -198,6 +198,16 @@ TEST_CASE( CHECK(statistics.applications == 3); CHECK(centeredDifferenceError < 2.0e-7); CHECK(linearityError < 5.0e-12); + int stellarElements = 0; + preparedOperator.VisitMappedGeometryRules([&](int, const mfem::IntegrationRule &) { ++stellarElements; }); + CHECK(preparedOperator.GetFastDisplacementElementCount() == stellarElements); + mfem::Vector preparedReference; + preparedOperator.ApplyDisplacementJacobianActionReference(firstVariation, preparedReference); + CHECK(prepared_hydrostatic_displacement_test_utils::relative_error( + firstAction, preparedReference, f.mesh->GetComm()) < 2.0e-11); + preparedOperator.ApplyDisplacementJacobianActionReference(secondVariation, preparedReference); + CHECK(prepared_hydrostatic_displacement_test_utils::relative_error( + secondAction, preparedReference, f.mesh->GetComm()) < 2.0e-11); } TEST_CASE( @@ -344,4 +354,13 @@ TEST_CASE( CHECK(displacementStatistics.applications == 4); CHECK(geometryReferenceError < 2.0e-7); CHECK(geometryEffect > 1.0e-8); + mfem::Vector preparedReference; + preparedOperator.ApplyDisplacementJacobianActionReference(displacementVariation, preparedReference); + CHECK(prepared_hydrostatic_displacement_test_utils::relative_error( + geometryUpdatedAction, preparedReference, f.mesh->GetComm()) < 2.0e-11); + preparedOperator.ApplyDisplacementJacobianActionCachedReference(displacementVariation, preparedReference); + CHECK(prepared_hydrostatic_displacement_test_utils::relative_error( + geometryUpdatedAction, preparedReference, f.mesh->GetComm()) < 2.0e-11); + CHECK(preparedOperator.GetTensorDisplacementElementCount() > 0); + CHECK(preparedOperator.GetDisplacementJacobianStatistics().applications == 4); } diff --git a/tests/operators/prepared_mass_normalization.cpp b/tests/operators/prepared_mass_normalization.cpp index 8f71b38..293b281 100644 --- a/tests/operators/prepared_mass_normalization.cpp +++ b/tests/operators/prepared_mass_normalization.cpp @@ -439,10 +439,26 @@ TEST_CASE( massOperator.Prepare({.targetMass = 1.0}, dependencies); const std::uint64_t preparationCount = massOperator.GetPreparationCount(); + CHECK(massOperator.GetJacobianRowPreparationCount() == 0); + const auto rhoDirection = gravityContext.GetDensityMap().gather( + mass_normalization_test_utils::make_density_direction(f, 0.67)); + const auto uDirection = gravityContext.GetDisplacementMap().gather( + mass_normalization_test_utils::make_displacement_direction(f, -0.53)); + auto checkRows = [&](std::uint64_t expectedBuilds) { + REQUIRE(massOperator.PrepareJacobianRows()); + CHECK(massOperator.GetJacobianRowPreparationCount() == expectedBuilds); + mfem::Vector actual, reference; + massOperator.ApplyCompleteJacobianAction(rhoDirection, uDirection, actual); + massOperator.ApplyCompleteJacobianActionReference(rhoDirection, uDirection, reference); + CHECK(mass_normalization_test_utils::relative_error(actual(0), reference(0)) < 2.0e-11); + CHECK(massOperator.GetJacobianRowPreparationCount() == expectedBuilds); + }; + checkRows(1); const auto repeated = massOperator.Prepare({.targetMass = 1.0}, dependencies); CHECK_FALSE(repeated.DidAnyWork()); CHECK(massOperator.GetPreparationCount() == preparationCount); + checkRows(1); ++gravityPotentialRevision; mass_normalization_test_utils::prepare_gravity_context( @@ -451,6 +467,7 @@ TEST_CASE( const auto potentialOnly = massOperator.Prepare({.targetMass = 1.0}, dependencies); CHECK_FALSE(potentialOnly.DidAnyWork()); + checkRows(1); const double residualBeforeTargetChange = mass_normalization_test_utils::residual_value(massOperator); const double massBeforeTargetChange = massOperator.GetCurrentMass(); @@ -463,6 +480,7 @@ TEST_CASE( CHECK_FALSE(targetOnly.refreshedGeometry); CHECK_FALSE(targetOnly.refreshedDensity); CHECK(massOperator.GetCurrentMass() == massBeforeTargetChange); + checkRows(1); CHECK( mass_normalization_test_utils::relative_error( mass_normalization_test_utils::residual_value(massOperator) - residualBeforeTargetChange, -0.4 @@ -481,6 +499,8 @@ TEST_CASE( CHECK(densityOnly.assembledResidual); CHECK_FALSE(densityOnly.refreshedGeometry); CHECK(massOperator.GetCurrentMass() != massBeforeDensityChange); + CHECK(massOperator.GetJacobianRowPreparationCount() == 1); + checkRows(2); displacement = mass_normalization_test_utils::make_displacement_direction(f, 0.87); ++dependencies.displacement.revision; @@ -492,6 +512,8 @@ TEST_CASE( CHECK(geometryOnly.refreshedGeometry); CHECK(geometryOnly.assembledResidual); CHECK_FALSE(geometryOnly.refreshedDensity); + CHECK(massOperator.GetJacobianRowPreparationCount() == 2); + checkRows(3); } TEST_CASE( @@ -587,6 +609,11 @@ TEST_CASE( massOperator.ApplyDisplacementJacobianAction(reducedDisplacementDirection, displacementAction); massOperator.ApplyCompleteJacobianAction(reducedDensityDirection, reducedDisplacementDirection, completeAction); + mfem::Vector preparedReference; + massOperator.ApplyCompleteJacobianActionReference( + reducedDensityDirection, reducedDisplacementDirection, preparedReference); + CHECK(mass_normalization_test_utils::relative_error(completeAction(0), preparedReference(0)) < 2.0e-11); + CHECK( mass_normalization_test_utils::relative_error(completeAction(0), densityAction(0) + displacementAction(0)) < 2.0e-15 diff --git a/tests/operators/prepared_pressure_force.cpp b/tests/operators/prepared_pressure_force.cpp index ca66a6f..b92a334 100644 --- a/tests/operators/prepared_pressure_force.cpp +++ b/tests/operators/prepared_pressure_force.cpp @@ -439,6 +439,15 @@ TEST_CASE( mfem::Vector kernelDisplacementActionTrue; preparedOperator.ApplyDisplacementJacobianAction(displacementDirection, preparedDisplacementAction); + REQUIRE(preparedOperator.GetTensorElementCount() > 0); + mfem::Vector cachedDisplacementAction; + preparedOperator.ApplyDisplacementJacobianActionCachedReference(displacementDirection, cachedDisplacementAction); + CHECK(prepared_pressure_force_test_utils::relative_difference( + preparedDisplacementAction, cachedDisplacementAction, f.mesh->GetComm()) < 2.0e-12); + mfem::Vector retainedDisplacementAction; + preparedOperator.ApplyDisplacementJacobianActionReference(displacementDirection, retainedDisplacementAction); + CHECK(prepared_pressure_force_test_utils::relative_difference( + preparedDisplacementAction, retainedDisplacementAction, f.mesh->GetComm()) < 2.0e-12); mean_field::operators::kernels::apply_pressure_force_displacement_action( f, *f.domainMapperStateless, equationOfState, enthalpyTrue, displacementDirectionTrue, displacementTrue, @@ -465,6 +474,30 @@ TEST_CASE( prepared_pressure_force_test_utils::relative_difference(fusedAction, expectedFusedAction, f.mesh->GetComm()) < 2.0e-12 ); + + mfem::Vector originalResidual, referenceResidual, referenceEnthalpy; + preparedOperator.BuildResidual(originalResidual); + const auto state = mean_field::operators::context::pressure_force::PressureForceStateView{ + .enthalpy = enthalpy, .displacement = displacement}; + const auto dependencies = prepared_pressure_force_test_utils::make_dependencies(); + CHECK(preparedOperator.PrepareReference(state, dependencies).DidAnyWork()); + preparedOperator.BuildResidual(referenceResidual); + preparedOperator.ApplyEnthalpyJacobianAction(enthalpyDirection, referenceEnthalpy); + CHECK(prepared_pressure_force_test_utils::relative_difference( + originalResidual, referenceResidual, f.mesh->GetComm()) < 2.0e-12); + CHECK(prepared_pressure_force_test_utils::relative_difference( + preparedEnthalpyAction, referenceEnthalpy, f.mesh->GetComm()) < 2.0e-12); + CHECK_FALSE(preparedOperator.PrepareReference(state, dependencies).DidAnyWork()); + CHECK(preparedOperator.PrepareCachedReference(state, dependencies).DidAnyWork()); + preparedOperator.BuildResidual(referenceResidual); + preparedOperator.ApplyEnthalpyJacobianAction(enthalpyDirection, referenceEnthalpy); + CHECK(prepared_pressure_force_test_utils::relative_difference( + originalResidual, referenceResidual, f.mesh->GetComm()) < 2.0e-12); + CHECK(prepared_pressure_force_test_utils::relative_difference( + preparedEnthalpyAction, referenceEnthalpy, f.mesh->GetComm()) < 2.0e-12); + CHECK_FALSE(preparedOperator.PrepareCachedReference(state, dependencies).DidAnyWork()); + CHECK(preparedOperator.Prepare(state, dependencies).DidAnyWork()); + CHECK_FALSE(preparedOperator.Prepare(state, dependencies).DidAnyWork()); } TEST_CASE( diff --git a/tests/operators/prepared_rotation_displacement_force.cpp b/tests/operators/prepared_rotation_displacement_force.cpp index 672bf81..dd7b133 100644 --- a/tests/operators/prepared_rotation_displacement_force.cpp +++ b/tests/operators/prepared_rotation_displacement_force.cpp @@ -551,6 +551,19 @@ TEST_CASE( CHECK(rotationReport.preparedResidual); CHECK(preparedOperator.GetResidualPreparationCount() == 3); CHECK(preparedOperator.GetResidualApplicationCount() == 1); + mfem::Vector updatedResidual, referenceResidual; + preparedOperator.BuildResidual(updatedResidual); + CHECK(preparedOperator.PrepareReference( + {.density = density, .displacement = displacement}, dependencies, rotation).DidAnyWork()); + preparedOperator.BuildResidual(referenceResidual); + CHECK(rotational_displacement_force_test_utils::relative_difference( + updatedResidual, referenceResidual, f.mesh->GetComm()) < 2.0e-12); + CHECK_FALSE(preparedOperator.PrepareReference( + {.density = density, .displacement = displacement}, dependencies, rotation).DidAnyWork()); + CHECK(preparedOperator.Prepare( + {.density = density, .displacement = displacement}, dependencies, rotation).DidAnyWork()); + CHECK_FALSE(preparedOperator.Prepare( + {.density = density, .displacement = displacement}, dependencies, rotation).DidAnyWork()); } TEST_CASE( @@ -598,6 +611,16 @@ TEST_CASE( preparedOperator.ApplyCompleteJacobianAction(densityDirection, displacementDirection, completeAction); + mfem::Vector retainedAction; + preparedOperator.ApplyCompleteJacobianActionReference(densityDirection, displacementDirection, retainedAction); + CHECK(preparedOperator.GetFastElementCount() > 0); + CHECK(preparedOperator.GetTensorElementCount() == preparedOperator.GetFastElementCount()); + CHECK(rotational_displacement_force_test_utils::relative_difference( + completeAction, retainedAction, f.mesh->GetComm()) < 2.0e-12); + preparedOperator.ApplyCompleteJacobianActionCachedReference(densityDirection, displacementDirection, retainedAction); + CHECK(rotational_displacement_force_test_utils::relative_difference( + completeAction, retainedAction, f.mesh->GetComm()) < 2.0e-12); + mfem::Vector summedColumns(densityAction); summedColumns += displacementAction; diff --git a/tests/preconditioning/gravity_field.cpp b/tests/preconditioning/gravity_field.cpp index 53a0172..abdcdb1 100644 --- a/tests/preconditioning/gravity_field.cpp +++ b/tests/preconditioning/gravity_field.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -313,6 +314,8 @@ TEST_CASE( preconditioning::GravityApproximateLDU{} ); auto chebyshevPrepared = preconditioning::prepare(geometry.finiteElements, geometry.context, chebyshevBlock); + CHECK(prepared.GetDivergenceAssemblyCount() == 1); + CHECK(chebyshevPrepared.GetDivergenceAssemblyCount() == 1); const auto unchanged = prepared.Refresh(geometry.finiteElements, geometry.context); const auto unchangedChebyshev = chebyshevPrepared.Refresh(geometry.finiteElements, geometry.context); @@ -338,6 +341,8 @@ TEST_CASE( const std::uint64_t sourcePreparations = geometry.context.GetSourceOperator().GetPreparationCount(); const auto changed = prepared.Refresh(geometry.finiteElements, geometry.context); const auto changedChebyshev = chebyshevPrepared.Refresh(geometry.finiteElements, geometry.context); + CHECK(prepared.GetDivergenceAssemblyCount() == 1); + CHECK(chebyshevPrepared.GetDivergenceAssemblyCount() == 1); CHECK(changed.geometryChanged); CHECK_FALSE(changed.discretizationChanged); @@ -377,6 +382,7 @@ TEST_CASE( CHECK(reconstructed.rebuiltPotentialSchur); CHECK(chebyshevPrepared.IsCurrent()); CHECK(chebyshevPrepared.GetMassInverse().GetStatistics().setups == 3); + CHECK(chebyshevPrepared.GetDivergenceAssemblyCount() == 2); mfem::Vector firstReconstructedAction(chebyshevPrepared.Height()); mfem::Vector secondReconstructedAction(chebyshevPrepared.Height()); @@ -391,3 +397,52 @@ TEST_CASE( CHECK(std::isfinite(firstReconstructedAction(index))); } } + +TEST_CASE( + "Fixed Gravity Divergence Cache Preserves Changing Mass Weights And Invalidates", + tags::preconditioning_gravity_integration +) { + const auto args = test_utils::setup_args(); + auto f = mean_field::fem::setup_fem(args.mesh_file, args, 0); + REQUIRE(f.okay()); + preconditioning::PreparedGravityDivergenceCache cache; + mfem::Vector diagonal(f.gravityFluxFes->GetTrueVSize()); diagonal = 1.0; + const auto x = gravity_prepared_test_utils::make_deterministic_vector(f.gravityPotentialFes->GetTrueVSize(), 0.37); + auto initial = preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal, &cache); + mfem::Vector initialAction(initial->Height()); initial->Mult(x, initialAction); + REQUIRE(initialAction.Norml2() > 0.0); + REQUIRE(cache.GetBuildCount() == 1); + for (double scale : {2.0, 0.5, 1.0}) { + CAPTURE(scale); + diagonal = scale; + auto fast = preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal, &cache); + auto reference = preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal); + mfem::Vector a(fast->Height()), b(reference->Height()), expected(initialAction); + fast->Mult(x, a); reference->Mult(x, b); expected /= scale; + CHECK(gravity_prepared_test_utils::relative_error(a, b, f.mesh->GetComm()) < 2.0e-12); + CHECK(gravity_prepared_test_utils::relative_error(a, expected, f.mesh->GetComm()) < 2.0e-12); + CHECK(cache.GetBuildCount() == 1); + initial->Mult(x, a); + CHECK(gravity_prepared_test_utils::relative_error(a, initialAction, f.mesh->GetComm()) < 2.0e-14); + } + for (double bad : {0.0, -1.0, std::numeric_limits::quiet_NaN()}) { + diagonal = bad; + CHECK_THROWS_AS( + preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal, &cache), std::invalid_argument); + CHECK(cache.GetBuildCount() == 1); + } + diagonal = 1.0; + const auto checkRebuild = [&] { + auto fast = preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal, &cache); + auto reference = preconditioning::assembleGravityPotentialSchurSurrogate(f, diagonal); + mfem::Vector a(fast->Height()), b(reference->Height()); + fast->Mult(x, a); reference->Mult(x, b); + CHECK(gravity_prepared_test_utils::relative_error(a, b, f.mesh->GetComm()) < 2.0e-12); + }; + cache.Clear(); checkRebuild(); + CHECK(cache.GetBuildCount() == 2); + f.mesh->NodesUpdated(); checkRebuild(); + CHECK(cache.GetBuildCount() == 3); + checkRebuild(); + CHECK(cache.GetBuildCount() == 3); +} diff --git a/tests/solver/stellar_equilibrium_runtime.cpp b/tests/solver/stellar_equilibrium_runtime.cpp index e12d622..0d6162f 100644 --- a/tests/solver/stellar_equilibrium_runtime.cpp +++ b/tests/solver/stellar_equilibrium_runtime.cpp @@ -1552,3 +1552,48 @@ TEST_CASE( CHECK(report.lastAcceptedCheckpointView().valid()); } } + +TEST_CASE("Skipping Geometry Preflight Retains Trial Acceptance And Fold Rejection", + "[solver][newton][geometry][preflight][backtracking][wiring]") { + using namespace mean_field; + using namespace stellar_solver_architecture_test; + for (bool folding : {false, true}) { + CAPTURE(folding); + auto finiteElements = makeFiniteElements(); + auto discretization = equilibrium::makeStellarDiscretization( + std::move(finiteElements), + normalization::PhysicalRieszDiagonal{dimensions::LengthValue{utils::RADIUS}, utils::G}); + auto context = solver::makeContext( + makeModel(), std::move(discretization), preconditioning::makePreconditioner(), + ScriptedBackend{nullptr, folding ? -10.0 : 0.0, false, true}); + auto metricState = std::make_shared(); + metricState->evaluations = {{.residualNorm = 2.0, .merit = 2.0}, {.residualNorm = 1.0, .merit = 0.5}}; + bool observed = false; + auto observer = solver::nonlinear::makeObserver( + [](const solver::nonlinear::BeforeIteration &) {}, + [&](const solver::nonlinear::AfterIteration &event) { + observed = true; + CHECK_FALSE(event.geometryPreflight.has_value()); + CHECK(event.geometryPreflightSeconds == 0.0); + CHECK(event.lineSearchTrials == 1); + CHECK(event.stepAccepted == !folding); + }); + auto newton = solver::nonlinear::Newton( + solver::nonlinear::NewtonOptions{ + .relativeTolerance = 0.0, .absoluteTolerance = 0.0, .maximumIterations = 1, + .linearSolve = {.relativeTolerance = 0.0, .absoluteTolerance = std::numeric_limits::max(), + .maximumIterations = 1}, + .backtracking = {.maximumTrials = 1, .geometryPreflight = false}}, + SequencedMetric{metricState}); + auto solverInstance = solver::make(context, std::move(newton), std::move(observer)); + const auto report = solverInstance.evaluate(); + CHECK(observed); + CHECK_FALSE(report.diagnostics().lastGeometryPreflight.has_value()); + CHECK(report.diagnostics().totalGeometryPreflightSeconds == 0.0); + CHECK(report.diagnostics().geometryLimitedIterations == 0); + CHECK(report.diagnostics().totalLineSearchTrials == 1); + CHECK(report.diagnostics().acceptedNonlinearIterations == (folding ? 0 : 1)); + CHECK(report.diagnostics().inadmissibleLineSearchTrials == (folding ? 1 : 0)); + CHECK(report.lastAcceptedCheckpointView().valid()); + } +}